Testing
Two approaches: constructor injection (preferred) or container-based mocking.
Creating Mocks
Create mock classes that conform to the same protocols you registered. Control their behavior with simple properties.
final class MockNetworkService: NetworkServiceProtocol {
var fetchResult: Result<Data, Error> = .success(Data())
func fetch(_ endpoint: String) async throws -> Data {
try fetchResult.get()
}
}
final class MockUserRepository: UserRepositoryProtocol {
var stubbedUser: User?
func fetchCurrentUser() async throws -> User {
guard let user = stubbedUser else { throw MockError.notFound }
return user
}
} Example Subject
A typical @Injectable view model that depends on two services:
@Injectable
final class ProfileViewModel {
let networkService: NetworkServiceProtocol
let repository: UserRepositoryProtocol
private(set) var state: LoadState = .idle
func loadProfile() async {
state = .loading
do {
let user = try await repository.fetchCurrentUser()
let data = try await networkService.fetch("/profile/\(user.id)")
state = .loaded
} catch {
state = .failed(error)
}
}
} Approach 1: Constructor Injection (preferred)
Because @Injectable generates a matching init, tests pass mocks directly as arguments — no container, no global state, no setup/teardown.
@Test
func profileLoadsSuccessfully() async {
let mockNetwork = MockNetworkService()
mockNetwork.fetchResult = .success(profileData)
let mockRepository = MockUserRepository()
mockRepository.stubbedUser = User(id: "u-1", name: "Alice")
// Pass mocks directly via the generated init
let viewModel = ProfileViewModel(
networkService: mockNetwork,
repository: mockRepository
)
await viewModel.loadProfile()
#expect(viewModel.state == .loaded)
} func testProfileLoadsSuccessfully() async {
let mockNetwork = MockNetworkService()
mockNetwork.fetchResult = .success(profileData)
let mockRepository = MockUserRepository()
mockRepository.stubbedUser = User(id: "u-1", name: "Alice")
let viewModel = ProfileViewModel(
networkService: mockNetwork,
repository: mockRepository
)
await viewModel.loadProfile()
XCTAssertEqual(viewModel.state, .loaded)
} Why this is preferred: no shared state between tests, no race conditions on ForgeContainer.shared, and the test clearly shows which dependencies the subject actually uses.
Approach 2: Container-Based Mocking
When you can't use constructor injection — typically because the subject uses @Inject or #inject() instead of @Injectable — set up a test container and point ForgeContainer.shared at it.
Important: ForgeContainer.shared enforces set-once-per-app semantics via precondition. In tests, this means you must either (a) mark suites touching shared with .serialized or (b) clear shared to nil in teardown before the next test sets it.
@Suite(.serialized)
struct ProfileTests {
init() {
// Fresh container per suite — use .serialized to avoid races on the global shared.
let container = ForgeContainer()
container.register(with: .singleton) { _ in
MockNetworkService() as NetworkServiceProtocol
}
container.register(with: .singleton) { _ in
MockUserRepository() as UserRepositoryProtocol
}
ForgeContainer.shared = container
}
deinit {
ForgeContainer.shared = nil
}
@Test
func usesContainerMocks() async {
let viewModel = ProfileViewModel() // @Injectable default-resolves from container
await viewModel.loadProfile()
#expect(viewModel.state == .loaded)
}
} class ProfileTests: XCTestCase {
override func setUp() {
super.setUp()
let container = ForgeContainer()
container.register(with: .singleton) { _ in
MockNetworkService() as NetworkServiceProtocol
}
container.register(with: .singleton) { _ in
MockUserRepository() as UserRepositoryProtocol
}
ForgeContainer.shared = container
}
override func tearDown() {
ForgeContainer.shared = nil
super.tearDown()
}
func testUsesContainerMocks() async {
let viewModel = ProfileViewModel()
await viewModel.loadProfile()
XCTAssertEqual(viewModel.state, .loaded)
}
} Best Practices
- Prefer
@Injectablefor new code — constructor injection gives you the cleanest test story with zero container gymnastics. - Serialize tests that touch
ForgeContainer.shared— use@Suite(.serialized)(Swift Testing) or one-at-a-time execution (XCTest). - Always clear
shared = nilin teardown — otherwise the next test that tries to set it will crash with the set-once precondition. - Mock protocols, not concrete classes — if you catch yourself mocking a concrete class, register it as a protocol instead.