Skip to content
Mobile App Testing: Strategies and Tools

Mobile App Testing: Strategies and Tools

DodaTech Updated Jun 20, 2026 9 min read

Mobile app testing is the systematic process of verifying that mobile applications function correctly, perform well, and remain secure across diverse devices, OS versions, and network conditions — using unit tests, UI tests, device labs, and crash reporting.

What You’ll Learn

You’ll implement unit tests with XCTest (iOS) and JUnit (Android), UI tests with XCUITest and Espresso, screenshot tests for visual regression, test on real devices with Firebase Test Lab and BrowserStack, set up crash reporting with Crashlytics and Sentry, and integrate testing into CI/CD pipelines.

Why Mobile App Testing Matters

Mobile apps run on thousands of different device and OS combinations. A bug that crashes on one device but not another can destroy your app’s reputation. Doda Browser must work flawlessly across Android and iOS. Durga Antivirus Pro’s scanning engine is tested against thousands of malware samples before every release. Testing isn’t optional — it’s what separates professional apps from hobby projects.

Mobile App Testing Learning Path

    flowchart LR
  A[Android / iOS Development] --> B[Mobile Security]
  B --> C[Push Notifications]
  C --> D[Mobile App Testing]
  D --> E[App Store Deployment]
  D:::current
  classDef current fill:#f90,color:#fff,stroke:#333,stroke-width:2px
  
Prerequisites: A working Android or iOS app, basic knowledge of testing concepts (assertions, test runners), and Xcode or Android Studio installed.

The Mobile Testing Pyramid

    flowchart TD
    subgraph "E2E / UI Tests (Slow, Brittle)"
        UI["XCUITest / Espresso"]
    end
    subgraph "Integration Tests (Medium)"
        INT["API tests, database tests"]
    end
    subgraph "Unit Tests (Fast, Reliable)"
        UNIT["XCTest / JUnit"]
    end
    UI --> INT --> UNIT
    style UNIT fill:#4caf50,color:#fff
    style INT fill:#ff9800,color:#fff
    style UI fill:#f44336,color:#fff
  

The ideal ratio: 70% unit tests, 20% integration tests, 10% UI/E2E tests. Unit tests are fast and reliable — they catch most bugs early. UI tests are slow and flaky — use them sparingly for critical user flows.

Unit Testing

iOS: XCTest

import XCTest
@testable import DodaBrowser

final class SearchManagerTests: XCTestCase {
    var searchManager: SearchManager!

    override func setUp() {
        super.setUp()
        searchManager = SearchManager()
    }

    override func tearDown() {
        searchManager = nil
        super.tearDown()
    }

    func testValidURLDetection() {
        let validURLs = ["https://dodatech.com", "http://example.com/path"]
        for url in validURLs {
            XCTAssertTrue(searchManager.isValidURL(url), "\(url) should be valid")
        }
    }

    func testInvalidURLDetection() {
        let invalidURLs = ["not a url", "htp://bad", ""]
        for url in invalidURLs {
            XCTAssertFalse(searchManager.isValidURL(url), "\(url) should be invalid")
        }
    }

    func testSearchQueryConstruction() {
        let query = searchManager.constructSearchURL("DodaTech tutorial")
        XCTAssertEqual(query.absoluteString, "https://search.dodatech.com?q=DodaTech%20tutorial")
    }

    func testPerformanceExample() {
        measure {
            // Measure how long this takes
            _ = searchManager.constructSearchURL("performance test query that is quite long")
        }
    }
}

Expected output:

Test Suite 'SearchManagerTests' started
  ✅ testValidURLDetection (0.002s)
  ✅ testInvalidURLDetection (0.001s)
  ✅ testSearchQueryConstruction (0.001s)
  ✅ testPerformanceExample (0.015s)
Test Suite 'SearchManagerTests' passed

Android: JUnit + Mockito

import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito.*

class SearchManagerTest {
    private lateinit var searchManager: SearchManager
    private lateinit var apiService: ApiService

    @Before
    fun setUp() {
        apiService = mock(ApiService::class.java)
        searchManager = SearchManager(apiService)
    }

    @Test
    fun `search returns results on success`() {
        // Arrange
        val expectedResults = listOf(
            SearchResult("DodaTech", "https://dodatech.com"),
            SearchResult("Durga Antivirus", "https://durga-av.com")
        )
        `when`(apiService.search("DodaTech")).thenReturn(expectedResults)

        // Act
        val results = searchManager.search("DodaTech")

        // Assert
        assertEquals(2, results.size)
        assertEquals("DodaTech", results[0].title)
        verify(apiService).search("DodaTech")
    }

    @Test
    fun `search returns empty list on network error`() {
        `when`(apiService.search("error")).thenThrow(NetworkException("Timeout"))
        val results = searchManager.search("error")
        assertTrue(results.isEmpty())
    }

