Mobile App Testing Strategy: Unit, Integration, and E2E

Shipping a buggy mobile app is one of the fastest ways to lose users. Unlike web applications, where you can deploy fixes within minutes, mobile app updates must go through store review processes, and users must choose to update. A bug that reaches production can haunt you for days or weeks.

A well-structured testing strategy catches bugs before they reach users. This guide covers the three layers of testing every mobile app needs: unit tests, integration tests, and end-to-end (E2E) tests. We will cover practical implementation for both iOS and Android, with examples you can adapt to your project.

The Testing Pyramid

The testing pyramid is a well-established model for balancing test types:

    /  E2E  \          Few, slow, expensive
   /----------\
  / Integration \      Moderate number
 /----------------\
/    Unit Tests    \   Many, fast, cheap
--------------------

Unit tests form the base. They are fast, isolated, and test individual functions or classes. You should have the most of these.

Integration tests sit in the middle. They test how components work together: API calls, database operations, navigation flows. You need a moderate number.

E2E tests sit at the top. They test complete user workflows through the actual UI. They are slow and brittle, so you should have the fewest, covering only critical paths.

A practical ratio for mobile apps: 70 percent unit tests, 20 percent integration tests, 10 percent E2E tests.

Unit Testing

Unit Testing Infographic

What to Unit Test

Focus unit tests on:

  • Business logic (calculations, transformations, validations)
  • View models and presenters
  • Data models and serialisation
  • Utility functions
  • State machines

Do not unit test:

  • UI layout (use snapshot tests or manual review)
  • Third-party library internals
  • Simple getters/setters with no logic

iOS Unit Testing with XCTest

// TaskViewModel.swift
class TaskViewModel {
    private(set) var tasks: [Task] = []

    func addTask(title: String) -> Bool {
        guard !title.trimmingCharacters(in: .whitespaces).isEmpty else {
            return false
        }
        let task = Task(title: title, isCompleted: false)
        tasks.append(task)
        return true
    }

    func toggleTask(at index: Int) {
        guard index >= 0 && index < tasks.count else { return }
        tasks[index].isCompleted.toggle()
    }

    var completedCount: Int {
        tasks.filter { $0.isCompleted }.count
    }

    var completionPercentage: Double {
        guard !tasks.isEmpty else { return 0 }
        return Double(completedCount) / Double(tasks.count) * 100
    }
}
// TaskViewModelTests.swift
import XCTest
@testable import TaskBoard

class TaskViewModelTests: XCTestCase {
    var sut: TaskViewModel!

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

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

    func testAddTask_withValidTitle_addsTask() {
        let result = sut.addTask(title: "New task")

        XCTAssertTrue(result)
        XCTAssertEqual(sut.tasks.count, 1)
        XCTAssertEqual(sut.tasks.first?.title, "New task")
        XCTAssertFalse(sut.tasks.first?.isCompleted ?? true)
    }

    func testAddTask_withEmptyTitle_returnsFalse() {
        let result = sut.addTask(title: "   ")

        XCTAssertFalse(result)
        XCTAssertEqual(sut.tasks.count, 0)
    }

    func testToggleTask_togglesCompletionStatus() {
        _ = sut.addTask(title: "Test task")

        sut.toggleTask(at: 0)

        XCTAssertTrue(sut.tasks[0].isCompleted)
    }

    func testToggleTask_withInvalidIndex_doesNotCrash() {
        sut.toggleTask(at: 99) // Should not crash
    }

    func testCompletionPercentage_withNoTasks_returnsZero() {
        XCTAssertEqual(sut.completionPercentage, 0)
    }

    func testCompletionPercentage_withMixedTasks_calculatesCorrectly() {
        _ = sut.addTask(title: "Task 1")
        _ = sut.addTask(title: "Task 2")
        _ = sut.addTask(title: "Task 3")
        sut.toggleTask(at: 0) // Complete first task

        XCTAssertEqual(sut.completionPercentage, 33.33, accuracy: 0.01)
    }
}

