Introduction

Shipping bugs costs more than preventing them. A bug caught in development takes minutes to fix. The same bug found by users takes hours of support, reputation damage, and emergency patches. Good testing catches bugs before they reach users.

This guide covers the testing strategies and tools that work for mobile apps in 2025, with practical examples for both iOS and Android.

The Testing Pyramid

Not all tests are created equal. The testing pyramid guides where to invest:

          /\
         /  \
        / E2E \         <- Few, slow, expensive
       /--------\

      /Integration\     <- Some, moderate speed
     /--------------\
    /   Unit Tests   \  <- Many, fast, cheap
   /------------------\

Unit tests (70-80% of tests): Fast, isolated, test single functions/classes Integration tests (15-20%): Test component interactions E2E tests (5-10%): Test complete user flows

Unit Testing

Unit Testing Infographic

iOS Unit Testing (XCTest)

import XCTest
@testable import YourApp

final class OrderCalculatorTests: XCTestCase {

    var calculator: OrderCalculator!

    override func setUp() {
        super.setUp()
        calculator = OrderCalculator()
    }

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

    func testSubtotalCalculation() {
        let items = [
            OrderItem(name: "Widget", price: 10.00, quantity: 2),
            OrderItem(name: "Gadget", price: 25.00, quantity: 1)
        ]

        let subtotal = calculator.calculateSubtotal(items)

        XCTAssertEqual(subtotal, 45.00, accuracy: 0.01)
    }

    func testGSTCalculation() {
        let subtotal = 100.00

        let gst = calculator.calculateGST(subtotal)

        XCTAssertEqual(gst, 10.00, accuracy: 0.01) // 10% GST
    }

    func testEmptyCartSubtotal() {
        let items: [OrderItem] = []

        let subtotal = calculator.calculateSubtotal(items)

        XCTAssertEqual(subtotal, 0.00)
    }

    func testDiscountApplication() {
        let subtotal = 100.00
        let discount = Discount(type: .percentage, value: 20)

        let discounted = calculator.applyDiscount(to: subtotal, discount: discount)

        XCTAssertEqual(discounted, 80.00, accuracy: 0.01)
    }

    func testFreeShippingThreshold() {
        let subtotal = 150.00 // Above $100 threshold

        let shipping = calculator.calculateShipping(subtotal: subtotal)

        XCTAssertEqual(shipping, 0.00)
    }

    func testShippingBelowThreshold() {
        let subtotal = 50.00 // Below threshold

        let shipping = calculator.calculateShipping(subtotal: subtotal)

        XCTAssertEqual(shipping, 9.95, accuracy: 0.01) // Standard shipping
    }
}

Android Unit Testing (JUnit + Kotlin)

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

class OrderCalculatorTest {

    private lateinit var calculator: OrderCalculator

    @Before
    fun setUp() {
        calculator = OrderCalculator()
    }

    @Test
    fun `subtotal calculation with multiple items`() {
        val items = listOf(
            OrderItem(name = "Widget", price = 10.00, quantity = 2),
            OrderItem(name = "Gadget", price = 25.00, quantity = 1)
        )

        val subtotal = calculator.calculateSubtotal(items)

        assertEquals(45.00, subtotal, 0.01)
    }

    @Test
    fun `GST calculation at 10 percent`() {
        val subtotal = 100.00

        val gst = calculator.calculateGST(subtotal)

        assertEquals(10.00, gst, 0.01)
    }

    @Test
    fun `empty cart returns zero subtotal`() {
        val items = emptyList<OrderItem>()

        val subtotal = calculator.calculateSubtotal(items)

        assertEquals(0.00, subtotal, 0.01)
    }

    @Test
    fun `percentage discount applied correctly`() {
        val subtotal = 100.00
        val discount = Discount(type = DiscountType.PERCENTAGE, value = 20.0)

        val discounted = calculator.applyDiscount(subtotal, discount)

        assertEquals(80.00, discounted, 0.01)
    }

    @Test
    fun `free shipping above threshold`() {
        val subtotal = 150.00

        val shipping = calculator.calculateShipping(subtotal)

        assertEquals(0.00, shipping, 0.01)
    }

    @Test
    fun `standard shipping below threshold`() {
        val subtotal = 50.00

        val shipping = calculator.calculateShipping(subtotal)

        assertEquals(9.95, shipping, 0.01)
    }
}