    @Test(expected = IllegalArgumentException::class)
    fun `search throws on empty query`() {
        searchManager.search("")
    }
}

UI Testing

iOS: XCUITest

import XCTest

class LoginUITests: XCTestCase {
    let app = XCUIApplication()

    override func setUp() {
        continueAfterFailure = false
        app.launch()
    }

    func testSuccessfulLogin() {
        // Fill in credentials
        let emailField = app.textFields["emailField"]
        emailField.tap()
        emailField.typeText("user@dodatech.com")

        let passwordField = app.secureTextFields["passwordField"]
        passwordField.tap()
        passwordField.typeText("SecurePass123!")

        // Tap login button
        app.buttons["loginButton"].tap()

        // Verify we navigated to the dashboard
        let dashboard = app.staticTexts["dashboardTitle"]
        XCTAssertTrue(dashboard.waitForExistence(timeout: 5))
    }

    func testEmptyEmailShowsError() {
        app.buttons["loginButton"].tap()

        let errorLabel = app.staticTexts["errorLabel"]
        XCTAssertTrue(errorLabel.exists)
        XCTAssertEqual(errorLabel.label, "Email is required")
    }
}

Android: Espresso

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.rules.ActivityScenarioRule
import org.junit.Rule
import org.junit.Test

class LoginUITest {
    @get:Rule
    val activityRule = ActivityScenarioRule(LoginActivity::class.java)

    @Test
    fun successfulLogin() {
        onView(withId(R.id.emailInput)).perform(typeText("user@dodatech.com"))
        onView(withId(R.id.passwordInput)).perform(typeText("SecurePass123!"))
        onView(withId(R.id.loginButton)).perform(click())

        onView(withId(R.id.dashboardTitle)).check(matches(isDisplayed()))
    }

    @Test
    fun emptyEmailShowsError() {
        onView(withId(R.id.loginButton)).perform(click())
        onView(withId(R.id.emailError)).check(matches(withText("Email is required")))
    }
}

Screenshot Testing

Screenshot tests capture UI screens and compare them against baselines to detect visual regressions.

iOS: SnapshotTesting (Point-Free)

import SnapshotTesting
import XCTest

class ProfileViewSnapshotTests: XCTestCase {
    func testProfileView() {
        let view = ProfileView(user: .mock())
        let vc = UIHostingController(rootView: view)

        assertSnapshot(matching: vc, as: .image(on: .iPhone13Pro))
        assertSnapshot(matching: vc, as: .image(on: .iPhoneSE))
        assertSnapshot(matching: vc, as: .image(on: .iPadPro11))
    }

    func testProfileViewDarkMode() {
        let view = ProfileView(user: .mock())
            .environment(\.colorScheme, .dark)
        let vc = UIHostingController(rootView: view)

        assertSnapshot(matching: vc, as: .image(on: .iPhone13Pro))
    }
}

Android: Paparazzi

class ProfileViewSnapshotTest {
    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_6,
        theme = "Theme.DodaTech",
        showSystemUi = false
    )

    @Test
    fun profileView_light() {
        paparazzi.snapshot {
            ProfileView(user = User.mock())
        }
    }

    @Test
    fun profileView_dark() {
        paparazzi.snapshot {
            DodaTechTheme(darkTheme = true) {
                ProfileView(user = User.mock())
            }
        }
    }
}

Device Labs: Testing on Real Devices

Firebase Test Lab

# Authenticate
gcloud auth login

# Run tests on Firebase
gcloud firebase test android run \
  --app app/build/outputs/apk/debug/app-debug.apk \
  --test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
  --device model=Pixel6,version=31,locale=en,orientation=portrait \
  --device model=SamsungS22,version=33,locale=en,orientation=portrait \
  --timeout 30m

BrowserStack (Manual + Automated)

# BrowserStack App Automate
export BROWSERSTACK_USERNAME="your_username"
export BROWSERSTACK_ACCESS_KEY="your_key"

# Upload app
curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \
  -X POST "https://api-cloud.browserstack.com/app-automate/upload" \
  -F "file=@app-debug.apk"

# Run tests on specific devices

Crash Reporting

Firebase Crashlytics

// Android setup
// build.gradle.kts
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")

// In app
FirebaseCrashlytics.getInstance().log("User tapped login button")
FirebaseCrashlytics.getInstance().setUserId(userId)
// iOS setup
// AppDelegate
FirebaseApp.configure()
Crashlytics.crashlytics().setUserID(userId)
Crashlytics.crashlytics().log("Payment processing started")

Sentry

// Android
SentryAndroid.init(this) { options ->
    options.dsn = "https://example@sentry.io/project-id"
    options.tracesSampleRate = 1.0
}

