post Image
やさしいSwift単体テスト~テスト可能なクラス設計・後編~

概要

テストが書けない/書きづらいコードの原因と回避策

前編のまとめから話を続けます。

クラス外の値を内部で利用している場合

  • テストを行うための値を準備できない

    • クラス外の値 = どんなものが返ってくるか分からない = テストできない

    テスト時のみ、返ってくる値を固定値にする(= 返ってくる値が明確) ことで回避します。

  • テスト失敗時に、原因がクラス内/外どちらなのか明確にできない

    • クラス外の値 = どんな実装になっているかわからない = テスト失敗時に原因が明確にできない

    クラス外とのやりとりはProtocolを通すことで、実際の実装との関係を切り離す ことで回避します。

大域変数(UserDefaultsなど)を利用している場合

  • 与えた変更を消し忘れると、本体実行時や他のテストに影響する

    大域変数と直接やりとりを行わないようにし、大域変数への変更は最小限にとどめる ことで回避します。

  
以降では、上記の要望を満たすための方法を具体例とともに説明します。 
暗黙的入力のテスト と、 暗黙的出力のテスト と2つに分かれますが、行う作業はほぼ同じです。

暗黙的入力のテスト

  • 暗黙的入力 = クラス外から値を取得している = テストが書けない/書きづらい

例えば、以下の本体コードがあるとします。

class ImplicitInput {
    private let data: Data

    init() {
        // 1 ~ 10 までのランダムな数字を使って Data クラスを生成する
        let random = Int(arc4random_uniform(10) + 1)
        self.data = Data(value: random)
    }

    func reduce () -> Int {
        // クラス外から値を取得している
        return self.data.double() - 1
    }



    class Data {

        let value: Int

        init(value: Int) {
            self.value = value
        }

        func double() -> Int {
            return value * 2
        }
    }
}

ImplicitInput#reduce() の部分で、暗黙的入力を行なっています。

現状では「Data#double() = 4 の時、 ImplicitInput#reduce() = 3 となる」といったテストは書けません。
しかし、 Data#double() の値を指定できるようになれば、テストを書けるようになります。

=> スタブ というオブジェクトを利用する手法を紹介します。

スタブとは

単体テストのハジメver2.019.jpeg

  • スタブ = 事前に設定した振る舞いをする
偽物のオブジェクト。特に、取得用のオブジェクトとして利用する。

スタブを利用すると何が良いのか

スタブを利用することで、 冒頭で示した 3つの回避策

  • テスト時のみ、返ってくる値を固定値にする(= 返ってくる値が明確)
  • クラス外とのやりとりはProtocolを通すことで、実際の実装との関係を切り離す
  • 大域変数と直接やりとりを行わないようにし、大域変数への変更は最小限にとどめる

を全て満たすことができます。

本番時には 「本物の振る舞い」をするオブジェクトを利用し、
テスト時には「偽物の振る舞い」をするオブジェクト(スタブ)に差し替える。
そうすることで、本番の動きは変えずにテストを書けるようになります。

スタブを利用するために本体コードを変更する

スタブへ差し替え可能にするためには準備が必要です。

  • 必要な作業
    1. 制御したい振る舞いをProtocol定義する
    2. 暗黙的入力を行なっている箇所でProtocolを利用する
    3. 本番用にProtocolに準拠したクラスを定義する

1. 制御したい振る舞いをProtocol定義する

「クラス外から取得した値」に相当するProtocolを定義します。
例題の場合は、Data#double() です。


// 取得用のプロトコル
protocol ReadableRepositoryContract {

    // Data#double() に相当する
    func read() -> Int

}

2. 暗黙的入力を行なっている箇所でProtocolを利用する

次に、定義したProtocolを本体コードで利用します。

変更後のコード:


class ImplicitInput {

    // - 変更1
    private let repository: ReadableRepositoryContract

    // - 変更2
    init (readVia repository: ReadableRepositoryContract) {
        self.repository = repository
    }

    func reduce() -> Int {

        // - 変更3
        return self.repository.read() - 1
    }
}
  • 変更1: 取得用の ReadableRepositoryContractプロトコル を プロパティで持つ
  • 変更2: プロパティの実態は 初期化時に init の引数としてもらう
  • 変更3: ImplicitInput#reduce() 内では、ReadableRepositoryContractプロトコル を利用する

上記変更のおかげで、テスト時に ReadableRepositoryContractプロトコル に準拠した偽物のオブジェクト(スタブ)を利用できるようになります。

3. 本番用にProtocolに準拠したクラスを定義する

本番時に利用するオブジェクトは別途必要になるので、元となるクラスを定義します。


// 本番用の ReadableRepositoryContractプロトコル に準拠したクラス
class ReadableRepository: ReadableRepositoryContract {

    private let data: ImplicitInput.Data

    init(data: ImplicitInput.Data) {
        self.data = data
    }

    // プロトコル準拠部分
    // Data#double() に相当する
    func read() -> Int {
        return self.data.double()
    }

}

今回の例題では Dataクラス から値を取得していますが、 UserDefaults から取得する場合は以下のようになります。

class ReadableRepository: ReadableRepositoryContract {

    func read() -> Int {
        return UserDefaults.standard.integer(forKey: "read")
    }

}

本体コード変更後

以上でスタブを利用する準備は完了です。

ちなみに、 本番でImplicitInputクラス を使う時は以下の書き方になります。


// 本番の取得用オブジェクトを生成
let repository = ReadableRepository(
    data: ImplicitInput.Data(value: 5)
)

let implicitInput = ImplicitInput(readVia: repository)

// ReadableRepositoryContract を経由して、 Dataクラス から値を取得する
let result = implicitInput.reduce()

テストを書く

スタブを使う準備が整ったので、実際に ImplicitInputクラス のテストコードを書いていきます。

1. スタブを定義する

スタブ = 取得用の偽物のオブジェクト = 取得用のプロトコル(ReadableRepositoryContract) に準拠しています。


// スタブを定義する
class ReadableRepositoryStub: ReadableRepositoryContract {

    private let base: Int

    init(base: Int) {
        self.base = base
    }

    // 偽物の振る舞いを行なっている箇所
    func read() -> Int {
        // init時に渡された値をそのまま返す
        return self.base
    }

}

2. スタブを利用してテストを書く

本体コードを変更する前は、「Data#double() = 4 の時、 ImplicitInput#reduce() = 3 となる」というテストを書こうとしても書けませんでした。
変更後のコードで、「ReadableRepositoryContract#read() = 4 の時、ImplicitInput#reduce() = 3 となる」というテストを書いてみます。


class ImplicitInputTests: XCTestCase {

    func testMultiplication() {
        // ReadableRepositoryContract#read() = 4 の時、
        let int = 4

        // ImplicitInput#reduce() = 3 となる
        let expected = 3

        // - 1: スタブを作成
        let repositoryStub = ReadableRepositoryStub(readValue: int)

        // - 2: スタブを差し込む
        let input = ImplicitInput(readVia: repositoryStub)

        // - 3: 内部でスタブが利用される
        //      これにより、「ReadableRepositoryContract#read() = 4 の時」を再現できる
        let actual = input.reduce()

        XCTAssertEqual(actual, expected)

    }

}

無事、テストが書けるようになりました。

スタブ説明のまとめ

スタブを利用すると何が良いのか

  • テスト時のみ、返ってくる値を固定値にする(= 返ってくる値が明確) → 該当
    • テストを行う前の準備が用意になり、テストが書きやすくなった
  • クラス外とのやりとりはProtocolを通すことで、実際の実装との関係を切り離す → 該当
    • ImplicitInputクラス 内では ReadableRepositoryContractプロトコル を利用することにより、他のクラスの実装から切り離すことができた
      なので、ImplicitInputクラス のテストが失敗した場合は、ImplicitInputクラス の実装が間違っているということが明確になった
  • 大域変数と直接やりとりを行わないようにし、大域変数への変更は最小限にとどめる → 該当
    • ReadableRepositoryContractプロトコル を通すことで、大域変数と直接やりとりを行わなくなった
      本番時に大域変数を利用する箇所は、テスト時に偽のオブジェクトへ差し替えることで、大域変数への変更を行わなくて済むようになった

