Testing in Swift: Unit, Integration, E2E — from Theory to Practice
Quality testing is not just a "good practice" in iOS and macOS development, but a critically important stage that ensures the stability, security, and predictability of an application. In this article, we will break down three levels of testing in Swift: Unit tests, Integration tests, and E2E (end-to-end). You will learn how they differ, when to apply them, and how to write effective tests using the XCTest and XCUITest frameworks.
1. Unit Testing: Checking the "Building Blocks" of the Application
Unit tests are the lowest-level and fastest type of tests. They verify the operation of individual functions, methods, or classes (units) in isolation from external dependencies (network, database, UI).
Basic Principles of Unit Tests in Swift:
- Isolation: The code under test should not depend on the state of other modules.
- Repeatability: The test should produce the same result every time it is run.
- Speed: Thousands of unit tests should execute in seconds.
Example of a Unit Test with XCTest:
import XCTest
class Calculator { func add(_ a: Int, _ b: Int) -> Int { return a + b } func divide(_ a: Int, _ b: Int) throws -> Int { guard b != 0 else { throw CalculationError.divisionByZero } return a / b }}
enum CalculationError: Error { case divisionByZero}
class CalculatorTests: XCTestCase { var calculator: Calculator! override func setUp() { super.setUp() calculator = Calculator() } override func tearDown() { calculator = nil super.tearDown() } func testAddition() { // Given let a = 5 let b = 3 // When let result = calculator.add(a, b) // Then XCTAssertEqual(result, 8, "5 + 3 should equal 8") } func testDivisionByZeroThrowsError() { // Use XCTAssertThrowsError to check exceptions XCTAssertThrowsError(try calculator.divide(10, 0)) { error in XCTAssertEqual(error as? CalculationError, .divisionByZero) } }}Best Practices for Unit Tests:
- Use the AAA (Arrange-Act-Assert) pattern — setup, action, verification.
- Name tests meaningfully:
testMethodName_condition_expectedResult. - Avoid testing private methods — test the public interface.
- For mocks and stubs, use protocols and dependency injection.
2. Integration Testing: Checking Component Interaction
Integration tests verify how different modules of the application work together. In the context of iOS, this could be the interaction of a ViewModel with the network, Core Data, or UserDefaults.
Example of an Integration Test with a Mock Network Layer:
import XCTest
// Protocol for the network service (allows easy substitution)protocol NetworkServiceProtocol { func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void)}
// Real service (we don't test it in the integration test)class RealNetworkService: NetworkServiceProtocol { func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) { // Real HTTP request }}
// Mock service for testingclass MockNetworkService: NetworkServiceProtocol { var shouldReturnError = false var mockUser = User(id: 1, name: "Test User") func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) { if shouldReturnError { completion(.failure(NSError(domain: "test", code: 500)))