Android Unit Testing with JUnit and Mockk

// TaskViewModel.kt
class TaskViewModel(
    private val repository: TaskRepository
) : ViewModel() {

    private val _tasks = MutableLiveData<List<Task>>(emptyList())
    val tasks: LiveData<List<Task>> = _tasks

    fun loadTasks() {
        viewModelScope.launch {
            val result = repository.getTasks()
            _tasks.value = result
        }
    }

    fun addTask(title: String): Boolean {
        if (title.isBlank()) return false
        val currentTasks = _tasks.value.orEmpty().toMutableList()
        currentTasks.add(Task(title = title, isCompleted = false))
        _tasks.value = currentTasks
        return true
    }
}
// TaskViewModelTest.kt
class TaskViewModelTest {
    @get:Rule
    val instantExecutorRule = InstantTaskExecutorRule()

    @get:Rule
    val coroutineRule = MainCoroutineRule()

    private lateinit var repository: TaskRepository
    private lateinit var viewModel: TaskViewModel

    @Before
    fun setup() {
        repository = mockk()
        viewModel = TaskViewModel(repository)
    }

    @Test
    fun `addTask with valid title adds task to list`() {
        val result = viewModel.addTask("New task")

        assertTrue(result)
        assertEquals(1, viewModel.tasks.value?.size)
        assertEquals("New task", viewModel.tasks.value?.first()?.title)
    }

    @Test
    fun `addTask with blank title returns false`() {
        val result = viewModel.addTask("   ")

        assertFalse(result)
        assertTrue(viewModel.tasks.value.isNullOrEmpty())
    }

    @Test
    fun `loadTasks fetches from repository`() = coroutineRule.runBlockingTest {
        val expectedTasks = listOf(
            Task("Task 1", false),
            Task("Task 2", true)
        )
        coEvery { repository.getTasks() } returns expectedTasks

        viewModel.loadTasks()

        assertEquals(expectedTasks, viewModel.tasks.value)
    }
}

React Native Unit Testing with Jest

// taskUtils.js
export const calculateProgress = (tasks) => {
  if (tasks.length === 0) return 0;
  const completed = tasks.filter(t => t.isCompleted).length;
  return Math.round((completed / tasks.length) * 100);
};

export const sortTasks = (tasks, sortBy) => {
  const sorted = [...tasks];
  switch (sortBy) {
    case 'title':
      return sorted.sort((a, b) => a.title.localeCompare(b.title));
    case 'status':
      return sorted.sort((a, b) => Number(a.isCompleted) - Number(b.isCompleted));
    case 'date':
      return sorted.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
    default:
      return sorted;
  }
};
// taskUtils.test.js
import { calculateProgress, sortTasks } from './taskUtils';

describe('calculateProgress', () => {
  it('returns 0 for empty task list', () => {
    expect(calculateProgress([])).toBe(0);
  });

  it('returns 100 when all tasks completed', () => {
    const tasks = [
      { isCompleted: true },
      { isCompleted: true },
    ];
    expect(calculateProgress(tasks)).toBe(100);
  });

  it('calculates percentage correctly', () => {
    const tasks = [
      { isCompleted: true },
      { isCompleted: false },
      { isCompleted: true },
      { isCompleted: false },
    ];
    expect(calculateProgress(tasks)).toBe(50);
  });
});

describe('sortTasks', () => {
  const tasks = [
    { title: 'Charlie', isCompleted: true, createdAt: '2021-04-01' },
    { title: 'Alpha', isCompleted: false, createdAt: '2021-04-03' },
    { title: 'Bravo', isCompleted: false, createdAt: '2021-04-02' },
  ];

  it('sorts by title alphabetically', () => {
    const result = sortTasks(tasks, 'title');
    expect(result.map(t => t.title)).toEqual(['Alpha', 'Bravo', 'Charlie']);
  });

  it('sorts by status with incomplete first', () => {
    const result = sortTasks(tasks, 'status');
    expect(result[0].isCompleted).toBe(false);
    expect(result[2].isCompleted).toBe(true);
  });

  it('does not mutate original array', () => {
    const original = [...tasks];
    sortTasks(tasks, 'title');
    expect(tasks).toEqual(original);
  });
});

