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.