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

はじめに

  • この記事は iOS Test Night #5 で発表したスライド「単体テストのハジメ」の説明補強版です
  • スライドでは説明を省いた部分も記述しているため、記事が長くなりましたが、内容を複雑にしたわけではないので読むのは容易だと思います(願望)
  • スライドは Speaker Deck にあげています

書いた人

  • 単体テスト書くようになって半年ぐらい

対象読者は以下の方々

  • テストを書こうと思っているものの、どうやって書いたらいいか分からない方
  • テスト初心者の方で、テストの手法について知りたい方
  • XCTestのことは知っている方(XCTestの使い方、といった説明はなくてokな方)

概要

  • 前編(この記事): テスト対象と、テストが書きづらいコードはなぜ書きづらいのかを説明します。

  • 後編: テストが書きづらいコードを書きやすいコードへ変更する方法、実際のテストコードを説明します。

  • さくっとテストの書き方だけ知りたいという方は 前半のまとめに目を通していただいた後、後半へお進みください。

テストが書きづらいコードとは

テストが書きづらいコード = テストが書きやすいように設計されていないコード となります。
以降では、書きやすさ/書きづらさの具体例を説明します。

テストの対象

テストの対象は大きく分けると以下の2種類になります。

  • 明示的入出力
    • テストが書きやすい
  • 暗黙的入出力
    • テストが書きづらい

それぞれの具体的なコードを順番に見てみましょう。

1. 明示的入出力のみのコード

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

  • 明示的入出力 = 引数と戻り値以外の値が利用されない処理のこと

具体的には以下のようなコードになります。


func doSomething(arg: Int) -> Int {
    return arg * 2
}

明示的入出力のテスト

明示的入出力の処理はテストが書きやすいです。


class Tests: XCTestCase {

    func testSomething() {

        // 引数として渡す値
        let arg = 1

        // 期待する戻り値
        let expected = 2

        // 実際の戻り値
        let actual = doSomething(arg: arg)

        XCTAssertEqual(actual, expected)
    }

}

テストが書きやすくて話すことが無いので、以降 明示的入出力のテストに関しては特に言及しません。

2. 暗黙的入出力があるコード

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

  • 暗黙的入出力 = 処理を行う過程で、引数・戻り値以外の値を利用しているもの
    • 例) クラス外の値、外部との通信結果、UserDefaults、global変数 などを利用

例えば、「クラス外の値を利用する場合」は以下のようなコードになります。


class Sample {

    private let data: SampleData

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

    func reduce () -> Int {
        // Sampleクラス外の値を利用する
        return self.data.double() - 1
    }



    class SampleData {

