Testing in Swift: From Theory to Practice with Mocks and Fixtures
Imagine you're baking a cake, but you've run out of flour. You can't test if the frosting turns out right until you go to the store. That's roughly how a developer feels when writing unit tests for code that works with the network, a database, or complex dependencies. Instead of waiting for a real server or an actual database, we use mock objects and fixtures in Swift tests — they are our "flour substitutes" that allow us to test isolated pieces of code here and now.
In this article, we won't just list definitions. We'll break down how to properly apply Swift testing in practice, when to use mocks and when to use stubs, and how fixtures help you avoid going crazy when preparing data. Let's go!
What are mock, stub, and fixture? Understanding the terms
Before writing code, let's agree on the concepts. In the world of iOS unit testing, mocks and stubs are often confused. Remember this simple rule:
- Stub — an object that returns predefined data. It doesn't check how it was called. It just returns a response.
- Mock — an object that not only returns data but also verifies whether certain methods were called and with what parameters. It's a "spy".
- Fixture — a pre-prepared set of data (usually JSON or a struct) used in tests to create a predictable environment.
In Swift testing, we often combine these approaches. For example, we load a JSON fixture, parse it, and then replace the network layer with a mock that checks whether the request was sent to the correct URL.
Writing your first mock in Swift using protocols
The most common way to create mock objects in Swift is to use protocols. In a real project, you have a service that makes network calls. In tests, you replace it with a mock.
Here's an example of an abstraction:
// Protocol for the network layerprotocol NetworkServiceProtocol { func fetchUsers(completion: @escaping (Result<[User], Error>) -> Void)}
// Real implementation (makes network calls)class RealNetworkService: NetworkServiceProtocol { func fetchUsers(completion: @escaping (Result<[User], Error>) -> Void) { // URLSession, Alamofire, etc. }}
// Mock for testsclass MockNetworkService: NetworkServiceProtocol { var fetchUsersCalled = false var resultToReturn: Result<[User], Error> = .success([])
func fetchUsers(completion: @escaping (Result<[User], Error>) -> Void) { fetchUsersCalled = true completion(resultToReturn) }}Now in the test, we can verify that the method was called:
func testViewModelFetchesUsers() { let mockService = MockNetworkService() mockService.resultToReturn = .success([User(id: 1, name: "Test")])
let viewModel = UserViewModel(networkService: mockService) viewModel.loadUsers()
XCTAssertTrue(mockService.fetchUsersCalled, "The fetchUsers method should have been called")}Note: we are using dependency injection in Swift — passing the dependency through the initializer. This is a key pattern for testing.
Fixtures: how to avoid writing 100500 lines in tests
When you test JSON parsing or working with CoreData, you need real data. But generating it every time in code is a nightmare. This is where fixtures in Swift tests come to the rescue.
Create a file users_fixture.json in the test folder:
[ {"id": 1, "name": "Alice", "email": "alice@example.com"}, {"id": 2, "name": "Bob", "email": "bob@example.com"}]Now