Testing Async Code

iOS:

func testAsyncDataFetch() async throws {
    let service = ProductService(api: MockAPI())

    let products = try await service.fetchProducts()

    XCTAssertEqual(products.count, 3)
    XCTAssertEqual(products.first?.name, "Test Product")
}

// Or with expectations for callback-based code
func testCallbackBasedFetch() {
    let expectation = expectation(description: "Products loaded")
    let service = ProductService(api: MockAPI())

    service.fetchProducts { result in
        switch result {
        case .success(let products):
            XCTAssertEqual(products.count, 3)
        case .failure:
            XCTFail("Expected success")
        }
        expectation.fulfill()
    }

    wait(for: [expectation], timeout: 5.0)
}

Android:

import kotlinx.coroutines.test.runTest

@Test
fun `fetch products returns expected data`() = runTest {
    val mockApi = MockProductApi()
    val repository = ProductRepository(mockApi)

    val products = repository.getProducts()

    assertEquals(3, products.size)
    assertEquals("Test Product", products.first().name)
}

// With Turbine for Flow testing
@Test
fun `products flow emits loading then success`() = runTest {
    val viewModel = ProductViewModel(MockRepository())

    viewModel.products.test {
        assertEquals(UiState.Loading, awaitItem())
        assertEquals(UiState.Success(testProducts), awaitItem())
        cancelAndConsumeRemainingEvents()
    }
}

Integration Testing

Integration tests ver

ify that components work together correctly.

Testing Repository with Database (Android)

@RunWith(AndroidJUnit4::class)
class ProductRepositoryIntegrationTest {

    private lateinit var database: AppDatabase
    private lateinit var productDao: ProductDao
    private lateinit var repository: ProductRepository

    @Before
    fun setUp() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
            .allowMainThreadQueries()
            .build()
        productDao = database.productDao()
        repository = ProductRepository(productDao, MockApiService())
    }

    @After
    fun tearDown() {
        database.close()
    }

    @Test
    fun `refresh products updates database`() = runTest {
        // Initially empty
        assertTrue(productDao.getAll().first().isEmpty())

        // Refresh from API
        repository.refreshProducts()

        // Database now has products
        val products = productDao.getAll().first()
        assertEquals(3, products.size)
    }

    @Test
    fun `getProducts returns cached data when offline`() = runTest {
        // Pre-populate cache
        productDao.insertAll(testProducts)

        // Simulate offline
        val offlineRepository = ProductRepository(
            productDao,
            OfflineApiService() // Throws on any call
        )

        // Should return cached data
        val products = offlineRepository.getProducts()
        assertEquals(3, products.size)
    }
}

Testing Network Integration (iOS)

import XCTest
@testable import YourApp

final class APIClientIntegrationTests: XCTestCase {

    var client: APIClient!
    var mockServer: MockServer!

    override func setUp() async throws {
        mockServer = MockServer()
        try await mockServer.start()

        client = APIClient(baseURL: mockServer.url)
    }

    override func tearDown() async throws {
        await mockServer.stop()
    }

    func testFetchProductsIntegration() async throws {
        // Configure mock response
        mockServer.register(
            path: "/products",
            response: """
            {
                "data": [
                    {"id": "1", "name": "Widget", "price": 10.00},
                    {"id": "2", "name": "Gadget", "price": 25.00}
                ]
            }
            """
        )

        // Make real HTTP request to mock server
        let products = try await client.fetchProducts()

        XCTAssertEqual(products.count, 2)
        XCTAssertEqual(products[0].name, "Widget")
    }

    func testHandlesServerError() async {
        mockServer.register(
            path: "/products",
            statusCode: 500,
            response: """{"error": "Internal server error"}"""
        )

        do {
            _ = try await client.fetchProducts()
            XCTFail("Expected error")
        } catch {
            XCTAssertTrue(error is APIError)
        }
    }
}

UI Testing

iOS UI Testing (XCUITest)

import XCTest

final class LoginUITests: XCTestCase {

    var app: XCUIApplication!

    override func setUp() {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["--uitesting"]
        app.launch()
    }