// iOS
SentrySDK.start { options in
    options.dsn = "https://example@sentry.io/project-id"
    options.tracesSampleRate = 1.0
}

CI/CD Pipeline Integration

# GitHub Actions — Mobile CI/CD
name: Mobile CI

on:
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Run iOS Unit Tests
        run: |
          xcodebuild test \
            -workspace DodaBrowser.xcworkspace \
            -scheme DodaBrowser \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            -testPlan UnitTests

      - name: Run Android Unit Tests
        working-directory: ./android
        run: ./gradlew testDebugUnitTest

  ui-tests:
    needs: unit-tests
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Run iOS UI Tests
        run: |
          xcodebuild test \
            -workspace DodaBrowser.xcworkspace \
            -scheme DodaBrowser \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            -testPlan UITests

      - name: Run Android UI Tests on Firebase
        run: |
          gcloud firebase test android run \
            --app app/build/outputs/apk/debug/app-debug.apk \
            --test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk

Common Mobile Testing Errors

1. Flaky UI Tests from Timing Issues

UI tests that sometimes pass, sometimes fail are worse than no tests. Use proper waits (waitForExistence on iOS, IdlingResource on Android) instead of fixed delays.

2. Not Testing on Real Devices

Emulators and simulators don’t have real sensors, network conditions, or memory constraints. A test that passes on the simulator may crash on a real device.

3. Insufficient Test Coverage for Edge Cases

Testing only the “happy path” misses 90% of bugs. Test empty states, network errors, permission denials, and boundary conditions.

4. Ignoring Performance and Memory Tests

A UI test doesn’t catch memory leaks. Profile your app with Instruments (iOS) or Memory Profiler (Android) regularly, especially for list views and image-heavy screens.

5. Not Automating Regression Tests

Manual regression testing doesn’t scale. Every bug fix should include a test that reproduces the bug, preventing regression.

6. Testing on Only Latest OS Versions

iOS 17 and Android 14 may work fine, but the app crashes on iOS 15 or Android 11. Test across the OS versions your app supports (typically the last 2-3 major versions).

7. Ignoring Crash Reports in Production

A crash in production that you don’t act on is a lost user. Set up real-time alerts for crash rate increases. Investigate and fix crashes before they affect more users.

Practice Questions

1. What’s the difference between unit tests and UI tests?

Unit tests verify individual functions/classes in isolation (fast, reliable). UI tests verify user-facing behavior through the app’s UI (slow, flaky). Unit tests should outnumber UI tests 7:1.

2. Why are flaky UI tests problematic?

Flaky tests erode trust in the test suite. Developers learn to ignore test failures, which means real bugs slip through. Fix flaky tests immediately or remove them.

3. What’s the purpose of crash reporting tools like Crashlytics?

They capture stack traces, device state, and user actions leading up to crashes — giving developers the information needed to reproduce and fix production crashes.

4. How do you test on multiple device configurations efficiently?

Use cloud device labs (Firebase Test Lab, BrowserStack, Sauce Labs) that provide hundreds of real device/OS combinations. Run tests in parallel for faster results.

5. Challenge: Set up a complete testing pipeline.

Create a testing strategy for a social media app that includes: unit tests for the authentication logic, UI tests for the login flow and news feed, screenshot tests for the profile screen, device lab testing on 20 devices, and crash reporting with alerts for >0.1% crash rate. Integrate everything into a CI pipeline that blocks merging if tests fail.

FAQ

How many tests do I need?
Aim for test coverage of critical paths (auth, payments, data persistence). 70-80% line coverage is a good target. Quality matters more than quantity — a well-written test that catches a real bug is worth more than 100 trivial tests.
Should I test UI or test logic?
Both, but focus on logic. A well-tested business layer with thin UI is more robust than the reverse. Use the MVVM or MVI pattern to make business logic testable without the UI.
How do I handle network requests in tests?
Mock the network layer. Use URLProtocol (iOS) or MockWebServer (Android) to return predefined responses. Never make real network calls in tests — they’re slow, unreliable, and cost money.
What’s the best CI/CD tool for mobile apps?
GitHub Actions, GitLab CI, and Bitrise are popular. Fastlane handles mobile-specific tasks (code signing, app store upload). Choose the one that integrates best with your existing workflow.

Try It Yourself

Set up a testing workflow for your app:

  1. Write 3 unit tests for a core function (e.g., input validation)
  2. Write 1 UI test for a critical user flow (e.g., login)
  3. Set up Firebase Crashlytics or Sentry
  4. Configure a CI pipeline that runs tests on every PR
  5. Review crash reports after your next release

What’s Next

A comprehensive testing strategy prevents bugs, improves code quality, and gives you confidence to release frequently. Start with unit tests for your core logic, add UI tests for critical flows, and set up crash reporting before your next release. Every hour spent on testing saves ten hours of debugging in production.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro