How to Test File Upload on Android (Complete Guide)

File upload is a common touchpoint in Android apps—profile pictures, document sharing, media attachments, and backup flows all rely on moving a user‑selected file from the device to a server. When thi

June 12, 2026 · 17 min read · How-To Guides

Why File Upload Testing Matters on Android

File upload is a common touchpoint in Android apps—profile pictures, document sharing, media attachments, and backup flows all rely on moving a user‑selected file from the device to a server. When this path fails, the impact is immediate: users cannot complete a core task, they see cryptic error messages, or worse, the app crashes or leaks data.

In production, upload failures often stem from a combination of factors that unit tests miss:

Because these defects are triggered only when the app interacts with the real Android ecosystem (intents, storage providers, varying network stacks), a dedicated test strategy is essential. The following guide walks through a complete matrix, manual steps, automated scripts, and how autonomous, persona‑driven exploration can surface bugs that scripted tests never consider.

---

Building a Test Matrix for File Upload

A structured matrix helps you ensure coverage across functional, non‑functional, and security dimensions. Below is a comprehensive table that you can adapt to your app’s specific upload entry points (e.g., profile avatar, chat attachment, document upload).

Test CategorySub‑testObjectivePass CriteriaNotes
Happy PathSelect image from galleryVerify UI flows to picker, returns correct URI, upload succeedsFile appears on server with correct metadata, UI shows success toastUse a known‑good JPEG < 5 MB
Select PDF from DownloadsSame as above for document typeServer receives PDF, content‑type matches
Capture photo via camera intentConfirm camera flow works, file saved to temporary locationUpload succeeds, EXIF data preserved
Validation & Error HandlingNo file selected (cancel picker)App should not attempt upload, show appropriate messageNo network request, toast “No file selected”
Unsupported MIME type (e.g., .exe)App should reject before uploadValidation error shown, no upload request
Zero‑byte fileApp should detect empty fileError shown, no upload
File exceeds server limit (e.g., 20 MB limit, 25 MB file)Client should either block or show server‑side errorApp shows size‑limit message, does not waste bandwidth
Permission ScenariosPermission denied at runtimeApp should request permission, handle denial gracefullyPermission rationale shown, upload button disabled until grantedTest on Android 13 where MANAGE_EXTERNAL_STORAGE is restricted
Permission granted after denialAfter granting, upload should workSame as happy path
Network ConditionsWi‑Fi → LTE handoff mid‑uploadUpload should retry or fail with clear messageApp shows retry option or error after timeoutUse tc or network emulator
Airplane mode enabled before uploadNo network request, immediate offline errorToast “No internet connection”
High latency (300 ms+) with low bandwidthUpload takes longer but eventually finishes or times out per configApp respects timeout setting, shows progress
Concurrency & StressMultiple simultaneous uploads (e.g., batch select)All files queued, each tracked individuallyEach file gets own progress indicator, no crashes
Upload while app in backgroundService or WorkManager continues uploadUpload completes, notification updatesVerify no foreground service misuse
AccessibilityTalkBack navigation to upload buttonButton is focusable, label announces purposeAnnounces “Upload photo, button”
Color contrast on upload iconMeets WCAG AA (4.5:1)Contrast ratio verified with tools
Switch control activationUpload can be triggered via external switchNo extra steps needed
Security & PrivacyFile name with path traversal chars (../../evil.exe)Server sanitizes name; client does not crashUpload succeeds, stored under safe name
Embedded metadata (EXIF GPS)App strips or warns about sensitive dataNo location leaked unless user consents
Virus‑like content (EICAR test file)Backend may reject; client should not executeNo crash, appropriate server response
Performance & BatteryUpload of 50 MB video over Wi‑FiMeasure average upload speed, battery drainWithin expected thresholds (< 2 Mbps drain)Use adb shell dumpsys batterystats
Repeated uploads in loop (10×)No memory leak, no excessive wake locksStable memory, wake‑lock count returns to baseline

You can expand or shrink this matrix based on risk. The key is to treat each row as a test case that can be executed manually, automated, or verified by an autonomous explorer.

---

Manual Testing Approach: Step‑by‑Step

Manual testing remains valuable for exploratory checks, UI polish, and validating error messages that are hard to assert in code. Below is a repeatable workflow you can follow on a physical device or an emulator.

1. Prepare the Test Environment

ActionCommand / UIPurpose
Install the app under testadb install -r app-debug.apkEnsure latest build
Grant necessary storage permissions (pre‑emptively)adb shell pm grant com.example.app android.permission.READ_EXTERNAL_STORAGEAvoid flaky permission prompts
Clear app data to start cleanadb shell pm clear com.example.appReset state
Push test files to device storageadb push sample.jpg /sdcard/Download/Provide known assets
(Optional) Set up network shapingadb shell tc qdisc add dev wlan0 root netem delay 120ms limit 1000Simulate latency

2. Execute the Happy Path

  1. Launch the app and navigate to the upload screen (e.g., tap Profile → Change Photo).
  2. Tap the Upload button. The system file picker should appear.
  3. Choose Gallery → navigate to Download/sample.jpg → select it.
  4. Observe:
  1. Verify on the backend that the file arrived with correct name, size, and MIME type.

3. Inject Error Conditions

ScenarioStepsExpected Observation
Permission deniedRevoke storage permission via Settings → Apps → Your App → Permissions → Deny. Return to app and try upload.Permission rationale dialog appears; upload button stays disabled.
No file selectedTap upload, then immediately hit back/cancel in picker.No network request; toast “No file selected”.
Unsupported typePush test.exe to Downloads, select it.App shows “Invalid file type” before any upload attempt.
Zero‑byte fileCreate empty file: adb shell touch /sdcard/Download/empty.txt. Select it.App shows “File is empty”.
Network lossEnable Airplane mode after picker returns but before upload completes.Upload fails, retry option appears, or error toast shown.
Server‑side size limitAttempt upload of a 30 MB video when limit is 10 MB.Client either blocks locally (shows size error) or receives HTTP 413 and displays appropriate message.

4. Check UI Feedback and Logs

5. Accessibility Spot‑Check

6. Clean Up

Repeating this checklist for each upload entry point (avatar, chat, document) gives you confidence that the core flows behave as expected before you invest in automation.

---

Automated Testing with Espresso and UIAutomator

Instrumented tests run on the device or emulator and can validate both UI interactions and underlying behavior. Espresso excels at testing within your app’s UI, while UIAutomator is needed for system dialogs like the native file picker.

1. Project Setup

Add the following dependencies to your app/build.gradle:


dependencies {
    androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
    androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0"
    androidTestImplementation "androidx.test:runner:1.5.2"
    androidTestImplementation "androidx.test:rules:1.5.0"
    // Optional: MockWebServer for backend simulation
    testImplementation "com.squareup.okhttp3:mockwebserver:4.12.0"
}

Create a test class under src/androidTest/java/com/example/app/UploadTest.kt.

2. Helper to Grant Permissions


@Before
fun grantPermissions() {
    // Grant storage permission for API 23+
    val context = ApplicationProvider.getApplicationContext()
    if (ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.READ_EXTERNAL_STORAGE
        ) != PackageManager.PERMISSION_GRANTED) {
        GrantPermissionRule
            .grant(Manifest.permission.READ_EXTERNAL_STORAGE)
            .apply { }
    }
}

3. Espresso Test for Happy Path (Internal Picker)

If your app uses an internal file picker (e.g., a custom RecyclerView), you can test entirely with Espresso:


@Test
fun uploadImage_success() {
    // Launch the activity that contains the upload button
    val activityRule = ActivityScenarioRule(MainActivity::class.java)
    activityRule.scenario.onActivity { /* no extra setup needed */ }

    // Click the upload button
    onView(withId(R.id.btn_upload)).perform(click())

    // Verify that the custom picker opens (e.g., a RecyclerView with id rv_picker)
    onView(withId(R.id.rv_picker)).check(matches(isDisplayed()))

    // Choose the first item (assume it's a known test image)
    onView(withId(R.id.rv_picker))
        .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click()))

    // Verify progress bar appears
    onView(withId(R.id.progress_upload)).check(matches(isDisplayed()))

    // Wait for upload to finish – we mock the backend with MockWebServer
    // Assume the app makes a POST to /upload and expects 200 OK
    // Here we just idle until a specific view appears (e.g., success toast)
    onView(withText(R.string.upload_success))
        .withEffectiveVisibility(Visibility.VISIBLE)
        .check(matches(isDisplayed()))
}

4. UIAutomator for System File Picker

When the app invokes Intent.ACTION_GET_CONTENT or Intent.ACTION_OPEN_DOCUMENT, the system picker appears. UIAutomator can interact with it:


@Test
fun uploadViaSystemPicker_success() {
    val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

    // Launch the upload flow
    onView(withId(R.id.btn_upload)).perform(click())

    // Wait for the system picker to appear (title contains “Documents”)
    val pickerTitle = device.findObject(
        UiSelector().textContains("Documents")
    )
    assertTrue(pickerTitle.waitForExists(5000))

    // Navigate to Download folder
    val downloadFolder = device.findObject(
        UiSelector().text("Download")
    )
    downloadFolder.clickAndWaitForNewWindow()

    // Select the sample image (assume file name known)
    val sampleImg = device.findObject(
        UiSelector().textMatches("sample\\.jpg")
    )
    sampleImg.clickAndWaitForNewWindow()

    // Back in app – verify progress
    val progress = device.findObject(
        UiSelector().resourceId("com.example.app:id/progress_upload")
    )
    assertTrue(progress.waitForExists(10000))

    // Verify success toast
    val toast = device.findObject(
        UiSelector().className("android.widget.Toast")
    )
    assertTrue(toast.waitForExists(5000))
    assertTrue(toast.getText().contains("success"))
}

5. Handling Runtime Permissions in Tests

If the app requests permission at runtime, you can pre‑grant it using grantPermissionRule from androidx.test.ext.junit.rules:


@get:Rule
val permissionRule = GrantPermissionRule.grant(
    Manifest.permission.READ_EXTERNAL_STORAGE,
    Manifest.permission.CAMERA
)

6. Verifying Upload with MockWebServer

To avoid depending on a real backend, spin up a MockWebServer in @BeforeClass:


private lateinit var mockWebServer: MockWebServer

@BeforeClass
@JvmStatic
fun setUpServer() {
    mockWebServer = MockWebServer()
    mockWebServer.start()
    // Enqueue a 200 response with JSON indicating success
    mockWebServer.enqueue(
        MockResponse()
            .setResponseCode(200)
            .setBody("{\"status\":\"ok\"}")
    )
    // Optionally set the server URL in your app via manifest meta-data or DI
}

In your test, assert that the server received a request with the correct Content-Type and body (you can inspect mockWebServer.takeRequest()).

7. Running the Tests

Execute via Gradle:


./gradlew connectedAndroidTest   # runs all instrumented tests

Or target a specific class:


./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.app.UploadTest

Automated tests give you repeatable regression guards for the happy path, basic error handling, and permission flows. However, they cannot easily simulate variable network conditions, background execution nuances, or the myriad of third‑party file picker apps that users may have installed. That’s where manual exploration and autonomous testing add value.

---

Automated Testing with Appium (Cross‑platform)

Appium lets you drive the Android UI from a test script written in Java, JavaScript, Python, etc., and works equally well for native apps, hybrid WebViews, and pure web apps accessed via Chrome.

1. Desired Capabilities


DesiredCapabilities caps = new DesiredCapabilities();
caps.setCapability("platformName", "Android");
caps.setCapability("deviceName", "Pixel_4_API_33");
caps.setCapability("appPackage", "com.example.app");
caps.setCapability("appActivity", ".MainActivity");
caps.setCapability("automationName", "UiAutomator2");
caps.setCapability("grantPermissions", true); // auto‑grant dangerous perms
AndroidDriver<MobileElement> driver = new AndroidDriver<>(
        new URL("http://localhost:4723/wd/hub"), caps);
driver.manage().timeouts().implicitlyWait(10, TimeUnit.SECONDS);

2. Triggering the Upload Flow


// Click the upload button
driver.findElement(By.id("btn_upload")).click();

// Wait for the system picker to appear (context NATIVE_APP)
new WebDriverWait(driver, 15)
        .until(ExpectedConditions.elementToBeClickable(By.xpath("//android.widget.TextView[@text='Documents']")));

// Navigate to Downloads
driver.findElement(By.xpath("//android.widget.TextView[@text='Download']")).click();

// Select the file
driver.findElement(By.xpath("//android.widget.TextView[@text='sample.jpg']")).click();

3. Handling the Return to App

After picking a file, the system returns control to your app. You can wait for a UI element unique to the upload screen (e.g., a progress bar):


new WebDriverWait(driver, 20)
        .until(ExpectedConditions.visibilityOfElementLocated(By.id("progress_upload")));

4. Verifying Upload via Network Inspection

Appium can retrieve network logs if you enable Chromedriver logging for WebView or use the adb logcat approach:


LogEntries logEntries = driver.manage().logs().get("performance");
for (LogEntry entry : logEntries) {
    Json json = Json.parse(entry.getMessage());
    // Look for a request to your upload endpoint and check status
}

Alternatively, use a proxy like mitmproxy to capture requests and assert that a multipart/form‑data POST with the correct file part was sent.

5. Testing Error Scenarios

6. Sample JavaScript (WebDriverIO) Test


describe('File upload flow', () => {
    it('should upload a picture from gallery', async () => {
        await $('~btn_upload').click(); // accessibility id
        await driver.waitUntil(
            async => (await $('android=new UiSelector().textContains("Documents")')).isExisting(),
            { timeout: 15000, interval: 500 }
        );
        await $('android=new UiSelector().text("Download")').click();
        await $('android=new UiSelector().text("sample.jpg")').click();

        await driver.waitUntil(
            async => (await $('~progress_upload')).isExisting(),
            { timeout: 20000, interval: 500 }
        );

        const toast = await $('android.widget.Toast');
        await expect(toast).toHaveTextContaining('success');
    });
});

Appium shines when you need to test across multiple device configurations or when your app includes a WebView where the file input is an HTML . In that case, you can bypass the native picker entirely by using driver.setFileDetector(new LocalFileDetector()) and then driver.findElement(By.css("input[type='file']")).sendKeys("/path/to/sample.jpg");.

---

Leveraging SUSA for Persona‑Driven Exploration

SUSA (SUSATest) is an autonomous QA agent that explores an app without pre‑written scripts. It injects a variety of user personas—each with distinct behavior patterns—into the app and monitors for crashes, ANRs, accessibility violations, security issues, and UX friction. When applied to file upload flows, SUSA can surface problems that scripted tests often miss because it does not follow a predetermined path; it tries combinations that real users might attempt out of curiosity, impatience, or confusion.

1. How SUSA Approaches an Upload Screen

When SUSA launches your app, it builds a state model of screens, buttons, and intents. For an upload screen it will:

Each action is logged, and SUSA checks for:

2. Running SUSA via CLI


pip install susatest-agent
susatest run \
    --apk path/to/app-debug.apk \
    --personas curious impatient novice accessibility \
    --target upload \
    --output-dir ./susa-reports

The --target upload flag tells SUSA to prioritize screens that contain an upload button or an intent filter for ACTION_GET_CONTENT. You can omit the flag to let it explore the whole app; it will still hit upload flows as part of its exploration.

3. Cross‑Session Learning

Susa stores a knowledge base of visited UI states and dead ends. On a subsequent run it:

This learning loop means that over time Susa becomes better at finding edge‑case bugs that are rare but high‑impact, such as a crash that only occurs when a user picks a file, rotates the screen, and then quickly cancels the picker.

4. What SUSA Finds That Scripts Miss

Issue TypeWhy Scripts May Miss ItHow Susa Catches It
Race condition between picker cancellation and UI state resetScripts usually follow a linear sequence; they rarely interleave cancel actions with rapid UI changes.The impatient persona taps cancel while the picker is still animating, exposing a null‑reference.
File‑provider URI permission leak on Android 10+Automated tests often grant broad storage permission, masking the need to take persistable permission.Susa’s novice persona does not grant storage permission, leading to a SecurityException when trying to open the URI.
TalkBack label missing on dynamically generated preview thumbnailsAccessibility tests may only check static layout XML.The accessibility persona navigates via TalkBack and reports “unlabeled button” on the preview image.
Server‑side validation bypass via filename with null bytesUnit tests may sanitize strings but not test raw byte injection.The adversarial persona sends a file whose name includes \0; the app crashes when passing the name to a native library.
Battery drain from a WakeLock held after upload completionPerformance tests rarely run long enough to observe lingering wakelocks.Susa’s power‑user persona repeats uploads ten times and checks dumpsys power for stale wakelocks.

Because SUSA treats the app as a black box and explores it with varied, semi‑randomized inputs, it complements both manual exploratory testing and automated regression suites.

---

Edge Cases That Surface Only in Production

Even with thorough lab testing, certain conditions only appear when the app runs in the wild. Below is a table of production‑only edge cases, the symptoms they cause, and the techniques you can use to detect or mitigate them.

Production Edge CaseTypical SymptomDetection / Mitigation Strategy
Variable network handoff (Wi‑Fi ↔ LTE)Upload stalls, then fails after a long timeout; user sees stale spinner.Use NetworkCallback to listen for CAPABILITY_NOT_METERED changes; implement exponential backoff and retry with cancelable UploadService.
Background execution limits (Android 9+)Upload stops when app goes to background; user thinks it completed.Use WorkManager with setExpedited(true) for urgent uploads, or a foreground service with a persistent notification.
Scoped storage & MediaStore access (Android 10+)FileNotFoundException when trying to read a content:// URI returned by the picker.Always open URIs via ContentResolver.openInputStream(uri); never assume a file path.
File provider URI permission not persistedSubsequent attempts to re‑upload the same file fail with PermissionDenial.Call takePersistableUriPermission(uri, flag) after picking; store the permission grant across app restarts.
Right‑to‑left (RTL) layout mirroringUpload icon appears misaligned; talkback reads wrong label.Test with adb shell setprop persist.sys.layout_direction RTL and verify layout in layout inspector.
Locale‑specific file name encodingFile names with non‑Latin characters appear garbled on server.Normalize file names to UTF‑8, validate on server, and optionally transliterate for storage.
Carrier‑level traffic shaping (e.g., LTE throttling after certain data usage)Upload speed drops dramatically after a few MB, causing timeout.Detect throughput via ConnectivityManager.getNetworkCapabilities().getLinkDownstreamBandwidthKbps(); adjust chunk size or notify user.
Battery optimisation whitelist removalSystem kills upload service during Doze mode.Request REQUEST_IGNORE_BATTERY_OPTIMIZATIONS and guide user to whitelist the app; use setAndAllowWhileIdle() for alarms if needed.
Concurrent uploads from multiple apps (e.g., two chat apps trying to use the same file picker)Picker returns ActivityNotFoundException or wrong MIME type.Use Intent.createChooser with explicit MIME filters; handle ActivityNotFoundException gracefully.
Unexpected file picker apps (e.g., a file manager that returns a content:// URI with no openable column)App crashes when querying DISPLAY_NAME.Query the URI for OpenableColumns.DISPLAY_NAME and OpenableColumns.SIZE; fall back to displaying a generic name if unavailable.
SD card ejection mid‑uploadIOException: No such file or directory; app may leak a partial upload.Monitor MediaScannerConnection for MEDIA_EJECT intent; abort ongoing uploads and inform user.
Thermal throttling causing CPU slowdownUpload takes much longer, leading to user-perceived lag.Use ThermalManager.getCurrentTemperature() to adjust thread priority or show a warning when temperature exceeds threshold.

To catch many of these issues in a lab, you can:

---

Checklist for Shipping File Upload Features

Before you mark an upload feature as “ready for release,” run through this concise checklist. It consolidates the most critical items from the matrix, manual steps, and automated guards.

AreaItemHow to Verify
FunctionalHappy path works for image, PDF, camera captureManual happy‑path test + automated Espresso/UIAutomator test
Cancellation does not trigger uploadVerify no network request in logcat/MockWebServer
Unsupported MIME type blocked before uploadShow validation toast, no network call
Zero‑byte file rejectedShow “File is empty” message
File size limit enforced locally or with appropriate server errorShow size‑limit toast; avoid wasted upload
PermissionsRuntime permission request shown when deniedRevoke permission, attempt upload, see rationale dialog
Permission granted after denial enables uploadGrant via Settings, retry upload
Persistable URI permission taken for content URIsCheck contentResolver.takePersistableUriPermission call
NetworkUpload survives Wi‑Fi → LTE handoffUse network shaping tool, assert retry or proper error
Offline state yields clear errorEnable Airplane mode, verify toast
High latency does not crash appAdd 300 ms delay, ensure timeout handling
ConcurrencyMultiple files queued, each trackedSelect several images, verify independent progress bars
Background upload completesPress home, check notification, verify server receipt
AccessibilityUpload button TalkBack label correctEnable TalkBack, navigate, hear label
Sufficient color contrastUse Accessibility Scanner or Android Studio inspector
Switch control activation worksPair external switch, trigger upload
SecurityFile name with path traversal sanitized

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.

Try SUSA Free