← Back to blog

Testing iOS Apps — Direct Integration Tests

A guide to integration testing in iOS development, demonstrating how to verify that multiple components work together correctly.

Within the testing pyramid, integration tests form the intermediate layer. They allow developers to test the correct integration between two parts of a project, whether self-developed or external code.

Integration testing involves taking multiple pieces of code that must work together and ensuring the result meets expectations. Think of a car assembly: just as connecting a steering wheel to the steering system should make wheels turn in the desired direction, integration tests verify that software components interact properly.

Example

Consider a task management application featuring a TaskManager class and a Database class:

class TaskManager {
    private let database: Database

    init(database: Database) {
        self.database = database
    }

    func addTask(_ task: Task) {
        database.saveTask(task)
    }

    func retrieveTasks() -> [Task] {
        return database.getAllTasks()
    }

    func deleteTasks() {
        database.deleteAllTasks()
    }
}

class Database {
    internal var tasks: [Task] = []

    func saveTask(_ task: Task) {
        tasks.append(task)
    }

    func getAllTasks() -> [Task] {
        return tasks
    }

    func deleteAllTasks() {
        tasks.removeAll()
    }
}

The tests verify three key behaviors:

  • Adding tasks stores them in the database
  • Retrieving tasks returns saved items
  • Deleting tasks removes all records

Test Refactoring Best Practices

Initially, a monolithic test function covering all scenarios violates the Single Responsibility Principle. Separating concerns improves organization:

func testAddTasks() {
    var database = Database()
    var taskManager = TaskManager(database: database)

    let task1 = Task(id: 1, title: "Buy milk", completed: false)
    let task2 = Task(id: 2, title: "Walk the dog", completed: false)

    taskManager.addTask(task1)
    taskManager.addTask(task2)

    XCTAssertEqual(database.tasks[0].title, "Buy milk")
    XCTAssertEqual(database.tasks[1].title, "Walk the dog")
}

Further refactoring uses setUp() and tearDown() methods from XCTestCase:

var database: Database!
var taskManager: TaskManager!
let task1 = Task(id: 1, title: "Buy milk", completed: false)
let task2 = Task(id: 2, title: "Walk the dog", completed: false)

override func setUp() {
    super.setUp()
    database = Database()
    taskManager = TaskManager(database: database)
}

override func tearDown() {
    database = nil
    taskManager = nil
    super.tearDown()
}

func setTasksToDatabase() {
    database.tasks = [task1, task2]
}

func testAddTasks() {
    taskManager.addTask(task1)
    taskManager.addTask(task2)

    XCTAssertEqual(database.tasks[0].title, "Buy milk")
    XCTAssertEqual(database.tasks[1].title, "Walk the dog")
}

func testRetrieveTasks() {
    setTasksToDatabase()
    var retrievedTasks = taskManager.retrieveTasks()
    XCTAssertEqual(retrievedTasks.count, 2)
}

func testRemoveTasks() {
    setTasksToDatabase()
    taskManager.deleteTasks()
    XCTAssertEqual(database.tasks.count, 0)
}

The setUp() and tearDown() methods execute before and after each test respectively. For Xcode to detect a test function, it must contain the test prefix.

The internal access modifier allows tests to access properties from any file within the same module while maintaining encapsulation from external modules.