暗黙的出力のテスト

  • 暗黙的出力 = 処理の結果または過程で、クラス外へ変更を行っている = テストが書けない/書きづらい

例として、以下の本体コードがあるとします。


class ImplicitOutput {

    private let data = Data(value: nil)

    func write(int: Int) {

        // クラス外へ変更を行っている
        self.data.value = int
    }

    class Data {

        var value: Int?

        init(value: Int?) {
            self.value = value
        }
    }

}

ImplicitOutput#write(:Int) の部分で、暗黙的出力を行なっています。
現状では、「ImplicitOutput#write(:Int) に 2 を渡した時、Data.value = 2 となる」 といったテストは書けません。

=> スパイ というオブジェクトを利用する手法を紹介します。

スパイとは

単体テストのハジメver3.001.jpeg

  • スパイ = 事前に設定した振る舞いをする偽物の関数やオブジェクト。関数の呼び出し回数や、自身への変更を記録するオブジェクトとして利用する。

スパイを利用すると何が良いのか

スパイを利用することで、 冒頭で示したウチの 2つの回避策

  • クラス外とのやりとりはProtocolを通すことで、実際の実装との関係を切り離す
  • 大域変数と直接やりとりを行わないようにし、大域変数への変更は最小限にとどめる

を満たすことができます。

スパイを利用するために本体コードを変更する

スパイの利用には準備が必要です。

  • 必要な作業
    1. 制御したい振る舞いをProtocolにする
    2. 暗黙的出力を行なっている箇所ではProtocolを利用する
    3. 本番用にProtocolに準拠したオブジェクトを作成する

1. 制御したい振る舞いをProtocolにする

「記録したいクラス外の値」に相当するProtocolを定義します。
例題の場合は、Data#value です。


// 記録用のプロトコル
protocol WritableRepositoryContract {

    // Data#value に相当する
    func write(int: Int)

}

2. 暗黙的出力を行なっている箇所ではProtocolを利用する

次に、定義したProtocolを本体コードで利用します。

変更後のコード:


class ImplicitOutput {

    // - 変更1
    private let repository: WritableRepositoryContract

    // - 変更2
    init(writeVia repository: WritableRepositoryContract) {
        self.repository = repository
    }

    func write(int: Int) {

        // - 変更3
        self.repository.write(int: int)

    }

}
  • 変更1: 記録用の WritableRepositoryContractプロトコル を プロパティで持つ
  • 変更2: プロパティの実態は 初期化時に init の引数としてもらう
  • 変更3: ImplicitOutput#write(:Int) 内では、WritableRepositoryContractプロトコル を利用する

上記変更のおかげで、テスト時に WritableRepositoryContractプロトコル に準拠した偽物のオブジェクト(スパイ)を利用できるようになります。

3. 本番用にProtocolに準拠したクラスを定義する

本番時に利用するオブジェクトは別途必要になるので、元となるクラスを定義します。


// 本番の WritableRepositoryContractプロトコル に準拠したクラス
class WritableRepository: WritableRepositoryContract {

    private let data: ImplicitOutput.Data

    init(data: ImplicitOutput.Data) {
        self.data = data
    }

    // プロトコル準拠部分
    // Data#value に相当する
    func write(int: Int) {
        self.data.value = int
    }

}

今回の例題では Dataクラス から値を取得していますが、 UserDefaults を変更場合は以下のようになります。


class WritableRepository: WritableRepositoryContract {

    func write(int: Int) {
        UserDefaults.standard.set(int, forKey: "write")
    }

}

変更後の本体コード

以上でスパイを利用する準備は完了です。

ちなみに、 本番で ImplicitOutputクラス を使う時は以下の書き方になります。


// 本番の記録用オブジェクトを生成
// ただし、本番では 記録 の必要はないので 変更 のために使用している
let repository = WritableRepository(
    data: ImplicitOutput.Data(value: nil)
)

let implicitOutput = ImplicitOutput(writeVia: repository)

// WritableRepositoryContractを経由して、Dataクラス の値を変更する
implicitOutput.write(int: 2)

テストを書く

スパイを使う準備が整ったので、スパイを使って実際にテストコードを書いていきます。

1. スパイオブジェクトを書く

スパイ = 記録用の偽物のオブジェクト = 記録用のプロトコル(WritableRepositoryContract) に準拠している


// スパイを定義する
class WritableRepositorySpy: WritableRepositoryContract {

    // メソッドが呼び出された際の引数列。
    private(set) var callArguments: [Int] = []

    // 偽物の振る舞いを行なっている箇所
    func write(int: Int) {
        // 呼び出しを記録してい
        self.record(int)
    }

    private func record(_ args: Int) {
        self.callArguments += [args]
    }

}

2. スパイを使ってテストを書く

本体コードを変更する前は、「ImplicitOutput#write(:Int) へ 2 を渡した時、Data.value = 2 となる」というテストを書こうとしても書けませんでした。
変更後のコードで、「ImplicitOutput#write(:Int) へ 2 を渡した時、 WritableRepositoryContract#write(:Int) へ 同じ値 を渡している」というテストを書いてみます。


class ImplicitOutputTests: XCTestCase {

    func testWrite() {
        // ImplicitOutput#write(:Int) へ 2 を渡した時、
        let int = 2

        // WritableRepositoryContract#write(:Int) へ 同じ値 を渡している
        let expected = int

        // - 1: スパイを作成する
        let spy = WritableRepositorySpy()

        // - 2: スパイを差し込む
        let output = ImplicitOutput(writeVia: spy)

        // - 3: 内部でスパイに記録される
        output.write(int: int)

        // - 4: スパイに記録された値を確認する
        XCTAssertEqual(expected, spy.callArguments.first!)
    }

}

無事、テストが書けるようになりました。

スパイ説明のまとめ

スパイを利用すると何が良いのか

  • クラス外とのやりとりはProtocolを通すことで、実際の実装との関係を切り離す
    • ImplicitOutputクラス 内では WritableRepositoryContractプロトコル を利用することにより、Dataクラス の実装から切り離すことができた
      なので、ImplicitOutputクラス のテストが失敗した場合は、ImplicitOutputクラス の実装が間違っているということが明確になった
  • 大域変数と直接やりとりを行わないようにし、大域変数への変更は最小限にとどめる
    • WritableRepositoryContractプロトコル を通すことで、大域変数と直接やりとりを行わなくなった
      本番時に大域変数を利用する箇所は、テスト時に偽のオブジェクトへ差し替えることで、大域変数への変更を行わなくて済むようになった

おまけ: Repositoryのテスト

ReadableRepositoryWritableRepository ですが、もちろんこれらもテストする必要があります。

RepositoryImplicitXxxxクラス をテストするために必要になったクラスですが、それ以外にも利点があります。
クラス外からの取得/クラス外への変更 のテストを ImplicitXxxxクラス から 分離 できた、という利点です。
テストを分けられるということは、1つのテストで確認する事柄が減るということであり、テストの書きやすさにつながります。

以降には、 Repository 経由で UserDefaults の値を取得/変更 している想定でのテストを載せています。
この階層まできたら、UserDefaults への変更はもう致し方なしと思っています。。。
追記: UserDefaults への直接操作を回避する

ReadableRepositoryのテスト


// 本体コード
class ReadableRepositoryForUserDefaults: ReadableRepositoryContract {

