Advanced Testing in Swift: Unit, Integration, and E2E
Testing is not just a check, but the foundation of reliable code. At an advanced level, a Swift developer needs not only to write tests but also to competently build the testing pyramid: from fast unit tests to complex end-to-end (e2e) scenarios. In this article, we will cover advanced techniques for iOS/macOS projects: working with dependencies, integration tests with CoreData, and UI automation.
1. Unit Tests: Dependency Management and Mocks
At an advanced level, unit tests must be isolated. The key technique is Dependency Injection (DI). Instead of testing the real network layer or database, we replace them with mocks.
Example: Protocol and DI
protocol NetworkServiceProtocol { func fetchData(completion: @escaping (Result<String, Error>) -> Void)}
class ViewModel { private let networkService: NetworkServiceProtocol init(networkService: NetworkServiceProtocol) { self.networkService = networkService } func loadData() { networkService.fetchData { result in // processing } }}
// Mock for testsclass MockNetworkService: NetworkServiceProtocol { var shouldReturnError = false func fetchData(completion: @escaping (Result<String, Error>) -> Void) { if shouldReturnError { completion(.failure(NSError(domain: "test", code: -1))) } else { completion(.success("Mock data")) } }}In the test, you create a MockNetworkService and pass it to the ViewModel. This allows you to test error and success scenarios without a real API.
XCTestExpectation for Asynchronous Code
func testViewModelLoadDataSuccess() { let mock = MockNetworkService() let viewModel = ViewModel(networkService: mock) let expectation = self.expectation(description: "Data loaded") viewModel.loadData() // Simulate async call DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } waitForExpectations(timeout: 1.0) // Check ViewModel state}2. Integration Tests: CoreData and Working with Real Data
Integration tests verify the interaction of components. For CoreData, we use an in-memory container to avoid polluting the main database.
Setting up In-Memory CoreData
class CoreDataTestStack { lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "MyModel") let description = NSPersistentStoreDescription() description.type = NSInMemoryStoreType container.persistentStoreDescriptions = [description] container.loadPersistentStores { _, error in if let error = error { fatalError("Failed to load store: \\(error)") } } return container }() var context: NSManagedObjectContext { return persistentContainer.viewContext }}Example Integration Test
func testSaveAndFetchUser() { let stack = CoreDataTestStack() let repository = UserRepository(context: stack.context) // Create user let user = User(context: stack.context) user.name = "Test User" user.age = 30 try? stack.context.save() // Verify data was saved let fetchRequest: NSFetchRequest<User> = User.fetchRequest() let users = try? stack.context.fetch(fetchRequest) XCTAssertEqual(users?.count, 1) XCTAssertEqual(users?.first?.name, "Test User")}Important: clear the context after each test so that tests do not affect each other.