How to Test Image Upload on Android (Complete Guide)

Image upload is a feature that touches many layers of an Android app: UI, storage, networking, permissions, and backend services. When it fails, users cannot share photos, profile pictures, or documen

March 22, 2026 · 18 min read · How-To Guides

Why Image Upload Testing Is Critical

Image upload is a feature that touches many layers of an Android app: UI, storage, networking, permissions, and backend services. When it fails, users cannot share photos, profile pictures, or documents, which often blocks core flows such as account creation, messaging, or e‑commerce checkout. A broken upload can also expose security holes—malicious files may be accepted, or sensitive data may be leaked through improper handling of EXIF metadata. Because the feature relies on external factors (device camera quality, available storage, network latency, MIME type detection), defects frequently appear only after release, under real‑world conditions that unit tests never see. Therefore a disciplined testing strategy that combines manual exploration, automated scripts, and autonomous persona‑driven analysis is essential to catch regressions before they impact users.

Common Ways Image Upload Breaks in Production

Understanding typical failure modes helps focus test effort. The list below aggregates issues observed across multiple Android apps in the wild.

Failure CategoryTypical SymptomRoot Cause
Permission misuseUpload button disabled or crashes when deniedApp does not handle RESULT_DENIED from runtime permission request
Incorrect MIME detectionBackend rejects file with “unsupported type”App sends application/octet-stream instead of image/jpeg
Size limits not enforcedOOM crash or ANR when selecting >10 MB photoNo client‑side size check; backend returns 413 but app ignores
Rotation/exif lossUploaded image appears sideways or stripped of orientationBitmap decoding discards EXIF orientation matrix
Storage access scoped failureFileUriExposedException on Android 10+Using file:// URIs instead of content:// from Storage Access Framework
Network interceptUpload stalls, retries indefinitelyMissing timeout or exponential backoff; socket hangs on flaky Wi‑Fi
Race conditionDuplicate uploads or missing thumbnailsUI triggers upload before previous request completes; no request deduplication
Accessibility blockTalkBack users cannot reach the “Choose file” buttonButton lacks content‑description or is hidden behind a overlay
Security bypassMalicious APK or script uploaded as imageBackend relies solely on client‑side extension check
Localization glitchUpload dialog shows English strings in Japanese localeHard‑coded strings or missing resource qualifiers

Each of these categories can be exercised with a combination of manual checks, automated assertions, and exploratory runs that simulate varied user behaviors.

Comprehensive Test Matrix for Image Upload

The matrix below organizes test scenarios by dimension (happy path, error paths, edge cases, accessibility, security) and indicates the recommended verification method (M = manual, A = automated, P = persona‑driven autonomous). Use it as a baseline; add project‑specific rows as needed.