        let value: Int

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

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

暗黙的入出力のテスト

暗黙的入出力のテストは、明示的入出力ほど簡単には書けません。
どころか、 現状の Sampleクラス単体テストが書けないクラス です。

説明のため、Sampleクラスのテストを考えてみましょう。

1. クラス外の値を内部で利用する場合のテスト

Sampleクラスreduce() は、クラス外の値(SampleData#double())を利用しています。
例えば、「SampleData#double() の値が 4 の場合は、 Sample#reduce() は 3 を返す」というパターンをテストするとしましょう。


class SampleTests: XCTestCase {

    func testReduce() {
        // SampleData#double() の値が 4 の場合は、
        let testSampleDateDouble = 4

        // Sample#reduce() の戻り値は 3 となるはず
        let expected = 3

        // テストを行うために、
        // SampleData#double() の値を 4 にしたい...が、どうすれば?
        let sample = Sample()

        // something to do test ...

    }
}

うーん、書けない。

  • なぜ書けないか?
    • Sample#resuce() をテストするためには、 SampleData#double() を任意の値(= 4) にする必要がある
    • しかし、Sampleクラスself.data は private かつ、内部 で生成されている。よって、SampleData#double() の値は外部から操作できない

つまり、テストを行うための値を準備できない のでテストを書けません。

テストを行うための値を準備できる場合はどうなるか

では、テストを行うための値を準備できる場合はテストが書けるのでしょうか?
仮に self.data の実態を Sample#init() 時に渡すように変更した場合はどうでしょうか?

“テスト” を書けるか書けないかで言えば、書けます。
しかし、それは Sample#reduce() のみのテストではなくなります。(= “単体テスト” は書けません。)

  • なぜ単体テストが書けないのか?
    • Sample#reduce() の値は、 SampleData#double() の戻り値に影響を受ける
    • Sample#reduce() のテストの結果が失敗だった場合、Sampleクラス の実装が間違っているかもしれないし、SampleDataクラス が間違っているかもしれない

Sampleクラス のテストを書きたいのに SampleDataクラス のことまで心配しなければいけなくなります。

疑う対象が増え テスト失敗時に、原因がクラス内/外どちらなのか明確にできない ため、 Sampleクラス のみの単体テストは書けません。
テスト失敗時に原因が明確にできなくなるので、他のクラスに影響をうける作りは極力避けたいです。

テストを書くためには

テストが書けない理由は、テストが書きやすいように設計されていないからです。
具体的なコードは後編に書きますが、テストを書くためには下記のような変更が必要になります。

  • Protocolを利用して SampleData#double() の値を差し替え可能にする
  • Protocolを利用することで SampleDataクラス の実装が Sampleクラス へ影響しないようにする

2. 大域変数(UserDefaultsなど)を利用する場合のテスト

Sampleクラス はクラス外の値を利用しているため、単体テストが書けない例でした。
次に、UserDefaultsを利用する場合を見てみましょう。
この場合は、単体テストは書けるけども 書きづらい となることが多いです。


// 本体コード
func doSomething(arg: Int) -> Int {
    // UserDefaults を使用する
    let hoge = UserDefaults.standard.integer(forKey: "hoge")
    return arg * hoge
}

// テストコード
class Tests: XCTestCase {

    func testDoSomething() {
        let testValue = 5
        let expected = 10

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

        /* テスト結果確認部分 */
        let actual = doSomething(arg: 2)
        XCTAssertEqual(actual, expected)
        /* テスト結果確認部分 */

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

}

さて、コードにコメントで「つらいポイント」を置きました。テストが書けてもつらい場合があります。

  • なにがつらいのか?
    • テスト行うともれなく大域変数(UserDefaults)への操作が行われる
    • 大域変数(UserDefaults)に加えた変更を取り消すためのコードを都度記述する必要がある

上記テストは実行するたびに副作用が発生します。
与えた変更を消し忘れると、本体実行時や他のテストに影響してしまいます。
あるテストが別のテストへも影響を及ぼす場合、テストはとたんに書きづらくなります。
「変更を消し忘れなければいいのでは」と思うかもしれませんが、テストはできるだけ副作用が少ないことが望ましいです。

テストを書くためには

こちらも具体的なコードは後編に書きますが、テストを書くためには下記のような変更が必要になります。

  • doSomething(:Int) が直接 UserDefaults から値を取得するのをやめる
  • 実際の UserDefaults への操作は1箇所で行い、副作用のあるテストケースを最小限にとどめる

3. 通信の結果を利用する場合のテスト

最後に、なんらかの通信結果を利用する場合を考えてみます。

例えば通信結果によって処理を振り分けているとして、テストを実行するたびに実際の通信を行っていては時間がかかります。
それに、テストケースを網羅する通信結果をすべて取得することは難しいです。

これは、1. クラス外の値を内部で利用する場合のテスト であった
テストを行うための値を準備できない のでテストを書けない」と同じ問題です。

もしも任意の通信結果を使うことができれば、この処理のテストを書くことができます。

前編のまとめ

以上で、前編「テスト対象と、テストが書きづらいコードはなぜ書きづらいのかの説明」を終わりとします。

まとめ

  • テストの対象

    • 明示的入出力
    • 暗黙的入出力 ← テストが書けない or 書きづらい!
  • テストが書けない or 書きづらい原因

    1. クラス外の値を内部で利用している場合
      1. テストを行うための値を準備できない
      2. テスト失敗時に、原因がクラス内/外どちらなのか明確にできない
    2. 大域変数(UserDefaultsなど)を利用している場合
      1. 与えた変更を消し忘れると、本体実行時や他のテストに影響する

後半では、テストが書きづらい原因の回避方法を具体例とともに説明します。 


『 Swift 』Article List