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