    func testSuccessfulLogin() {
        // Navigate to login if needed
        if app.buttons["Sign In"].exists {
            app.buttons["Sign In"].tap()
        }

        // Enter credentials
        let emailField = app.textFields["email-field"]
        XCTAssertTrue(emailField.waitForExistence(timeout: 5))
        emailField.tap()
        emailField.typeText("[email protected]")

        let passwordField = app.secureTextFields["password-field"]
        passwordField.tap()
        passwordField.typeText("password123")

        // Submit
        app.buttons["login-button"].tap()

        // Verify success
        let homeScreen = app.otherElements["home-screen"]
        XCTAssertTrue(homeScreen.waitForExistence(timeout: 10))
    }

    func testLoginValidationError() {
        let emailField = app.textFields["email-field"]
        emailField.tap()
        emailField.typeText("invalid-email")

        app.buttons["login-button"].tap()

        // Error should appear
        let errorText = app.staticTexts["error-message"]
        XCTAssertTrue(errorText.waitForExistence(timeout: 5))
        XCTAssertTrue(errorText.label.contains("valid email"))
    }

    func testForgotPasswordFlow() {
        app.buttons["forgot-password"].tap()

        let resetEmailField = app.textFields["reset-email-field"]
        XCTAssertTrue(resetEmailField.waitForExistence(timeout: 5))

        resetEmailField.tap()
        resetEmailField.typeText("[email protected]")

        app.buttons["send-reset-link"].tap()

        // Success message
        let successMessage = app.staticTexts["Check your email"]
        XCTAssertTrue(successMessage.waitForExistence(timeout: 5))
    }
}

Android UI Testing (Espresso + Compose)

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.junit.Rule
import org.junit.Test

class LoginUITest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Test
    fun successfulLogin() {
        // Enter email
        composeTestRule
            .onNodeWithTag("email-field")
            .performTextInput("[email protected]")

        // Enter password
        composeTestRule
            .onNodeWithTag("password-field")
            .performTextInput("password123")

        // Submit
        composeTestRule
            .onNodeWithTag("login-button")
            .performClick()

        // Verify home screen
        composeTestRule
            .onNodeWithTag("home-screen")
            .assertIsDisplayed()
    }

    @Test
    fun loginShowsValidationError() {
        // Enter invalid email
        composeTestRule
            .onNodeWithTag("email-field")
            .performTextInput("invalid-email")

        // Submit without password
        composeTestRule
            .onNodeWithTag("login-button")
            .performClick()

        // Error should be visible
        composeTestRule
            .onNodeWithText("Please enter a valid email")
            .assertIsDisplayed()
    }

    @Test
    fun forgotPasswordFlow() {
        composeTestRule
            .onNodeWithText("Forgot Password?")
            .performClick()

        // Reset screen should appear
        composeTestRule
            .onNodeWithTag("reset-email-field")
            .assertIsDisplayed()

        composeTestRule
            .onNodeWithTag("reset-email-field")
            .performTextInput("[email protected]")

        composeTestRule
            .onNodeWithText("Send Reset Link")
            .performClick()

        // Success message
        composeTestRule
            .onNodeWithText("Check your email")
            .assertIsDisplayed()
    }
}

// Traditional View-based Espresso
@Test
fun loginWithEspresso() {
    onView(withId(R.id.email_field))
        .perform(typeText("[email protected]"), closeSoftKeyboard())

    onView(withId(R.id.password_field))
        .perform(typeText("password123"), closeSoftKeyboard())

    onView(withId(R.id.login_button))
        .perform(click())

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

Snapshot Testing

Snapshot tests catch unintended UI changes.

iOS Snapshot Testing

import XCTest
import SnapshotTesting
@testable import YourApp

final class ProductCardSnapshotTests: XCTestCase {

    func testProductCardDefault() {
        let product = Product(
            id: "1",
            name: "Test Product",
            price: 29.99,
            imageURL: nil
        )

        let view = ProductCardView(product: product)
        let hostingController = UIHostingController(rootView: view)
        hostingController.view.frame = CGRect(x: 0, y: 0, width: 375, height: 200)

        assertSnapshot(of: hostingController, as: .image)
    }

    func testProductCardOnSale() {
        let product = Product(
            id: "1",
            name: "Test Product",
            price: 29.99,
            originalPrice: 49.99,
            imageURL: nil
        )

        let view = ProductCardView(product: product)
        let hostingController = UIHostingController(rootView: view)
        hostingController.view.frame = CGRect(x: 0, y: 0, width: 375, height: 200)

        assertSnapshot(of: hostingController, as: .image)
    }

    func testProductCardDarkMode() {
        let product = Product(id: "1", name: "Test Product", price: 29.99, imageURL: nil)

        let view = ProductCardView(product: product)
        let hostingController = UIHostingController(rootView: view)
        hostingController.view.frame = CGRect(x: 0, y: 0, width: 375, height: 200)
        hostingController.overrideUserInterfaceStyle = .dark

        assertSnapshot(of: hostingController, as: .image)
    }
}

Android Snapshot Testing (Paparazzi)

import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test

class ProductCardSnapshotTest {

    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_5
    )

    @Test
    fun productCardDefault() {
        paparazzi.snapshot {
            ProductCard(
                product = testProduct,
                onAddToCart = {}
            )
        }
    }

    @Test
    fun productCardOnSale() {
        paparazzi.snapshot {
            ProductCard(
                product = testProduct.copy(
                    originalPrice = 49.99
                ),
                onAddToCart = {}
            )
        }
    }

    @Test
    fun productCardDarkMode() {
        paparazzi.snapshot {
            MyAppTheme(darkTheme = true) {
                ProductCard(
                    product = testProduct,
                    onAddToCart = {}
                )
            }
        }
    }
}

CI/CD Integration

GitHub Actions for iOS

# .github/workflows/ios-tests.yml
name: iOS Tests

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  test:
    runs-on: macos-14

    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_15.2.app

      - name: Cache SPM
        uses: actions/cache@v4
        with:
          path: .build
          key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}

      - name: Run Unit Tests
        run: |
          xcodebuild test \
            -scheme YourApp \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            -resultBundlePath TestResults.xcresult \
            | xcpretty

      - name: Upload Test Results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: TestResults.xcresult

  ui-tests:
    runs-on: macos-14
    needs: test

    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_15.2.app

      - name: Run UI Tests
        run: |
          xcodebuild test \
            -scheme YourAppUITests \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            -resultBundlePath UITestResults.xcresult \
            | xcpretty

      - name: Upload UI Test Results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: ui-test-results
          path: UITestResults.xcresult

GitHub Actions for Android

# .github/workflows/android-tests.yml
name: Android Tests

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Cache Gradle
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}

      - name: Run Unit Tests
        run: ./gradlew testDebugUnitTest

      - name: Upload Test Results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: unit-test-results
          path: app/build/reports/tests/

  instrumented-tests:
    runs-on: ubuntu-latest
    needs: unit-tests

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Run Instrumented Tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          arch: x86_64
          script: ./gradlew connectedDebugAndroidTest

      - name: Upload Test Results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: instrumented-test-results
          path: app/build/reports/androidTests/

Test Coverage

iOS Coverage with Xcode

# Generate coverage report
xcodebuild test \
  -scheme YourApp \
  -destination 'platform=iOS Simulator,name=iPhone 15' \
  -enableCodeCoverage YES

# View in Xcode: Product > Show Code Coverage

Android Coverage with JaCoCo

// build.gradle.kts
plugins {
    id("jacoco")
}

android {
    buildTypes {
        debug {
            enableUnitTestCoverage = true
            enableAndroidTestCoverage = true
        }
    }
}

tasks.register<JacocoReport>("jacocoTestReport") {
    dependsOn("testDebugUnitTest")

    reports {
        xml.required.set(true)
        html.required.set(true)
    }

    sourceDirectories.setFrom("src/main/java", "src/main/kotlin")
    classDirectories.setFrom(
        fileTree("build/tmp/kotlin-classes/debug")
    )
    executionData.setFrom("build/jacoco/testDebugUnitTest.exec")
}

Conclusion

Effective mobile testing requires a balanced approach:

  1. Unit tests form the foundation—fast, focused, and comprehensive
  2. Integration tests verify component interactions work correctly
  3. UI tests validate critical user flows
  4. Snapshot tests catch visual regressions
  5. CI/CD runs tests automatically on every change

Start with unit tests for business logic. Add integration tests for data flows. Include UI tests for critical paths. Run everything in CI so bugs never reach users.

The goal isn’t 100% coverage—it’s confidence that your app works. Focus testing effort on code that matters: business logic, data handling, and user-facing features. A well-tested core with strategic UI tests beats extensive but shallow test coverage.