IDScenarioDescriptionExpected OutcomeVerification
H1Happy path – gallery pickUser selects a JPEG from gallery, confirms, upload succeeds200 OK, thumbnail appears, EXIF preservedA (Espresso) + M (visual check)
H2Happy path – camera captureUser takes photo, crops, uploadsSame as H1, image oriented correctlyA (UIAutomator) + M
H3Happy path – drag‑and‑drop (tablet)User drags image from file manager into upload zoneUpload proceeds, no crashP (curious persona)
E1Denied permissionUser denies READ_EXTERNAL_STORAGE when promptedPermission rationale shown, upload button stays disabledA (Espresso)
E2Revoked permission at runtimePermission granted initially, then revoked via Settings while app in foregroundApp shows snackbar, blocks upload, does not crashP (impatient persona)
E3Zero‑byte fileUser selects an empty file (created via file manager)Backend returns 400, app shows error toastA (Appium)
E4Unsupported MIMEUser attempts to upload a .pdf or .zip fileBackend rejects, app displays “invalid file type”M
E5File size > limitUser selects a 15 MB photo when limit is 5 MBClient shows size‑exceeded dialog, no network callA (Espresso)
E6No storage spaceDevice storage < 10 MB, user tries to pick imagePicker fails gracefully, app shows low‑storage warningP (elderly persona)
E7Rotation/orientationUser picks a portrait photo taken with camera held sidewaysUploaded image displays upright in UI, EXIF Orientation tag retainedA (Instrumentation test with BitmapFactory)
E8Network loss mid‑uploadWi‑Fi turned off after 50 % of bytes sentApp shows retry option, does not leak partial fileA (OkHttp mock web server)
E9Slow network (3G simulation)Upload takes >30 s, user navigates away and backUpload persists, progress bar updates correctlyP (power user)
E10Concurrent uploadsUser rapidly taps upload button 5 timesOnly one request sent, others debouncedA (Espresso with IdlingResource)
A1TalkBack navigationUser enables TalkBack, navigates to upload button via swipeButton announces “Upload image, button”, double‑tap opens pickerA (AccessibilityTestSuite)
A2Color contrastUpload button meets 4.5:1 contrast against backgroundVerified with contrast analyzerM (using Android Studio’s Layout Inspector)
A3Touch target sizeButton dimensions ≥48 dpVerified with UI Automator view hierarchyA
S1Extension spoofingUser renames a .exe to .jpg and attempts uploadBackend rejects based on MIME sniffing, not extensionP (adversarial persona)
S2Metadata exfiltrationImage contains GPS coordinates in EXIFApp strips location before upload (if privacy policy requires)M (exiftool check)
S3File injection via content providerMalicious app sends a content:// URI that points to a private fileApp fails to open URI or shows permission errorP (novice persona)
S4TLS interceptionUpload endpoint uses self‑signed cert; app accepts invalid certConnection fails, app shows network errorA (OkHttp certificate pinning test)
L1Locale switchUser changes device language to right‑to‑left (Arabic) mid‑sessionLayout mirrors correctly, button remains accessibleP (elderly persona)
L2Dark modeSystem theme set to darkUpload UI adapts, icons remain visibleM
R1App upgradeUser upgrades from v1.2 to v1.3 while an upload is pendingPending upload resumes or is cleared according to specP (power user)
R2Backup/restoreUser backs up app data via ADB, restores on new deviceUpload history and temporary files are handled correctlyA (adb backup/restore script)

How to read the matrix

You can prioritize rows based on risk: H1/H2, E1/E2/E5/E6, A1‑A3, S1‑S2 are usually high‑impact.

Manual Testing Step‑by‑Step Guide

Manual exploration remains valuable for discovering UX friction, permission flows, and device‑specific quirks. Follow this procedure on a physical device or emulator with Google Play services installed.

  1. Prepare the test environment
  1. Happy path via gallery
  1. Happy path via camera
  1. Permission denial flow
  1. Permission revocation at runtime
  1. Size limit enforcement
  1. Network failure simulation
  1. Accessibility checks
  1. Security probing
  1. Cleanup

Manual testing shines when you need to observe subtle UI glitches, verify that error messages are user‑friendly, or confirm that the app behaves correctly under interruptions (incoming call, screen rotation). Combine it with automated checks for regression safety.

Automated Testing on Android: Tools and Techniques

Automated tests give fast feedback on regressions and can be run on every commit. For image upload, you need to interact with the system picker, handle permissions, and assert network behavior. Below are the most effective Android‑specific approaches, each with a ready‑to‑run snippet.

1. Espresso with IdlingResource for Network Calls

Espresso runs in‑process UI tests and is ideal for validating UI state and simple permission flows. Use an IdlingResource that monitors OkHttp or Volley callbacks to know when an upload finishes.


// UploadIdlingResource.kt
class UploadIdlingResource(
    private val callback: () -> Unit
) : IdlingResource {
    private var resourceCallback: IdlingResource.ResourceCallback? = null
    private var idle = true

    init {
        // Assume you have a singleton UploadManager exposing a LiveData<UploadState>
        UploadManager.getInstance().uploadState.observeForever { state ->
            if (state == UploadState.IN_PROGRESS) {
                idle = false
            } else if (state == UploadState.COMPLETED || state == UploadState.FAILED) {
                idle = true
                resourceCallback?.onTransitionToIdle()
            }
        }
    }

    override fun getName() = "UploadIdlingResource"
    override fun isIdleNow() = idle
    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
        this.resourceCallback = callback
    }
}

// ImageUploadTest.kt
@RunWith(AndroidJUnit4::class)
class ImageUploadTest {

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

    private lateinit var idlingResource: UploadIdlingResource

    @Before
    fun registerIdling() {
        idlingResource = UploadIdlingResource { }
        IdlingRegistry.getInstance().register(idlingResource)
    }

    @After
    fun unregisterIdling() {
        IdlingRegistry.getInstance().unregister(idlingResource)
    }

    @Test
    fun happyPath_galleryUpload_showsImage() {
        // Launch the activity
        val activityScenario = ActivityScenario.launch(MainActivity::class.java)

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

        // System picker appears – use UiDevice to interact because Espresso cannot
        // access system dialogs directly
        val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
        uiDevice.wait(Until.hasObject(By.text("Gallery")), 5000)
        uiDevice.findObject(By.text("Gallery")).click()

        // Choose first image in grid
        uiDevice.wait(Until.hasObject(By.className("android.widget.ImageView")), 5000)
        uiDevice.findObject(By.className("android.widget.ImageView")).click()

        // Confirm crop if needed
        uiDevice.wait(Until.hasObject(By.text("Save")), 5000)
        uiDevice.findObject(By.text("Save")).click()

        // Wait for upload to complete via IdlingResource (implicitly handled)
        onView(withId(R.id.image_preview)).check(matches(isDisplayed()))
        onView(withId(R.id.image_preview)).check(matches(withDrawable(R.drawable.expected_image))) // custom matcher
    }
}

Why this works

2. UIAutomator for Robust System Dialog Interaction

UIAutomator can launch arbitrary activities and is better suited for cross‑app flows like picking images from DocumentsUI or handling the camera intent.


// ImageUploadUiAutomatorTest.java
@RunWith(AndroidJUnit4.class)
public class ImageUploadUiAutomatorTest {

    private UiDevice device;

    @Before
    public void setUp() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
        // Clear app data to start fresh
        Context ctx = InstrumentationRegistry.getTargetContext();
        ctx.deleteDatabase("app.db");
        ShellUtils.runCommand("pm clear com.example.app");
    }

    @Test
    public void testCameraUpload_orientationCorrect() throws Exception {
        // Launch main activity
        Intent intent = new Intent();
        intent.setClassName("com.example.app", "com.example.app.MainActivity");
        device.executeShellCommand("am start -n " + intent.getComponent().flattenToString());

        // Click upload button
        device.findObject(new UiSelector().resourceId("com.example.app:id/btn_upload_image"))
                .click()
                .waitForExists(2000);

        // Choose "Take photo"
        device.findObject(new UiSelector().text("Take photo")).click();

        // Handle camera app – set a simple scene (e.g., rotate device to landscape)
        device.setOrientationLeft(); // simulate landscape
        Thread.sleep(1500); // give camera time to adjust
        device.pressKeyCode(KeyEvent.KEYCODE_CAMERA); // capture

        // Confirm
        device.findObject(new UiSelector().text("OK")).click();

        // Wait for upload completion (poll a TextView that shows "Uploaded")
        UiObject2 status = device.wait(Until.findObject(
                new UiSelector().resourceId("com.example.app:id/tv_upload_status")), 10000);
        assertEquals("Uploaded", status.getText());

        // Verify orientation: pull the uploaded file and check EXIF
        String path = "/sdcard/Pictures/SusaTest/uploaded.jpg";
        device.executeShellCommand("cp " + path + " /data/local/tmp/");
        PullFile pull = new PullFile("/data/local/tmp/uploaded.jpg", new File(Environment.getExternalStorageDirectory(),
                "uploaded_check.jpg"));
        pull.start();
        pull.join(5000);
        ExifInterface exif = new ExifInterface(new File(Environment.getExternalStorageDirectory(),
                "uploaded_check.jpg").getAbsolutePath());
        int orientation = exif.getAttributeInt(
                ExifInterface.TAG_ORIENTATION,
                ExifInterface.ORIENTATION_NORMAL);
        assertTrue(orientation == ExifInterface.ORIENTATION_ROTATE_90 ||
                orientation == ExifInterface.ORIENTATION_ROTATE_270);
    }
}

