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

  • 28
    Like
  • 0
    Comment

この記事はやさしいSwift単体テスト~テスト可能なクラス設計・前編~の続きです。
前編のまとめから話を続けます。

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

1. クラス外の値を内部で利用しているコード

  1. テストが書けない or 書きづらい
    → クラス外の値 = どんなものが返ってくるか分からない = テストできない
    回避策A: テスト時は返ってくる値を固定値にしてしまう(= 返ってくる値が明確)
  • 2. テスト失敗時に原因が明確にできない
    → クラス外 = どんな実装になっているかわからない = テスト失敗時に原因が明確にできない
    回避策B: クラス外 の実装がどのように行われているかは意識しなくて良い様にする

2. 大域変数(UserDefaultsなど)に副作用が生じるコード

  • 1. 与えた変更を消し忘れると、本体実行時や他のテストに影響する
    回避策C: 変更を行ってる箇所を明確にする。また、変更は最小限に止める。

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

暗黙的入力のテスト

  • ※おさらい: 暗黙的入力 = クラス外から取ってきた値を計算に使っている = テストが書きづらい

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


class ImplicitInput {

    private let data = Data(value: 1)

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

「クラス外の値を内部で利用しているコード」 になります。
現状では、「Data#double() = 2 の時、 ImplicitInput#reduce() = 1 となる」 といったテストは書けません。

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

スタブとは

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

  • スタブ = 事前に設定した振る舞いをする
偽物のオブジェクト

スタブを使うと何が良いのか

  • プロパティ値や戻り値を固定にできる
    • 回避策A: テスト時は返ってくる値を固定値にしてしまう(= 返ってくる値が明確) に対応
  • テストしたいクラス以外の実装がどのように行われているかは意識しなくて良くなる
    • 回避策B: クラス外 の実装がどのように行われているかは意識しなくて良い様にする に対応
  • 上記の過程で、 大域変数へ変更を行ってる箇所が明確になる
    • 回避策C: 変更を行ってる箇所を明確にする。また、変更は最小限に止める。 に対応

スタブが挟めるように本体コードを書く

スタブを挟むためには準備が必要です。

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

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

Protocol に定義するのは、「偽物を挟みたい処理」です
例題の場合は、「Dataクラス から値を取得している処理」ですね。


protocol ReadableRepositoryContract {

    func read() -> Int

}

2. 暗黙的入力の箇所では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: reduce() 内では、ReadableRepositoryContract を利用する

3. Protocolに準拠したオブジェクトを作成する

class ReadableRepository: ReadableRepositoryContract {

    private let data: ImplicitInput.Data

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

    // プロトコル準拠部分
    func read() -> Int {
        return self.data.double()
    }

}

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

class ReadableRepository: ReadableRepositoryContract {

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

}

本体コード変更後

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 {
        return self.base
    }

}

2. スタブを使ってテストを書く

  • 確認事項

    • 「Repositoryから取得する値 - 1 = 戻り値」 となること
  • 状態

    • Repositoryから取得する値: 2
  • 想定結果

    • 戻り値: 1 となること

class ImplicitInputTests: XCTestCase {

    func testMultiplication() {
        let int = 2
        let expected = 1

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

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

        // - 3: 内部でスタブが利用される
        let actual = input.reduce()

        XCTAssertEqual(actual, expected)

    }

}

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

スタブ説明のまとめ

スタブを使うと何が良いのか

  • プロパティ値や戻り値を固定にできる
    → テストを行う前の準備が用意になり、テストが書きやすい
  • テストしたいクラス以外の実装がどのように行われているかは意識しなくて良くなる
    ImplicitInputクラス が利用するのは ReadableRepositoryContract であり、 ReadableRepositoryContractの中身がどうなっているかは知らなくて良い
  • 上記の過程で、 大域変数へ変更を行ってる箇所が明確になる
    → 大域変数へ変更を行ってる箇所 = ReadableRepositoryContract

暗黙的出力のテスト

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

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


class ImplicitOutput_1 {

    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 に 2 を渡した時、Data.value = 2 となる」 といったテストは書けません。

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

スパイとは

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

  • スパイ = 偽物の関数やオブジェクトで、呼び出しや、自身への変更を記録する

スパイを使うと何が良いのか

  • テストしたいクラス以外の実装がどのように行われているかは意識しなくて良くなる
    • 回避策B: クラス外 の実装がどのように行われているかは意識しなくて良い様にする に対応
  • 上記の過程で、 大域変数へ変更を行ってる箇所が明確になる
    • 回避策C: 変更を行ってる箇所を明確にする。また、変更は最小限に止める。 に対応
  • 自身への変更を「記録」にとどめ、実際の変更を避けることが可能
    • 回避策C: 変更を行ってる箇所を明確にする。また、変更は最小限に止める。 に対応

スパイを使えるように本体コードを書く

スパイオブジェクトの利用には準備が必要です。

  • 必要な作業
    1. 呼び出しや、自身への変更を行ってる箇所をProtocolにする
    2. 暗黙的出力の箇所ではProtocolを利用する
    3. 本番用にProtocolに準拠したオブジェクトを作成する

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

Protocol に定義するのは、「呼び出しや、自身への変更を行ってる処理」です
この場合は、「 Dataクラス の値を変更している処理」ですね


protocol WritableRepositoryContract {

    func write(int: Int)

}

2. 暗黙的出力の箇所では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: write(int) 内では、WritableRepositoryContract を利用する

3. Protocolに準拠したオブジェクトを作成する


class WritableRepository: WritableRepositoryContract {

    private let data: ImplicitOutput.Data

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

    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:) を実行した時, WritableRepositoryContract#write(int:) へ引数を渡していること
  • 状態

    • 引数値: 2

class ImplicitOutputTests: XCTestCase {

    func testWrite() {
        let int = 2

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

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

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

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

}

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

スパイ説明のまとめ

スパイを使うと何が良いのか

  • テストしたいクラス以外の実装がどのように行われているかは意識しなくて良くなる
    ImplicitOutputクラス が利用するのは WritableRepositoryContract であり、 WritableRepositoryContract の中身がどうなっているかは知らなくて良い
  • 上記の過程で、 大域変数へ変更を行ってる箇所が明確になる
    → 大域変数へ変更を行ってる箇所 = WritableRepositoryContract
  • 自身への変更を「記録」にとどめ、実際の変更を避けることが可能
    → 記録 = WritableRepositoryContract

おまけ: Repositoryのテスト

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

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

ReadableRepositoryのテスト


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

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

}

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

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

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

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

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

}

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を読んで(実践して)からだいぶ改善されました。
「テストコード書くようになったけど、時間かかってつらい」 という方はそちらの記事も参考にして見てください