💭

【XCTest】Swiftで学ぶ5つのテストダブル(モック)

2024/08/19に公開

テストダブルとは?

テスト対象の依存コンポーネントの代用品をテストダブル[1]と呼びます。

モックと呼ばれることもありますが、当記事ではテストダブルと呼称します。ダブルは英語で影武者や代役を意味します。

テストダブルは、テストの際に外部への依存を排除したり、テストを高速化したり、特定の条件をシミュレートするために用いられます。

当記事では、スタブ、モック、スパイ、フェイク、ダミーの5つのテストダブルについて簡単な例を用いて解説していきます。

スタブ(Test Stub)

スタブは、本番環境で使用する依存コンポーネントに代わり、テスト用にプリセットされた値を返すものです。

以下のTestTargetでは、fetchPerson(for:)Personを取得します。本番環境で使用するpersonAPIClientは、外部への通信によりPersonを取得しますが、ネットワークに接続できなかったり、サーバーがダウンしていたりするとテストの結果は不安定なものとなってしまいます。

struct Person: Equatable {
    let name: String
    let id: Int
}

protocol PersonAPIClientProtocol {
    func fetch(for id: Int) async throws -> Person
}

final class TestTarget {
    private(set) var person: Person?
    private let personAPIClient: PersonAPIClientProtocol

    init(personAPIClient: PersonAPIClientProtocol) {
        self.personAPIClient = personAPIClient
    }

    func fetchPerson(for id: Int) async throws {
        person = try await personAPIClient.fetch(for: id)
    }
}

スタブはテスト中に外部への依存を排除し、一貫した結果を保証するために使用されます。以下の例では、PersonAPIClientProtocolに準拠したPersonAPIClientStubをテスト対象にDIしています。

final class PersonAPIClientStub: PersonAPIClientProtocol {
    func fetch(for id: Int) async throws -> Person {
        .init(name: "John Doe", id: id)
    }
}

final class TestDoubleTests: XCTestCase {
    func test_FetchFunctionWithStub() async throws {
        let personAPIClientStub = PersonAPIClientStub()
        let testTarget = TestTarget(personAPIClient: personAPIClientStub)

        try await testTarget.fetchPerson(for: 0)
        XCTAssertNotNil(testTarget.person)
    }
}

このようにスタブを使用することで、テスト対象がAPIクライアントの振る舞いに依存しないで済むため、ネットワーク問題や他の外部要因に左右されずにテストを実行できます。

PersonAPIClientStub.returnValue: Personを定義し、インスタンス化の際に値を設定し、それをfetchで返す方法もあります。

final class PersonAPIClientStub: PersonAPIClientProtocol {
    var returnValue: Person

    init(returnValue: Person) {
        self.returnValue = returnValue
    }

    func fetch(for id: Int) async throws -> Person {
        returnValue
    }
}

モック(Mock Object)

モックは、テスト対象から依存コンポーネントへのアクセスを記録するものです。

ここで言うアクセスとは、メソッドの呼び出し回数、呼び出し時の引数、プロパティの値の取得などです。記録した値が事前に想定していた値と合致しているか確認することで、テスト対象と依存コンポーネント間のコミュニケーションの問題の有無を検証できます。

struct Person: Equatable {
    let name: String
    let id: Int
}

protocol PersonAPIClientProtocol {
    func upload(_ person: Person) async
}

final class PersonAPIClientMock: PersonAPIClientProtocol {
    private(set) var receivePeople: [Person] = .init()
    var calledCount: Int { receivePeople.count }

    func upload(_ person: Person) {
        receivePeople.append(person)
    }
}

final class TestTarget {
    private let personAPIClient: any PersonAPIClientProtocol

    init(personAPIClient: any PersonAPIClientProtocol) {
        self.personAPIClient = personAPIClient
    }

    func upload(_ person: Person) async {
        await personAPIClient.upload(person)
    }
}

func test_UploadFunctionWithMock() async {
    let personAPIClientMock = PersonAPIClientMock()
    let testTarget = TestTarget(personAPIClient: personAPIClientMock)

    let person = Person(name: "John Doe", id: 0)
    await testTarget.upload(person)

    XCTAssertEqual(personAPIClientMock.receivePeople.first, person)
    XCTAssertEqual(personAPIClientMock.calledCount, 1)
}

スパイ(Test Spy)

スパイは、テスト対象と本番環境で使用する依存コンポーネント間の仲介を担うと同時に、テスト対象から本番環境で使用する依存オブジェクトへのアクセスを記録するものです。

記録した値が事前に想定していた値と合致しているか確認することで、テスト対象と依存コンポーネント間のコミュニケーションの問題の有無を検証できます。スパイとモックと似ていますが、モックは「テスト対象 → 依存オブジェクト(偽物)」であるのに対し、スパイは「テスト対象 → スパイ → 依存オブジェクト(本物)」となっています。

struct Pokemon: Decodable {
    let id: Int
    let name: String
}

protocol PokeAPIClientProtocol {
    func fetch(for id: Int) async -> Pokemon?
}

final class PokeAPIClient: PokeAPIClientProtocol {
    func fetch(for id: Int) async -> Pokemon? {
        guard
            let url = URL(string: "https://pokeapi.co/api/v2/pokemon/\(id)"),
            let data = try? await URLSession.shared.data(from: url).0,
            let pokemon = try? JSONDecoder().decode(Pokemon.self, from: data)
        else {
            return nil
        }

        return pokemon
    }
}

final class PokeAPIClientSpy: PokeAPIClientProtocol {
    private let pokeAPIClient = PokeAPIClient()
    private(set) var recievedIDs: [Int] = .init()
    var calledCount: Int { recievedIDs.count }

    func fetch(for id: Int) async -> Pokemon? {
        recievedIDs.append(id)
        return await pokeAPIClient.fetch(for: id)
    }
}

final class TestTarget {
    private(set) var pokemon: Pokemon?
    private let pokeAPIClient: any PokeAPIClientProtocol

    init(pokeAPIClient: any PokeAPIClientProtocol) {
        self.pokeAPIClient = pokeAPIClient
    }

    func fetchPokemon(for id: Int) async {
        let pokemon = await pokeAPIClient.fetch(for: id)
        self.pokemon = pokemon
    }
}

func test_FetchPokemonFunctionWithSpy() async {
    let pokeAPIClientSpy = PokeAPIClientSpy()
    let testTarget = TestTarget(pokeAPIClient: pokeAPIClientSpy)

    let pokemonID = 1
    await testTarget.fetchPokemon(for: pokemonID)

    XCTAssertEqual(pokeAPIClientSpy.recievedIDs.first, pokemonID)
    XCTAssertEqual(pokeAPIClientSpy.calledCount, 1)
}

フェイク(Fake Object)

フェイクは、本番環境で使用する依存コンポーネントに近い機能をもつ代用品です。

本番環境で使用する依存コンポーネントの実装よりも簡単、または軽量な方法で機能を実装します。例えば、データベースのアクセスを行う依存コンポーネントの代わりに、インメモリデータベースを使用することがあります。

struct User: Equatable {
    typealias ID = Int
    var name: String
    var description: String
    let id: Int
}

protocol UserRepositoryProtocol {
    func fetch(for id: Int) -> User?
    func save(_ user: User)
}

final class FakeUserRepository: UserRepositoryProtocol {
    private(set) var users: [Int: User]

    init(users: [Int : User]) {
        self.users = users
    }

    func fetch(for id: Int) -> User? {
        return users[id]
    }

    func save(_ user: User) {
        users[user.id] = user
    }
}

@MainActor
final class TestTarget: ObservableObject {
    @Published private(set) var beingEditingUser: User?
    private let userRepository: any UserRepositoryProtocol

    init(userRepository: any UserRepositoryProtocol) {
        self.userRepository = userRepository
    }

    func fetchUser(for id: Int) {
        beingEditingUser = userRepository.fetch(for: id)
    }

    func updateUser(name: String?, description: String?) {
        guard var user = self.beingEditingUser,
              (name != nil) || (description != nil) else {
            return
        }

        if let name { user.name = name }
        if let description { user.description = description }

        self.beingEditingUser = user
        userRepository.save(user)
    }
}

@MainActor
func test_updateFunctionWithFake() {
    let sampleUsers: [User.ID: User] =
    [1: .init(name: "Naruto", description: "Good morning", id: 1),
     2: .init(name: "Sasuke", description: "Good afternoon", id: 2),
     3: .init(name: "Sakura", description: "Good evening", id: 3)]

    let updatedUsers: [User.ID: User] =
    [1: .init(name: "Naruto Uzumaki", description: "Hello, world.", id: 1),
     2: .init(name: "Sasuke Uchiha", description: "Hello, world.", id: 2),
     3: .init(name: "Sakura Haruno", description: "Hello, world.", id: 3)]

    let fakeUserRepository = FakeUserRepository(users: sampleUsers)
    let testTarget = TestTarget(userRepository: fakeUserRepository)

    updatedUsers.forEach { (userID, user) in
        testTarget.fetchUser(for: userID)
        XCTAssertNotNil(testTarget.beingEditingUser)
        testTarget.updateUser(name: user.name, description: user.description)
    }

    XCTAssertTrue(updatedUsers == fakeUserRepository.users)
}

ダミー(Dummy Object)

ダミーは、テスト対象の依存コンポーネントとは直接的に関係はないが、テスト対象の要件を満たすためだけに用いるものです。

以下のテストにおいてTestTarget.loggerはテスト結果に影響を与えるものではありませんが、インスタンス化する際にLoggerProtocolに準拠したものをDIしなければなりません。このような場合にダミーは使用されます。

protocol LoggerProtocol {
    func run()
}

final class DummyLogger: LoggerProtocol {
    func run() {}
}

final class TestTarget {
    private(set) var pokemon: Pokemon?

    private let logger: any LoggerProtocol
    private let pokeAPIClient = PokeAPIClient()

    init(logger: any LoggerProtocol) {
        self.logger = logger
    }

    func fetchPokemon(for id: Int) async {
        logger.run()
        pokemon = await pokeAPIClient.fetch(for: id)
    }
}

func test_FetchPokemonWithDummy() async {
    let dummyLogger = DummyLogger()
    let testTarget = TestTarget(logger: dummyLogger)

    let pokemonID = 1
    await testTarget.fetchPokemon(for: pokemonID)

    XCTAssertNotNil(testTarget.pokemon)
}

Summary

スタブ(Test Stub)
本番環境で使用する依存コンポーネントに代わり、テスト用にプリセットされた値を返すもの

モック(Mock Object)
テスト対象から依存コンポーネントへのアクセスを記録するもの

スパイ(Test Spy)
テスト対象と本番環境で使用する依存コンポーネント間の仲介を担うと同時に、テスト対象から本番環境で使用する依存オブジェクトへのアクセスを記録するもの

フェイク(Fake Object)
本番環境で使用する依存コンポーネントに近い機能をもつ代用品

ダミー(Dummy Object)
テスト対象の依存コンポーネントとは直接的に関係はないが、テスト対象の要件を満たすためだけに用いるもの

Bonus

Existential Typeではなく、ジェネリクス型を使うのも一つの方法だと思います。

final class TestTarget<PersonAPIClientType: PersonAPIClientProtocol> {
    private(set) var person: Person?
    private let personAPIClient: PersonAPIClientType

    init(personAPIClient: PersonAPIClientType) {
        self.personAPIClient = personAPIClient
    }

    func fetchPerson(for id: Int) async {
        person = await personAPIClient.fetch(for: id)
    }
}
脚注
  1. 代用品に差し替えるテクニック自体をテストダブルと呼ぶこともあります。 ↩︎

Discussion