Key points

3. Appium for Cross‑Platform Scripts (Optional)

If you maintain both Android and iOS test suites, Appium provides a unified JSON Wire Protocol. The same test can be written in JavaScript, Python, or Java.


# test_image_upload_appium.py
import time
from appium import webdriver
from appium.webdriver.common.mobileby import MobileBy
from appium.webdriver.common.touch_action import TouchAction

desired_caps = {
    "platformName": "Android",
    "deviceName": "Pixel_4_API_33",
    "appPackage": "com.example.app",
    "appActivity": ".MainActivity",
    "automationName": "UiAutomator2",
    "noReset": True,
}

driver = webdriver.Remote("http://localhost:4723/wd/hub", desired_caps)

def test_upload_from_gallery():
    driver.find_element(MobileBy.ID, "btn_upload_image").click()
    time.sleep(1)

    # Switch to native picker context
    driver.start_activity("com.android.documentsui", ".FilesActivity")
    time.sleep(1)

    # Navigate to Pictures folder and select first image
    driver.find_element(MobileBy.ANDROID_UIAUTOMATOR,
        'new UiSelector().resourceId("com.android.documentsui:id/icon_thumb")').click()
    time.sleep(1)

    driver.find_element(MobileBy.ANDROID_UIAUTOMATOR,
        'new UiSelector().description("Done")').click()

    # Wait for upload completion
    WebDriverWait(driver, 20).until(
        EC.visibility_of_element_located((MobileBy.ID, "tv_upload_status"))
    )
    assert driver.find_element(MobileBy.ID, "tv_upload_status").text == "Uploaded"

    driver.quit()

Appium shines when you need to run the same script on multiple device farms or integrate with CI pipelines that already host an Appium server.

4. Backend Contract Tests with MockWebServer

To avoid flaky reliance on a real server, enqueue expected responses and verify request bodies (including multipart boundaries). This also lets you assert that the client sends correct MIME types and headers.


// UploadContractTest.kt
@MediumTest
@RunWith(AndroidJUnit4::class)
class UploadContractTest {

    private lateinit var mockWebServer: MockWebServer
    private lateinit var uploadRepository: UploadRepository

    @Before
    fun setUp() {
        mockWebServer = MockWebServer()
        mockWebServer.start()
        val okHttpClient = OkHttpClient.Builder()
            .url(mockWebServer.url("/upload"))
            .build()
        uploadRepository = UploadRepository(okHttpClient)
    }

    @After
    fun tearDown() = mockWebServer.shutdown()

    @Test
    fun `upload sends correct multipart with image/jpeg`() {
        // Enqueue 200 OK with empty body
        mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}"))

        // Create a temporary JPEG file
        val jpegFile = File.createTempFile("test", ".jpg")
        jpegFile.writeBytes(byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()))

        // Trigger upload
        uploadRepository.uploadImage(jpegFile).await()

        // Take the recorded request
        val recorded = mockWebServer.takeRequest()
        assertEquals("POST", recorded.method)
        assertTrue(recorded.getHeader("Content-Type").startsWith("multipart/form-data; boundary="))

        val body = recorded.body.readUtf8()
        assertTrue(body.contains("Content-Disposition: form-data; name=\"file\"; filename=\"test.jpg\""))
        assertTrue(body.contains("Content-Type: image/jpeg"))
        assertTrue(body.contains("\r\n\r\n")) // ensure file data present
    }
}

These contract tests guarantee that the client never drifts from the expected API contract, catching bugs like wrong MIME type or missing authorization header before they reach production.

