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")
    }
}