    func read() -> Int {
        return UserDefaults.standard.integer(forKey: "read")
    }

}

// テストコード
class ReadableRepositoryTests: XCTestCase {
    func testRead() {
        let testValue = 2
        let expected = testValue

        // 本体コード、もしくは他のテストから変更されている可能性があるので、UserDefaultsを初期状態にしてからスタート
        UserDefaults.standard.removeObject(forKey: "read")

        // テストを行うために 「事前に入っていて欲しい値」をUserDefaultsに直接書き込む
        UserDefaults.standard.set(testValue, forKey: "read")

        /* Repositoryをテスト */
        let repository = ReadableRepositoryForUserDefaults()
        let actuals = repository.read()
        XCTAssertEqual(actuals, expected)
        /* Repositoryをテスト */

        // 本体コード、もしくは他のテストへ
影響を与えないために、UserDefaultsに加えた変更を取り消す
        UserDefaults.standard.removeObject(forKey: "read")
    }

}

WritableRepositoryのテスト


// 本体コード
class WritableRepositoryForUserDefaults: WritableRepositoryContract {

    func write(int: Int) {
        UserDefaults.standard.set(int, forKey: "write")
    }

}

// テストコード
class WritableRepositoryTests: XCTestCase {
    func testWrite() {
        let testValue = 2
        let expected = testValue

        // 本体コード、もしくは他のテストから変更されている可能性があるので、UserDefaultsを初期状態にしてからスタート
        UserDefaults.standard.removeObject(forKey: "write")

        /* Repositoryをテスト */
        let repository = WritableRepositoryForUserDefaults()
        repository.write(int: testValue)
        let actuals = UserDefaults.standard.integer(forKey: "write")
        XCTAssertEqual(actuals, expected)
        /* Repositoryをテスト */

        // 本体コード、もしくは他のテストへ
影響を与えないために、UserDefaultsに加えた変更を取り消す
        UserDefaults.standard.removeObject(forKey: "write")
    }
}

後半まとめ

  • 暗黙的入力のテスト: Protocolを使ってスタブ(取得用の偽物のオブジェクト)を使えるクラス設計にしよう
  • 暗黙的出力のテスト: Protocolを使ってスパイ(記録用の偽物のオブジェクト)を使えるクラス設計にしよう

  • メリット:

    • 偽物に差し替えられるとテストが書きやすい🎉
    • 結果的に疎結合になる(UserDefaultsにべったり依存😱などがなくなる)

おわりに

スタブ・スパイ に初対面だったという方、いかがだったでしょうか。めんどくさかったですか?
私はとてもめんどくさいと思いました(テストを書くようになった当初の話です)。
あと、テストを書くのも本体コードを書くのもめちゃくちゃ時間がかかりました。

「テスト可能な本体コードの書き方」がぜんっぜん身につかなくて投げ出したかったのですが、
車窓からの TDDを読んで(実践して)からだいぶ改善されました。
「テストコード書くようになったけど、時間かかってつらい」 という方はそちらの記事も参考にして見てください。

追記: UserDefaults への直接操作を回避する

回避策をコメントでいただけました。ありがとうございます!

下記手順でUserDefaults への直接操作を回避します。

  • UserDefaults の必要な部分だけのプロトコルを定義
  • 各Repositoryではプロトコル経由でUserDefaultsと接続する

まずは、UserDefaultsのプロトコル定義部分です。

// 取得用のプロトコル
protocol UserDefaultsReadableContract {
    func integer(forKey defaultName: String) -> Int
    func double(forKey defaultName: String) -> Double
}

// 記録用のプロトコル
protocol UserDefaultsWritableContract {
    func set(_ value: Int, forKey defaultName: String)
    func set(_ value: Double, forKey defaultName: String)
}

次に、本物のUserDefaultsに先ほど定義したのプロトコルを extension します。
これにより、UserDefaultsReadableContractUserDefaultsWritableContract を利用している箇所で
UserDefaults のインスタンスを渡すことが可能になります。
また、今後 Swift バージョンアップなどで UserDefaults のインターフェースが変わった場合、
この部分でコンパイルエラーとなるのでプロトコル定義の間違いに気づけます。

extension UserDefaults: UserDefaultsReadableContract, UserDefaultsWritableContract {}

各Repositoryがプロトコル経由でUserDefaultsと接続するように変更します。

ReadableRepositoryForUserDefaults
class ReadableRepositoryForUserDefaults: ReadableRepositoryContract {

    private let userDefaults: UserDefaultsReadableContract

    // 本体コード側では
    // ReadableRepositoryForUserDefaults(via: UserDefaults.standard) と
    // 宣言することで UserDefaults を利用できる
    init(via userDefaults: UserDefaultsReadableContract) {
        self.userDefaults = userDefaults
    }

    func read() -> Int {
        return self.userDefaults.integer(forKey: "read")
    }
}
WritableRepositoryForUserDefaults
class WritableRepositoryForUserDefaults: WritableRepositoryContract {

    private let userDefaults: UserDefaultsWritableContract

    // 本体コード側では
    // WritableRepositoryForUserDefaults(via: UserDefaults.standard) と
    // 宣言することで UserDefaults を利用できる
    init(via userDefaults: UserDefaultsWritableContract) {
        self.userDefaults = userDefaults
    }

    func write(int: Int) {
        self.userDefaults.set(int, forKey: "write")
    }

}

最後に、テストの部分です。

ReadableRepositoryのテスト
// 新しく定義した UserDefaultsReadableContract のスタブ(取得用の偽物)を作成する
struct UserDefaultsStub: UserDefaultsReadableContract {

    private let keyAndValues: [String: Any]

    init(keyAndValues:  [String: Any] = [:]) {
        self.keyAndValues = keyAndValues
    }

    func integer(forKey defaultName: String) -> Int {
        // テスト時に値を設定し忘れて nil だった場合、実行時エラーで気づける
        return self.keyAndValues[defaultName] as! Int
    }

    func double(forKey defaultName: String) -> Double {
        return self.keyAndValues[defaultName] as! Double
    }

}

// テストコード
class ReadableRepositoryTests: XCTestCase {
    func testRead() {
        let testValue = 2
        let expected = testValue

        // - 1: スタブを作成
        let userDefaultsStub = UserDefaultsStub(
            keyAndValues: ["read": testValue]
        )

        // - 2: スタブを差し込む
        let repository = ReadableRepositoryForUserDefaults(via: userDefaultsStub)

        // - 3: 内部でスタブが利用される
        let actuals = repository.read()
        XCTAssertEqual(actuals, expected)
    }
}
WritableRepositoryのテスト
// 新しく定義した UserDefaultsWritableContract のスパイ(記録用の偽物)を作成する
class UserDefaultsSpy: UserDefaultsWritableContract {

    private(set) var callSetIntArguments: [String: Int] = [:]
    private(set) var callSetDoubleArguments: [String: Double] = [:]

    func set(_ value: Int, forKey defaultName: String) {
        self.callSetIntArguments[defaultName] = value
    }

    func set(_ value: Double, forKey defaultName: String) {
        self.callSetDoubleArguments[defaultName] = value
    }
}

class WritableRepositoryTests: XCTestCase {
    func testWrite() {
        let testValue = 2
        let expected = testValue

        // - 1: スパイを作成する
        let userDefaultsSpy = UserDefaultsSpy()

        // - 2: スパイを差し込む
        let repository = WritableRepositoryForUserDefaults(via: userDefaultsSpy)

        // - 3: 内部でスパイに記録される
        repository.write(int: testValue)

        // - 4: スパイに記録された値を確認する
        let actual = userDefaultsSpy.callSetIntArguments["write"]!
        XCTAssertEqual(actual, expected)
    }
}

『 Swift 』Article List