Integration Testing

Integration

Integration Testing Infographic tests verify that components work together correctly. For mobile apps, the most important integration tests cover:

API Integration Tests

Test that your network layer correctly handles API responses, error states, and edge cases:

// iOS: API integration test
class TaskAPITests: XCTestCase {
    var apiClient: TaskAPIClient!
    var mockSession: URLSession!

    override func setUp() {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        mockSession = URLSession(configuration: configuration)
        apiClient = TaskAPIClient(session: mockSession)
    }

    func testFetchTasks_withValidResponse_returnsTasks() async throws {
        let mockData = """
        {
            "data": [
                {"id": "1", "title": "Test Task", "isCompleted": false}
            ]
        }
        """.data(using: .utf8)!

        MockURLProtocol.requestHandler = { request in
            let response = HTTPURLResponse(
                url: request.url!,
                statusCode: 200,
                httpVersion: nil,
                headerFields: nil
            )!
            return (response, mockData)
        }

        let tasks = try await apiClient.fetchTasks()

        XCTAssertEqual(tasks.count, 1)
        XCTAssertEqual(tasks.first?.title, "Test Task")
    }

    func testFetchTasks_withServerError_throwsError() async {
        MockURLProtocol.requestHandler = { request in
            let response = HTTPURLResponse(
                url: request.url!,
                statusCode: 500,
                httpVersion: nil,
                headerFields: nil
            )!
            return (response, Data())
        }

        do {
            _ = try await apiClient.fetchTasks()
            XCTFail("Expected error to be thrown")
        } catch {
            XCTAssertTrue(error is APIError)
        }
    }
}

Database Integration Tests

Test that your local database operations work correctly:

// Android: Room database integration test
@RunWith(AndroidJUnit4::class)
class TaskDaoTest {
    private lateinit var database: AppDatabase
    private lateinit var taskDao: TaskDao

    @Before
    fun setup() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).allowMainThreadQueries().build()
        taskDao = database.taskDao()
    }

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

    @Test
    fun insertAndRetrieveTask() = runBlocking {
        val task = TaskEntity(title = "Test Task", isCompleted = false)
        taskDao.insert(task)

        val tasks = taskDao.getAllTasks()
        assertEquals(1, tasks.size)
        assertEquals("Test Task", tasks[0].title)
    }

    @Test
    fun updateTaskCompletion() = runBlocking {
        val task = TaskEntity(title = "Test", isCompleted = false)
        val id = taskDao.insert(task)

        taskDao.updateCompletion(id, true)

        val updated = taskDao.getTaskById(id)
        assertTrue(updated.isCompleted)
    }
}

Test that navigation between screens works correctly, including deep links and back stack behaviour.

End-to-End Testing

E2E tests exer

End-to-End Testing Infographic cise the full application stack through the UI. They are the closest thing to a real user interacting with your app.

iOS E2E Testing with XCUITest

class TaskBoardUITests: XCTestCase {
    let app = XCUIApplication()

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

    func testCreateAndCompleteTask() {
        // Tap add button
        app.navigationBars["TaskBoard"].buttons["Add"].tap()

        // Enter task title
        let titleField = app.textFields["Task title"]
        titleField.tap()
        titleField.typeText("Buy groceries")

        // Tap save
        app.buttons["Add"].tap()

        // Verify task appears in list
        XCTAssertTrue(app.staticTexts["Buy groceries"].exists)

        // Tap the task to complete it
        app.staticTexts["Buy groceries"].tap()

        // Toggle completion
        app.switches["Completed"].tap()

        // Go back and verify
        app.navigationBars.buttons.firstMatch.tap()

        // Task should still be visible
        XCTAssertTrue(app.staticTexts["Buy groceries"].exists)
    }

