Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

概要

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

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

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

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

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

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

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

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

    クラス外とのやりとりは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)
    }
}
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした