Back to all articles

Mobile App Testing & QA: Complete Quality Assurance Guide 2025

Poor app quality costs businesses an average of $1.6 million annually in lost revenue and reputation damage. With 88% of users abandoning apps after encountering bugs, comprehensive testing is not optional—it's essential for success. This guide covers everything from unit tests to beta testing for bulletproof mobile apps.

Why Mobile Testing Matters

Cost of Poor Quality

  • 1-star reviews from bugs hurt downloads by 70%+
  • Fixing bugs post-release costs 15x more than catching early
  • 88% of users abandon apps after bad experience
  • Average cost of app failure: $1.6 million annually
  • App store may remove apps with high crash rates
  • Critical bugs can damage brand reputation permanently

Benefits of Comprehensive Testing

  • Better quality: Catch issues before users do
  • Higher ratings: Fewer bugs = better reviews
  • Reduced costs: Fix bugs early when cheap
  • Faster releases: Confidence to ship quickly
  • Better UX: Smooth, reliable experience
  • Compliance: Meet app store requirements

Testing Types Overview

Testing Pyramid

        /\
       /  \    UI Tests (10%)
      /    \   - End-to-end flows
     /------\  - User scenarios
    /        \
   /          \ Integration Tests (20%)
  /            \ - Component interactions
 /              \ - API integration
/----------------\
                  Unit Tests (70%)
                  - Individual functions
                  - Business logic
                  - Edge cases

Principle: More unit tests, fewer UI tests
Why: Unit tests are:
- Faster to run
- Easier to maintain
- More reliable
- Cheaper to write
- Better isolation

Unit Testing

iOS Unit Testing (XCTest)

Setup and Structure

import XCTest
@testable import YourApp

class UserManagerTests: XCTestCase {

    var sut: UserManager!  // System Under Test

    override func setUp() {
        super.setUp()
        sut = UserManager()
    }

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

    func testUserCreation() {
        // Given
        let name = "John Doe"
        let email = "[email protected]"

        // When
        let user = sut.createUser(name: name, email: email)

        // Then
        XCTAssertEqual(user.name, name)
        XCTAssertEqual(user.email, email)
        XCTAssertNotNil(user.id)
    }

    func testInvalidEmail() {
        // Given
        let invalidEmail = "invalid-email"

        // When/Then
        XCTAssertThrowsError(
            try sut.createUser(name: "John", email: invalidEmail)
        ) { error in
            XCTAssertEqual(
                error as? ValidationError,
                ValidationError.invalidEmail
            )
        }
    }
}

Best practices:
✓ One assert per test (ideally)
✓ Test names describe what they test
✓ Arrange-Act-Assert pattern
✓ Independent tests (no shared state)
✓ Fast execution (< 1 second per test)
✓ Deterministic (same result every time)

Testing Async Code

// Swift async/await testing
func testAsyncDataFetch() async throws {
    // Given
    let expectedData = [User(id: "1", name: "John")]

    // When
    let users = try await sut.fetchUsers()

    // Then
    XCTAssertEqual(users, expectedData)
}

// Completion handler testing
func testCompletionHandler() {
    // Given
    let expectation = expectation(description: "Fetch users")
    var fetchedUsers: [User]?

    // When
    sut.fetchUsers { users in
        fetchedUsers = users
        expectation.fulfill()
    }

    // Then
    waitForExpectations(timeout: 5)
    XCTAssertNotNil(fetchedUsers)
    XCTAssertFalse(fetchedUsers!.isEmpty)
}

// Combine testing
func testCombinePublisher() {
    // Given
    let expectation = expectation(description: "Receive value")
    var receivedValue: Int?
    var cancellables = Set()

    // When
    sut.valuePublisher
        .sink { value in
            receivedValue = value
            expectation.fulfill()
        }
        .store(in: &cancellables)

    // Then
    waitForExpectations(timeout: 5)
    XCTAssertEqual(receivedValue, 42)
}

Android Unit Testing (JUnit)

Setup and Structure

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

