Section 8/161 menit
8. Test Doubles: Mock, Stub, Spy, Fake
8. Test Doubles: Mock, Stub, Spy, Fake
Swift Testing tidak menyediakan mocking framework built-in. Gunakan pola Swift murni.
Stub — Mengembalikan Data Tetap
swift
// Protocol untuk dependency
protocol UserRepository {
func fetchUser(id: String) async throws -> User
func saveUser(_ user: User) async throws
}
// Stub: selalu return data yang sama
struct StubUserRepository: UserRepository {
let stubUser: User
func fetchUser(id: String) async throws -> User {
return stubUser // Selalu return sama, tidak peduli id
}
func saveUser(_ user: User) async throws {
// No-op — stub tidak perlu menyimpan apapun
}
}
@Test
func testUserDisplayName() async throws {
let stub = StubUserRepository(
stubUser: User(id: "1", name: "Budi Santoso", email: "budi@example.com")
)
let viewModel = UserProfileViewModel(repository: stub)
await viewModel.load(userId: "1")
#expect(viewModel.displayName == "Budi Santoso")
}
Mock — Verifikasi Interaksi
swift
// Mock: mencatat panggilan dan bisa di-verify
actor MockUserRepository: UserRepository {
private(set) var fetchCallCount = 0
private(set) var lastFetchedId: String?
private(set) var savedUsers: [User] = []
var fetchResult: Result<User, Error> = .success(.placeholder)
func fetchUser(id: String) async throws -> User {
fetchCallCount += 1
lastFetchedId = id
return try fetchResult.get()
}
func saveUser(_ user: User) async throws {
savedUsers.append(user)
}
}
@Test
func testProfileLoadCallsRepositoryOnce() async throws {
let mock = MockUserRepository()
let viewModel = UserProfileViewModel(repository: mock)
await viewModel.load(userId: "user-123")
await viewModel.load(userId: "user-123") // Panggil dua kali
// Verifikasi repository hanya dipanggil sekali (karena caching)
let callCount = await mock.fetchCallCount
#expect(callCount == 1, "Repository seharusnya dipanggil sekali karena cache")
let lastId = await mock.lastFetchedId
#expect(lastId == "user-123")
}
Spy — Wrap Implementasi Nyata + Record
swift
// Spy: forward ke implementasi nyata, tapi record panggilan
actor SpyUserRepository: UserRepository {
private let real: UserRepository
private(set) var fetchCallLog: [(id: String, timestamp: Date)] = []
init(wrapping real: UserRepository) {
self.real = real
}
func fetchUser(id: String) async throws -> User {
fetchCallLog.append((id: id, timestamp: Date()))
return try await real.fetchUser(id: id) // Delegate ke implementasi nyata
}
func saveUser(_ user: User) async throws {
try await real.saveUser(user)
}
}
@Test
func testUserFetchPattern() async throws {
let real = InMemoryUserRepository(users: [User(id: "1", name: "Test")])
let spy = SpyUserRepository(wrapping: real)
let viewModel = UserProfileViewModel(repository: spy)
await viewModel.load(userId: "1")
await viewModel.refreshIfNeeded()
let log = await spy.fetchCallLog
#expect(log.count == 1) // Refresh tidak fetch ulang jika data masih fresh
}
Fake — Implementasi Sederhana yang Fungsional
swift
// Fake: implementasi yang berfungsi tapi disederhanakan (bukan production-ready)
actor FakeUserRepository: UserRepository {
private var storage: [String: User] = [:]
init(initialUsers: [User] = []) {
for user in initialUsers {
storage[user.id] = user
}
}
func fetchUser(id: String) async throws -> User {
guard let user = storage[id] else {
throw RepositoryError.notFound(id: id)
}
return user
}
func saveUser(_ user: User) async throws {
storage[user.id] = user
}
}
// Fake bisa dipakai untuk banyak test sekaligus
let sharedFake = FakeUserRepository(initialUsers: [
User(id: "1", name: "Alice"),
User(id: "2", name: "Bob")
])
@Test
func testFetchExistingUser() async throws {
let user = try await sharedFake.fetchUser(id: "1")
#expect(user.name == "Alice")
}
@Test
func testFetchNonexistentUser() async throws {
#expect(throws: RepositoryError.notFound(id: "999")) {
try await sharedFake.fetchUser(id: "999")
}
}