Autonomous Persona‑Driven Exploration with SUSA

While scripted tests cover anticipated flows, real users exhibit unpredictable behavior—rapid tapping, unusual navigation patterns, or deliberate attempts to misuse features. SUSA (SUSATest) autonomously explores an app using a set of defined personas, each with a distinct behavior model. It can surface image‑upload bugs that scripted tests never consider because they fall outside the pre‑defined action sequences.

How SUSA Works in a Nutshell

  1. Ingestion – You provide an APK or a URL. SUSA installs the app on a cloud‑hosted Android emulator or a real device lab.
  2. Persona Engine – Eight built‑in personas (curious, impatient, novice, adversarial, elderly, accessibility, power user, and security tester) each possess a probability distribution over actions such as tap, long‑press, swipe, voice input, permission toggling, and app backgrounding.
  3. Exploration Loop – SUSA maintains a visited‑state graph (screen + UI hierarchy). From each state it selects an action weighted by the current persona’s profile, executes it, observes the result (new screen, crash, ANR, toast, logcat error), and updates the graph.
  4. Issue Detection – Built‑in detectors watch for:
  1. Learning – After each run, SUSA records dead ends and successfully completed flows. Subsequent runs bias exploration toward under‑tested areas, increasing coverage over time.
  2. Artifact Generation – From the explored graph, SUSA auto‑generates regression scripts: Appium for Android UI and Playwright for web views, enabling you to lock‑in the discovered paths.

Applying SUSA to Image Upload

When you point SUSA at an app that contains an image upload button, the following persona‑driven scenarios often emerge automatically:

PersonaTypical BehaviorDiscovered Issue Example
CuriousTaps every visible element, opens overflow menus, tries long‑press on the upload buttonFinds a hidden “Upload from URL” field that lacks validation, leading to SSRF when a malicious URL is supplied
ImpatientRapid double‑tap, quickly backgrounds app, returns after a few secondsDetects a race condition where the upload button becomes enabled before the previous request finishes, causing duplicate uploads
NoviceFollows on‑screen hints, avoids advanced gestures, frequently presses backReveals that the back button dismisses the picker without clearing a temporary file, leaving orphaned cache
AdversarialAttempts to inject special characters, tries to upload non‑image files, toggles permissions mid‑flowIdentifies that the app accepts a file renamed .jpg but containing a script, because backend relies solely on extension
ElderlyUses larger touch targets, prefers slower interactions, often enables system font scalingShows that the upload button’s hit‑area shrinks when font size is increased, making it hard to tap
AccessibilityRelies on TalkBack, uses explore‑by‑touch, expects audible feedbackDetects that the upload button lacks a content‑description, causing TalkBack to announce “unlabeled button”
Power userUses shortcuts, drags‑and‑drops from file manager, rotates device frequentlyFinds that dragging a file from a third‑party file manager onto the upload zone triggers a FileUriExposedException on Android 11 due to improper content:// handling
Security testerScans for clear‑text traffic, attempts to man‑in‑the‑middle with self‑signed certs, checks for exposed logsDiscovers that the app logs the full multipart payload (including base64‑encoded image) to Logcat, exposing potentially sensitive images

SUSA’s output includes a detailed report with screenshots, logcat excerpts, and the exact sequence of actions that led to each finding. You can then convert those sequences into automated regression tests (Espresso/UIAutomator) or address the root cause directly.

Integrating SUSA into CI

Add a step that runs the CLI agent against your latest APK:


pip install susatest-agent
susatest run \
    --apk path/to/app-release.apk \
    --personas curious,impatient,adversarial \
    --max-depth 6 \
    --output-dir ./susareport

The agent exits with a non‑zero status if any high‑severity issue (crash, ANR, security leak) is detected, causing the build to fail. Over time, you’ll notice the “explored screens” count rise, indicating growing coverage without writing additional test code.

Edge Cases That Only Appear in Production

Even the most thorough test matrix can miss issues that arise from the interplay of device fragmentation, carrier‑specific behavior, or user‑generated content. Below are production‑only edge cases that have caused outages in real apps, together with concrete detection strategies.

Edge CaseWhy It Happens in the WildDetection Approach
OEM‑specific gallery appsSome manufacturers replace the stock picker with a custom UI that returns a content:// URI lacking a readable file path. Your app may try to open the URI with FileInputStream, causing a FileNotFoundException.Use a device farm (e.g., Firebase Test Lab) to run the upload flow on a sampling of OEM images (Samsung, Xiaomi, OnePlus). Verify that the URI is opened via ContentResolver.openInputStream(uri).
MMS‑compressed imagesWhen users share images via messaging apps, the file may be recompressed to lower quality, stripping EXIF or altering dimensions. Your thumbnail generation logic might assume the original dimensions.Send an MMS‑compressed JPEG through a carrier simulator, upload, and assert that the displayed image’s aspect ratio matches the server‑returned metadata (width/height fields).
VPN or proxy interferenceEnterprise networks often enforce SSL inspection, presenting a different certificate chain. If your app uses certificate pinning incorrectly, the upload fails silently.Configure an emulator to use a HTTP proxy with a custom CA cert, enable network security config that allows user‑installed CAs for debug builds, and verify that the upload either succeeds (if pinning is disabled) or shows a clear error message.
Background location upload restrictionAndroid 12+ restricts background access to precise location; if your app extracts GPS from EXIF and tries to send it while in the background, the system may drop the request.Simulate a background upload (press Home, then trigger upload via a notification action) and check Logcat for LocationManager: Provider gps disabled or a network error.
File system case‑sensitivity on emulatorsSome CI emulators use a case‑sensitive ext4 image, while most physical devices use case‑folded vfat. A hard‑coded path like /sdcard/Picture/IMG.JPG may fail on emulators.Run the upload test on both a default Android emulator and a Google Play services image; assert that file‑access related exceptions do not appear.
Large burst uploads from rapid burst modeCamera burst mode can produce 10‑20 images in under a second. If the user selects all of them, your app may attempt to concatenate them into a single multipart request, exceeding payload limits.Use a script to generate a burst folder (e.g., for i in {1..15}; do cp base.jpg burst_$i.jpg; done) and attempt to upload the entire set via the picker’s multi‑select mode (if supported). Verify that either the app rejects excess files with a clear toast or splits them into separate requests respecting server limits.
Time‑zone shift during uploadA user changes device time zone while an upload is in progress; if your client timestamps the file using System.currentTimeMillis() and the server validates against a skewed clock, the request may be rejected as expired.Change the time zone via adb shell setpersist persist.sys.timezone America/New_York mid‑upload (using a background thread to issue the command) and ensure the upload still succeeds or yields a meaningful “request expired” error rather than a cryptic 500.
Low‑RAM killerOn low‑end devices, the system may kill your app’s process after the picker returns but before you handle the result, leading to a null data Intent.Use adb shell am kill com.example.app immediately after invoking startActivityForResult for the picker, then verify that onActivityResult receives resultCode = RESULT_CANCELED and that the app does not crash.
Network reconnection with captive portalPublic Wi‑Fi often redirects to a login page; if your app does not detect the captive portal, the upload may appear to hang.Connect the test device to a Wi‑Fi hotspot that serves a captive portal page (you can emulate with hostapd + a simple HTTP server that returns 200 with a login HTML). Ensure that the app shows a network‑error dialog and offers a “Retry” button after the portal is cleared.

Detecting these issues requires a combination of device‑farm testing, simulated network conditions, and intentional system state changes (time zone, permissions, kill signals). Incorporate them into your nightly test suite or into a periodic exploratory run with SUSA, which already varies device models, OS versions, and network profiles.

Accessibility and Security Considerations

Image upload touches two critical non‑functional areas: accessibility (ensuring all users can interact) and security/privacy (preventing data leakage or malicious file acceptance). Below are focused checklists you can embed in your test plans.

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