class UserManagerTest {

    private lateinit var userManager: UserManager

    @Before
    fun setUp() {
        userManager = UserManager()
    }

    @After
    fun tearDown() {
        // Clean up
    }

    @Test
    fun `create user with valid data`() {
        // Given
        val name = "John Doe"
        val email = "[email protected]"

        // When
        val user = userManager.createUser(name, email)

        // Then
        assertEquals(name, user.name)
        assertEquals(email, user.email)
        assertNotNull(user.id)
    }

    @Test(expected = ValidationException::class)
    fun `create user with invalid email throws exception`() {
        userManager.createUser("John", "invalid-email")
    }
}

// Testing coroutines
@Test
fun `fetch users returns data`() = runBlocking {
    // Given
    val expectedUsers = listOf(User("1", "John"))

    // When
    val users = userManager.fetchUsers()

    // Then
    assertEquals(expectedUsers, users)
}

// Testing LiveData
@Test
fun `user livedata emits value`() {
    // Given
    val observer = Observer { }
    viewModel.userLiveData.observeForever(observer)

    // When
    viewModel.loadUser("123")

    // Then
    val user = viewModel.userLiveData.value
    assertNotNull(user)
    assertEquals("123", user?.id)

    // Clean up
    viewModel.userLiveData.removeObserver(observer)
}

Test Doubles

Mocking

iOS (Mockingbird):
// Protocol for mocking
protocol UserService {
    func fetchUser(id: String) async throws -> User
}

// Mock implementation
class MockUserService: UserService {
    var fetchUserCalled = false
    var userToReturn: User?
    var errorToThrow: Error?

    func fetchUser(id: String) async throws -> User {
        fetchUserCalled = true

        if let error = errorToThrow {
            throw error
        }

        return userToReturn ?? User(id: id, name: "Mock User")
    }
}

// Usage in test
func testUserFetch() async throws {
    // Given
    let mockService = MockUserService()
    mockService.userToReturn = User(id: "123", name: "John")
    let viewModel = UserViewModel(service: mockService)

    // When
    try await viewModel.loadUser(id: "123")

    // Then
    XCTAssertTrue(mockService.fetchUserCalled)
    XCTAssertEqual(viewModel.user?.name, "John")
}

Android (Mockito):
// Create mock
val mockUserService = mock(UserService::class.java)

// Define behavior
`when`(mockUserService.fetchUser("123"))
    .thenReturn(User("123", "John"))

// Use in test
val viewModel = UserViewModel(mockUserService)
viewModel.loadUser("123")

// Verify interactions
verify(mockUserService).fetchUser("123")
verify(mockUserService, times(1)).fetchUser(any())

// Verify no interactions
verifyNoMoreInteractions(mockUserService)

Integration Testing

API Integration Tests

iOS:
func testAPIFetch() async throws {
    // Given
    let apiClient = APIClient(baseURL: testServerURL)

    // When
    let users = try await apiClient.fetchUsers()

    // Then
    XCTAssertFalse(users.isEmpty)
    XCTAssertNotNil(users.first?.id)
}

// Network stubbing with OHHTTPStubs
stub(condition: isHost("api.example.com")) { _ in
    let stubData = """
    {"users": [{"id": "1", "name": "John"}]}
    """.data(using: .utf8)!

    return HTTPStubsResponse(
        data: stubData,
        statusCode: 200,
        headers: ["Content-Type": "application/json"]
    )
}

Android:
@Test
fun testAPIFetch() = runBlocking {
    // Given
    val apiClient = APIClient(testServerURL)

    // When
    val users = apiClient.fetchUsers()

    // Then
    assertTrue(users.isNotEmpty())
    assertNotNull(users.first().id)
}

// MockWebServer for network testing
val mockWebServer = MockWebServer()

@Before
fun setUp() {
    mockWebServer.start()
}

@Test
fun testAPIRequest() = runBlocking {
    // Given
    mockWebServer.enqueue(
        MockResponse()
            .setResponseCode(200)
            .setBody("""{"users": [{"id": "1", "name": "John"}]}""")
    )

    val apiClient = APIClient(mockWebServer.url("/").toString())

    // When
    val users = apiClient.fetchUsers()

    // Then
    val request = mockWebServer.takeRequest()
    assertEquals("/users", request.path)
    assertEquals("GET", request.method)
    assertTrue(users.isNotEmpty())
}

@After
fun tearDown() {
    mockWebServer.shutdown()
}

UI Testing

iOS UI Testing (XCUITest)

import XCTest

class LoginFlowTests: XCTestCase {

    var app: XCUIApplication!

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

    func testSuccessfulLogin() {
        // Given
        let emailField = app.textFields["Email"]
        let passwordField = app.secureTextFields["Password"]
        let loginButton = app.buttons["Log In"]

        // When
        emailField.tap()
        emailField.typeText("[email protected]")

        passwordField.tap()
        passwordField.typeText("password123")

        loginButton.tap()

        // Then
        let welcomeText = app.staticTexts["Welcome"]
        XCTAssertTrue(welcomeText.waitForExistence(timeout: 5))
    }

    func testInvalidEmailError() {
        // Given
        let emailField = app.textFields["Email"]
        let loginButton = app.buttons["Log In"]

        // When
        emailField.tap()
        emailField.typeText("invalid-email")
        loginButton.tap()

        // Then
        let errorAlert = app.alerts["Error"]
        XCTAssertTrue(errorAlert.exists)
        XCTAssertTrue(
            errorAlert.staticTexts["Invalid email address"].exists
        )
    }

    func testNavigationFlow() {
        // Navigate through app screens
        app.tabBars.buttons["Profile"].tap()
        XCTAssertTrue(app.navigationBars["Profile"].exists)

        app.tables.cells.element(boundBy: 0).tap()
        XCTAssertTrue(app.navigationBars["Settings"].exists)

        app.navigationBars.buttons.element(boundBy: 0).tap()
        XCTAssertTrue(app.navigationBars["Profile"].exists)
    }
}

// Accessibility identifiers for reliable tests
emailTextField.accessibilityIdentifier = "email_field"
passwordTextField.accessibilityIdentifier = "password_field"
loginButton.accessibilityIdentifier = "login_button"

Android UI Testing (Espresso)

import androidx.test.espresso.Espresso.*
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.*
import androidx.test.espresso.matcher.ViewMatchers.*

@RunWith(AndroidJUnit4::class)
class LoginFlowTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun successfulLogin() {
        // When
        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())

        // Then
        onView(withText("Welcome"))
            .check(matches(isDisplayed()))
    }

    @Test
    fun invalidEmailShowsError() {
        // When
        onView(withId(R.id.email_field))
            .perform(typeText("invalid-email"), closeSoftKeyboard())

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

        // Then
        onView(withText("Invalid email address"))
            .check(matches(isDisplayed()))
    }

    @Test
    fun navigationFlow() {
        // Navigate to profile
        onView(withId(R.id.navigation_profile))
            .perform(click())

        onView(withId(R.id.profile_toolbar))
            .check(matches(isDisplayed()))

        // Open settings
        onView(withId(R.id.settings_item))
            .perform(click())

        onView(withText("Settings"))
            .check(matches(isDisplayed()))

        // Go back
        pressBack()

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

// RecyclerView testing
onView(withId(R.id.recycler_view))
    .perform(
        RecyclerViewActions.actionOnItemAtPosition(
            0,
            click()
        )
    )

// IdlingResource for async operations
@get:Rule
val idlingResourceRule = IdlingResourceRule()

onView(withId(R.id.refresh_button)).perform(click())
// Espresso waits for idling resource before continuing
onView(withId(R.id.content)).check(matches(isDisplayed()))

Test Automation

Continuous Integration

GitHub Actions

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

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

jobs:
  test:
    runs-on: macos-latest

    steps:
    - uses: actions/checkout@v3

    - name: Setup Xcode
      uses: maxim-lobanov/setup-xcode@v1
      with:
        xcode-version: '15.0'

    - name: Install dependencies
      run: pod install

    - name: Run tests
      run: |
        xcodebuild test \
          -workspace YourApp.xcworkspace \
          -scheme YourApp \
          -destination 'platform=iOS Simulator,name=iPhone 15' \
          -enableCodeCoverage YES \
          | xcpretty

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

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

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Setup JDK
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'

    - name: Setup Android SDK
      uses: android-actions/setup-android@v2

    - name: Run unit tests
      run: ./gradlew testDebugUnitTest

    - name: Run instrumented tests
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: 33
        target: google_apis
        arch: x86_64
        script: ./gradlew connectedDebugAndroidTest

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

Test Coverage

iOS (Xcode):
- Enable code coverage in scheme
- View coverage in Report Navigator
- Target: 70-80% minimum

Android (JaCoCo):
// build.gradle
android {
    buildTypes {
        debug {
            testCoverageEnabled true
        }
    }
}

// Generate report
./gradlew jacocoTestReport

Coverage metrics:
- Line coverage: 70-80% target
- Branch coverage: 60-70% target
- Critical paths: 90%+ coverage
- UI code: Lower acceptable (harder to test)

Focus coverage on:
✓ Business logic
✓ Data processing
✓ Validation
✓ Error handling
✓ Critical user flows

Don't obsess over:
- UI layout code
- Third-party library wrappers
- Simple getters/setters
- Generated code

Manual Testing

Exploratory Testing

Test charter template:

Mission:
Test user registration flow for edge cases and error handling

Areas to explore:
- Invalid input handling
- Network interruptions
- Duplicate accounts
- Special characters in fields
- Very long inputs
- Empty submissions

Time box: 60 minutes

Notes:
- Found issue with password field not showing strength indicator
- Special characters in username cause crash
- No loading state during network request
- Success message disappears too quickly

Bugs found: 4
Questions raised: 2
Areas for improvement: 3

Device Testing Matrix

iOS devices to test:

Primary:
- iPhone 15 Pro (iOS 17)
- iPhone 15 (iOS 17)
- iPhone 14 (iOS 16)
- iPhone SE 3rd gen (smaller screen)
- iPad Pro 12.9" (latest)

Secondary:
- iPhone 13 (iOS 15)
- iPhone 12 (older iOS support)
- iPad Air (mid-size tablet)

Test on:
✓ Latest iOS version
✓ iOS version - 1
✓ Oldest supported iOS version
✓ Different screen sizes
✓ Different device capabilities

Android devices to test:

Primary:
- Google Pixel 8 (Android 14)
- Samsung Galaxy S24 (Android 14)
- OnePlus 12 (Android 14)
- Budget device (Android 12-13)
- Tablet (10" screen)

Secondary:
- Samsung Galaxy S21 (older flagship)
- Xiaomi device (MIUI skin)
- Motorola (stock Android)

Test on:
✓ Latest Android version
✓ Android versions - 1, - 2
✓ Oldest supported version
✓ Different manufacturers (skins)
✓ Various screen sizes/densities
✓ Different RAM/performance levels

Beta Testing

TestFlight (iOS)

Setup process:
1. Archive and upload build to App Store Connect
2. Complete beta app information
3. Add internal testers (up to 100)
4. Add external testers (up to 10,000)
5. Submit for beta review (external only)
6. Distribute to testers

Best practices:
✓ Use test notes for each build
✓ Start with internal team
✓ Gradually expand to external
✓ Collect crash reports
✓ Monitor feedback
✓ Send surveys after testing
✓ Iterate based on feedback

Tester communication:
"What's New in This Build:
- Fixed login crash on iOS 16
- Improved photo upload speed
- Added dark mode support

What to Test:
- Login flow with various accounts
- Photo uploads (5+ photos)
- Dark mode in all screens
- Share feedback via TestFlight app

Known Issues:
- Search occasionally slow (investigating)

Thank you for testing!"

Google Play Beta

Beta tracks:

Internal testing:
- Up to 100 testers
- Instant access
- No review required
- For team testing

Closed testing:
- Invite-only
- Up to 100 groups
- Email/link distribution
- Controlled audience

Open testing:
- Public opt-in
- Up to 10,000+ testers
- Anyone can join
- Wider feedback

Production (staged rollout):
- 1%, 5%, 10%, 20%, 50%, 100%
- Gradual production release
- Monitor metrics at each stage
- Rollback if issues found

Beta testing process:
1. Upload AAB to Play Console
2. Select testing track
3. Add testers or create opt-in URL
4. Release to track
5. Collect feedback via Play Console
6. Monitor crash reports
7. Iterate and re-release
8. Promote to production when ready

Testing Checklist

Pre-Release Testing

Functionality:
□ All features work as designed
□ No crashes on launch
□ No crashes during core flows
□ Error handling works
□ Offline functionality works
□ Network errors handled gracefully
□ Deep links work
□ Push notifications work
□ In-app purchases work (if applicable)
□ Subscriptions work (if applicable)
□ Data syncs correctly
□ Settings persist correctly

UI/UX:
□ All screens display correctly
□ Images load properly
□ Text is readable (no truncation)
□ Buttons are tappable
□ Forms validate correctly
□ Loading states shown
□ Error messages clear
□ Navigation intuitive
□ Back button works
□ Gestures work
□ Animations smooth

Compatibility:
□ Works on minimum iOS/Android version
□ Works on all target devices
□ Works on different screen sizes
□ Works in portrait and landscape
□ Works with dark mode
□ Works with different locales
□ Works with accessibility features
□ Works with different font sizes

Performance:
□ App launches quickly (< 3 seconds)
□ Screens load fast
□ Scrolling is smooth
□ No memory leaks
□ Battery usage reasonable
□ App size acceptable
□ Network usage optimized

Security:
□ Authentication works
□ Authorization enforced
□ Data encrypted
□ HTTPS used
□ Sensitive data not logged
□ API keys not exposed
□ Certificates valid

Compliance:
□ Privacy policy accessible
□ Terms of service accessible
□ Age rating appropriate
□ Permissions justified
□ Data collection disclosed
□ GDPR compliant (if EU)
□ COPPA compliant (if children)

Store Requirements:
□ App Store guidelines met
□ Google Play policies met
□ Screenshots current
□ Description accurate
□ Keywords optimized
□ Support URL valid
□ Privacy policy URL valid

Testing Tools

Popular Testing Tools

Unit Testing:
- XCTest (iOS)
- JUnit (Android)
- Quick/Nimble (iOS BDD)
- Robolectric (Android shadow testing)

UI Testing:
- XCUITest (iOS)
- Espresso (Android)
- Detox (React Native)
- Appium (cross-platform)

Mocking:
- Mockito (Android)
- OCMock (iOS)
- Cuckoo (Swift)

API Testing:
- OHHTTPStubs (iOS)
- MockWebServer (Android)
- WireMock

Performance:
- Xcode Instruments
- Android Profiler
- Firebase Performance Monitoring

Crash Reporting:
- Firebase Crashlytics
- Sentry
- Bugsnag

Beta Distribution:
- TestFlight (iOS)
- Google Play Beta (Android)
- Firebase App Distribution

Device Farm:
- AWS Device Farm
- Firebase Test Lab
- BrowserStack
- Sauce Labs

Conclusion

Comprehensive testing is the foundation of successful mobile apps. By implementing a robust testing strategy—from unit tests to beta testing—you can catch bugs early, deliver better user experiences, and maintain high app store ratings. Remember: testing is not a phase, it's a continuous practice throughout development and beyond release.

Quality apps need quality support infrastructure. Our support URL generator creates professional, tested support pages that meet all app store requirements and provide great user experience.

Need a Support URL for Your App?

Generate a compliant, professional support page in under a minute. Our easy-to-use generator creates everything you need for App Store and Google Play submissions.