Skip to main content
โšก Calmops

Mobile App Testing: Complete Guide to Quality Assurance

Mobile App Testing: Complete Guide to Quality Assurance

TL;DR: This comprehensive guide covers all aspects of mobile app testing including unit tests, UI testing, performance testing, and CI/CD integration. Learn best practices for building reliable mobile applications.


Introduction

Quality assurance is critical for mobile applications where users expect flawless experiences. Unlike web apps, mobile apps must work across thousands of device configurations, OS versions, and network conditions. This guide covers testing strategies for both iOS and Android platforms.


Testing Pyramid for Mobile

        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
        โ”‚     E2E     โ”‚  โ† Few, expensive, slow
        โ”‚   Tests     โ”‚
       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
       โ”‚ Integration  โ”‚  โ† Medium count
       โ”‚    Tests     โ”‚
      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
      โ”‚   Unit Tests   โ”‚  โ† Many, fast, cheap
      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Unit Testing

iOS - XCTest

import XCTest
@testable import MyApp

class UserServiceTests: XCTestCase {
    
    var sut: UserService!
    
    override func setUp() {
        super.setUp()
        sut = UserService()
    }
    
    override func tearDown() {
        sut = nil
        super.tearDown()
    }
    
    func testValidateEmail_WithValidEmail_ReturnsTrue() {
        let result = sut.validateEmail("[email protected]")
        XCTAssertTrue(result)
    }
    
    func testValidateEmail_WithInvalidEmail_ReturnsFalse() {
        let result = sut.validateEmail("invalid-email")
        XCTAssertFalse(result)
    }
    
    func testCalculateScore_WithValidInput_ReturnsCorrectScore() {
        let result = sut.calculateScore(points: 100, multiplier: 2)
        XCTAssertEqual(result, 200)
    }
}

Android - JUnit & Mockito

import org.junit.Before
import org.junit.Test
import org.mockito.Mockito.*
import kotlinx.coroutines.test.runBlockingTest

class UserServiceTest {
    
    private lateinit var userService: UserService
    private lateinit var mockRepository: UserRepository
    
    @Before
    fun setup() {
        mockRepository = mock(UserRepository::class.java)
        userService = UserService(mockRepository)
    }
    
    @Test
    fun `validateEmail with valid email returns true`() {
        val result = userService.validateEmail("[email protected]")
        assert(result)
    }
    
    @Test
    fun `getUser with valid ID returns user`() = runBlockingTest {
        val mockUser = User(id = 1, name = "John")
        `when`(mockRepository.getUser(1)).thenReturn(mockUser)
        
        val result = userService.getUser(1)
        
        assertEquals(mockUser, result)
    }
}

UI Testing

iOS - XCTest UI Automation

import XCTest

class LoginFlowTests: XCTestCase {
    
    var app: XCUIApplication!
    
    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["--uitesting", "true"]
        app.launch()
    }
    
    func testLoginFlow_WithValidCredentials_NavigatesToHome() {
        let emailTextField = app.textFields["emailTextField"]
        let passwordTextField = app.secureTextFields["passwordTextField"]
        let loginButton = app.buttons["loginButton"]
        
        emailTextField.tap()
        emailTextField.typeText("[email protected]")
        
        passwordTextField.tap()
        passwordTextField.typeText("password123")
        
        loginButton.tap()
        
        XCTAssertTrue(app.staticTexts["homeScreenTitle"].exists)
    }
    
    func testLoginFlow_WithInvalidEmail_ShowsError() {
        let emailTextField = app.textFields["emailTextField"]
        let loginButton = app.buttons["loginButton"]
        
        emailTextField.tap()
        emailTextField.typeText("invalid-email")
        
        loginButton.tap()
        
        XCTAssertTrue(app.alerts["Invalid Email"].exists)
    }
}

Android - Espresso

import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*

class LoginFlowTest {
    
    @get:Rule
    val activityScenarioRule = activityScenarioRule<LoginActivity>()
    
    @Test
    fun `login with valid credentials navigates to home`() {
        onView(withId(R.id.emailInput))
            .perform(typeText("[email protected]"))
        
        onView(withId(R.id.passwordInput))
            .perform(typeText("password123"))
        
        onView(withId(R.id.loginButton))
            .perform(click())
        
        onView(withId(R.id.homeScreenTitle))
            .check(matches(isDisplayed()))
    }
    
    @Test
    fun `login with invalid email shows error`() {
        onView(withId(R.id.emailInput))
            .perform(typeText("invalid-email"))
        
        onView(withId(R.id.loginButton))
            .perform(click())
        
        onView(withText("Invalid email address"))
            .check(matches(isDisplayed()))
    }
}

Integration Testing

iOS - Integration Tests

import XCTest

class APIIntegrationTests: XCTestCase {
    
    var apiClient: APIClient!
    
    override func setUp() {
        super.setUp()
        apiClient = APIClient(baseURL: "https://api.example.com")
    }
    
    func testFetchUsers_ReturnsUserList() async throws {
        let users = try await apiClient.fetchUsers()
        
        XCTAssertFalse(users.isEmpty)
        XCTAssertNotNil(users.first?.id)
    }
    
    func testCreateUser_WithValidData_ReturnsCreatedUser() async throws {
        let newUser = User(name: "John", email: "[email protected]")
        
        let createdUser = try await apiClient.createUser(newUser)
        
        XCTAssertEqual(createdUser.name, "John")
        XCTAssertEqual(createdUser.email, "[email protected]")
    }
}

Android - Integration Tests

import org.junit.Test
import kotlinx.coroutines.test.runBlockingTest

class UserRepositoryIntegrationTest {
    
    private val apiService = RetrofitClient.apiService
    private val repository = UserRepositoryImpl(apiService)
    
    @Test
    fun `fetchUsers returns non-empty list`() = runBlockingTest {
        val users = repository.getUsers()
        assert(users.isNotEmpty())
    }
    
    @Test
    fun `createUser returns created user`() = runBlockingTest {
        val newUser = User(name = "John", email = "[email protected]")
        val createdUser = repository.createUser(newUser)
        
        assertEquals("John", createdUser.name)
    }
}

Device Testing

Firebase Test Lab

# Run instrumentation tests on Firebase Test Lab
gcloud firebase test android run \
  --type instrumentation \
  --app app/build/outputs/apk/debug/app-debug.apk \
  --test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
  --devices model Pixel 4,version 30

BrowserStack Integration

# .browserstack.yml
platforms:
  - device: iPhone 13
    os_version: "15.0"
    browser: "Safari"
  - device: Samsung Galaxy S21
    os_version: "11.0"
    browser: "Chrome"

browserstack_username: "your_username"
browserstack_access_key: "your_access_key"

Performance Testing

iOS Performance Tests

import XCTest

class PerformanceTests: XCTestCase {
    
    func testListRendering_Performance() {
        measure {
            let dataSource = LargeListDataSource(itemCount: 10000)
            dataSource.prepareData()
        }
    }
    
    func testImageLoading_Performance() {
        measureMetrics([.wallClockTime, .cpu], automaticallyStartMeasuring: true) {
            let imageLoader = ImageLoader()
            imageLoader.loadImage(from: "https://example.com/large-image.jpg")
            stopMeasuring()
        }
    }
}

Android Performance Tests

import androidx.benchmark.junit4.BenchmarkRule
import org.junit.Rule
import org.junit.Test

class PerformanceBenchmark {
    
    @get:Rule
    val benchmarkRule = BenchmarkRule()
    
    @Test
    fun `list rendering performance benchmark`() {
        benchmarkRule.measureRepeated {
            val adapter = LargeListAdapter(10000)
            adapter.prepareData()
        }
    }
}

CI/CD Integration

GitHub Actions - iOS

name: iOS CI

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

jobs:
  test:
    runs-on: macos-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_15.0.app
      
      - name: Cache pods
        uses: actions/cache@v3
        with:
          path: Pods
          key: ${{ runner.os }}-pods-${{ hashFiles('Podfile.lock') }}
      
      - name: Install dependencies
        run: pod install
      
      - name: Run tests
        run: xcodebuild test -workspace App.xcworkspace -scheme App -destination 'platform=iOS Simulator,name=iPhone 15' test
      
      - name: Upload test results
        uses: actions/upload-artifact@v3
        with:
          name: test-results
          path: build/reports/tests/testDebugUnitTests/

GitHub Actions - Android

name: Android CI

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

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'
      
      - name: Cache Gradle
        uses: actions/cache@v3
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
      
      - name: Run tests
        run: ./gradlew testDebugUnitTest
      
      - name: Run lint
        run: ./gradlew lintDebug
      
      - name: Upload test results
        uses: actions/upload-artifact@v3
        with:
          name: test-results
          path: app/build/reports/tests/

Test Coverage

iOS - Code Coverage

// Enable coverage in scheme settings
// Run tests with coverage
xcodebuild test -workspace App.xcworkspace \
  -scheme App \
  -destination 'platform=iOS Simulator,name=iPhone 15' \
  -enableCodeCoverage YES

// Generate coverage report
xcrun xccov view --report \
  --json build/reports/index.json

Android - Code Coverage

// build.gradle
android {
    buildTypes {
        debug {
            enableUnitTestCoverage = true
        }
    }
}

// Run with coverage
./gradlew testDebugUnitTestCoverage

Best Practices

Do

  • Write tests before fixing bugs (regression tests)
  • Use descriptive test names
  • Test edge cases and error conditions
  • Run tests on real devices when possible
  • Automate tests in CI/CD pipelines

Don’t

  • Test implementation details
  • Write flaky tests
  • Ignore test failures
  • Skip UI testing for “speed”
  • Hardcode test data

Tools Summary

Purpose iOS Android
Unit Testing XCTest JUnit, Kotlin Test
UI Testing XCTest UI Espresso
Mocking OCMock, Mockolo Mockito, MockK
Integration XCTest Android Integration Tests
Performance XCTest Metrics Android Benchmark
CI/CD GitHub Actions GitHub Actions
Device Farm Firebase Test Lab Firebase Test Lab

Conclusion

Comprehensive mobile testing is essential for delivering quality apps. Implement a testing strategy that balances speed with coverage, and automate your CI/CD pipeline to catch issues early.

Comments