    func testDeleteTask() {
        // Assuming a task exists
        let taskCell = app.cells.firstMatch

        // Swipe to delete
        taskCell.swipeLeft()
        app.buttons["Delete"].tap()

        // Verify task is removed
        XCTAssertEqual(app.cells.count, 0)
    }
}

Android E2E Testing with Espresso

@RunWith(AndroidJUnit4::class)
class TaskBoardE2ETest {

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

    @Test
    fun createAndCompleteTask() {
        // Tap FAB to add task
        onView(withId(R.id.fab_add_task)).perform(click())

        // Enter task title
        onView(withId(R.id.edit_task_title))
            .perform(typeText("Buy groceries"), closeSoftKeyboard())

        // Save task
        onView(withId(R.id.button_save)).perform(click())

        // Verify task appears in list
        onView(withText("Buy groceries"))
            .check(matches(isDisplayed()))

        // Tap checkbox to complete
        onView(allOf(
            withId(R.id.checkbox_complete),
            hasSibling(withText("Buy groceries"))
        )).perform(click())

        // Verify task is marked complete
        onView(allOf(
            withId(R.id.checkbox_complete),
            hasSibling(withText("Buy groceries"))
        )).check(matches(isChecked()))
    }
}

Cross-Platform E2E with Detox (React Native)

For React Native apps, Detox provides reliable E2E testing:

// e2e/taskBoard.e2e.js
describe('TaskBoard', () => {
  beforeAll(async () => {
    await device.launchApp();
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('should create a new task', async () => {
    await element(by.id('add-task-button')).tap();
    await element(by.id('task-title-input')).typeText('Buy groceries');
    await element(by.id('save-button')).tap();

    await expect(element(by.text('Buy groceries'))).toBeVisible();
  });

  it('should complete a task', async () => {
    await element(by.id('task-checkbox-0')).tap();

    await expect(element(by.id('task-checkbox-0'))).toHaveToggleValue(true);
  });

  it('should delete a task by swiping', async () => {
    await element(by.id('task-row-0')).swipe('left');
    await element(by.text('Delete')).tap();

    await expect(element(by.id('task-row-0'))).not.toBeVisible();
  });
});

Test Infrastructure

Continuous Integration

Run your tests automatically on every pull request. For mobile apps, consider:

  • GitHub Actions: Free for public repos, generous free tier for private repos. Supports macOS runners for iOS tests.
  • Bitrise: Purpose-built for mobile CI/CD. Excellent for teams that want a managed solution.
  • CircleCI: Strong mobile support with macOS and Linux runners.

A basic CI pipeline should:

  1. Run unit tests on every commit
  2. Run integration tests on every pull request
  3. Run E2E tests nightly or before releases

Code Coverage

Aim for meaningful coverage, not 100 percent. Good targets:

  • Business logic: 80 percent or higher
  • View models/presenters: 70 percent or higher
  • Overall app: 60 percent or higher

Low coverage in UI code is acceptable if you have E2E tests covering critical paths.

Building a Testing Culture

Testing is a habit, not a one-time effort. Practical tips for building testing into your workflow:

  1. Write tests alongside features, not after. Tests written after the fact tend to test implementation rather than behaviour.
  2. Make tests fast. If your test suite takes more than 5 minutes, developers will skip it. Keep unit tests under 1 minute, integration under 5 minutes.
  3. Fix flaky tests immediately. A flaky test is worse than no test. It erodes trust in the entire suite.
  4. Review tests in code reviews. Tests are code. They deserve the same review attention as production code.

A comprehensive testing strategy is an investment that pays for itself in reduced bugs, faster development, and confident deployments. At eawesome, testing is built into our development process from day one, and our clients benefit from more reliable apps as a result.