test
iOS
Swift
Quick

StubとSpyを使ってiOSのUnitテストを書いてみた(Clean architecture)

😲「Clean architectureの採用でテストがしやすくなります。」
👶「どうやるの?」

ということで、XCTestsとQuickでテストを書いてみました。

今回は、テストコード自体の書き方とDIをどう利用するかという点で試したかったので
アプリ自体の機能は最低限のものを用意し、「見た海外ドラマのシーズンを記録するアプリ」
という名目の元、ただべた書きされたデータを表示するだけのものとなります。
(もし手元で動かしたいという場合はクローンしてライブラリをインストールするだけで大丈夫です)

Simulator Screen Shot 2017.07.30 0.05.20.png

リポジトリはこちらになります。

GitHub - shymst/iOSTestFrameworkComparison

初めは色々なテストフレームワークを試し、使いやすさなどを比較しようと思いましたが
XCTestsとQuickのどちらかで良いと思ったのでこの2つでしか実装していません。

UITestに関しては後日 Google製のEarlGreyを試す予定です。

今回は、以下の3点を紹介したいと思います。

  • DIを利用した代替物の用意
    • StubとSpyについて
  • XCTests
    • XCTestsの書き方
  • Quick
    • メリット、デメリット

DIを利用した代替物の用意

初めは必要な代替物を全てMockとして実装しようとしていましたが、
先日開催された iOS Test Night #5 の中で発表されていました単体テストのハジメ // Speaker DeckのスライドでStubとSpyについて知り、今回試してみました。

(読み進める前に、まずスライドを一読することをおすすめします。)

StubとSpyについて

スライド内ではStub、Spyの役割を以下のように紹介しています。

Stubは、事前に設定した振る舞いをする偽物のオブジェクト
Spyは、偽物の関数やオブジェクトで呼び出しや自身への変更を記録する

つまり、Stubは入力用の代替物で Spyは出力用の代替物です。

Mockでは入出力の代替物を全て用意する必要があるところ、
StubとSpyではそれぞれを明示的に分別し、必要な代替物を最低限の実装で用意することができます。

ここで具体例を紹介します。
以下の例ではPresenterのテストをするためにUseCaseのStubを実装しています。

まずDramaListUseCaseについてですが、
通常の実装においてfetchDramaListでPresenterへ渡すデータは、
メソッド内で呼んだRepositoryのfetchDramaList から受け取ります。

DramaListUseCase.swift
import Foundation

protocol DramaListUseCase {
    func fetchDramaList(_ closure: @escaping (DramaListModel) -> Void)
}

struct DramaListUseCaseImpl: DramaListUseCase {
    private let repository: DramaRepository
    private let translator: DramaListTranslator

    init(repository: DramaRepository, translator: DramaListTranslator) {
        self.repository = repository
        self.translator = translator
    }

    func fetchDramaList(_ closure: @escaping (DramaListModel) -> Void) {
        repository.fetchDramaList {
            closure(translator.translate(from: $0))
        }
    }
}

この通常の実装で返ってくるデータは (本来は) APIのデータだったりDBのデータだったりで変動的です。
すると、以下のような問題が発生することがあります。

  • API通信の影響でテストが遅い、又は止まる
  • データの受取が正常に行われるかをテストしたいのに返ってくるデータが無い

クライアント側のテストでは実際のデータをテストしたいのではなく、関数から正常にデータが渡ってくるか、
もしくは加工したデータの値が想定のものとなっているかをテストしたいので、返ってくるデータはテスト用に操作したいです。

そこで前述したStub(入力用の代替物)を使います。

以下のDramaListUseCaseStub では、resultToBeReturnedという変数を持っています。
この値をテストメソッド内で設定し、そのままfetchDramaList のクロージャで返します。
そうすることで本番データに関係なくテストを行うことができ、更にテストメソッド毎に希望の値を用意することが可能です。

DramaListUseCaseStub.swift
import XCTest
@testable import iOSTestFrameworkComparison

class DramaListUseCaseStub: DramaListUseCase {

    // Testsで生成する際にこの値を設定する
    var resultToBeReturned: DramaListModel!

    func fetchDramaList(_ closure: @escaping (DramaListModel) -> Void) {
        // 設定された値をそのまま返す
        closure(resultToBeReturned)
    }
}

DramaListPresenterTeststest_fetchDramaList_success_numberOfDramaListでは、
Presenter内のfetchDramaListをテスト対象としています。

UseCaseから渡されるデータ数とPresenterで管理するdramaListに格納されたデータ数が同じかをテストをしています。

DramaListPresenterTests.swift
import XCTest
@testable import iOSTestFrameworkComparison

class DramaListPresenterTests: XCTestCase {

    let viewControllerSpy = DramaListViewControllerSpy()
    let wireframe = DramaListWireframeImpl(viewController: DramaListViewController())
    let useCaseStub = DramaListUseCaseStub()

    var presenter: DramaListPresenterImpl!

    override func setUp() {
        super.setUp()
        presenter = DramaListPresenterImpl(viewController: viewControllerSpy, wireframe: wireframe, useCase: useCaseStub)
    }

    // ドラマ数が3つになっていること
    func test_fetchDramaList_success_numberOfDramaList() {
        // Given
        let expectedNumberOfDramaList = 3
        let dramaListToBeReturned = DramaListModel.createDramaList(numberOfElements: expectedNumberOfDramaList)
        useCaseStub.resultToBeReturned = dramaListToBeReturned

        // When
        presenter.fetchDramaList()

        // Then
        XCTAssertEqual(expectedNumberOfDramaList, presenter.dramaList.items.count)
    }
}

このテストを本番データの元に行おうとすると、恐らく不可能に近いと思います。
前述の通り、本番データはデータが変動的であり常に同じ個数が返ってくるとは限らないからです。

そこで先程作ったDramaListUseCaseStubを利用してテストメソッド内で希望の値を渡します。

PresenterのfetchDramaListを呼ぶ前に、希望の値を用意しdramaListToBeReturnedに渡します。
その後にfetchDramaListを呼び、dramaListの値を検証します。
希望の値とdramaListの個数が合っていれば成功となります。

このように必要最低限のオブジェクトを用意することでテストが簡単に行なえます。
また上記の場合、通常の実装ではUseCaseを呼んだ際にRepositoryを注入する初期化を必要があり、
使わないオブジェクトも定義しなければいけないですが、それについても省くことができます。

具体的なの実装方法はFortechRomania/ios-mvp-clean-architecture のリポジトリを参考にしました。

XCTests

XCTestsのテストメソッド内の書き方をテンプレート化することで可読性を保てるのではないかと感じたので紹介します。

XCTestsの書き方

まず、XCTestsではRailsのRspecやテストフレームワークライブラリのQuickのように状態を分けて書けません。
データの生成、テストターゲットの呼び出し、検証コードが混在していると可読性が悪いです。

ios-mvp-clean-architecture/BooksPresenterTest.swift at master · FortechRomania/ios-mvp-clean-architecture · GitHub

上記リポジトリを参考にした際に、テストメソッド内でコメントをフェーズに分けて付けており、
これなら可読性が保てるのではないかと思いました。

import XCTest
@testable import Library

class BooksPresenterTest: XCTestCase {
    // https://www.martinfowler.com/bliki/TestDouble.html
    let diplayBooksUseCaseStub = DisplayBooksUseCaseStub()
    let deleteBookUseCaseSpy = DeleteBookUseCaseSpy()
    let booksViewRouterSpy = BooksViewRouterSpy()
    let booksViewSpy = BooksViewSpy()

    var booksPresenter: BooksPresenterImplementation!

    // MARK: - Set up

  override func setUp() {
      super.setUp()
        booksPresenter = BooksPresenterImplementation(view: booksViewSpy,
                                        displayBooksUseCase: diplayBooksUseCaseStub,
                                        deleteBookUseCase: deleteBookUseCaseSpy,
                                        router: booksViewRouterSpy)
  }

    // MARK: - Tests

    func test_viewDidLoad_success_refreshBooksView_called() {
        // Given
        let booksToBeReturned = Book.createBooksArray()
        diplayBooksUseCaseStub.resultToBeReturned = .success(booksToBeReturned)

        // When
        booksPresenter.viewDidLoad()

        // Then
        XCTAssertTrue(booksViewSpy.refreshBooksViewCalled, "refreshBooksView was not called")
    }   
}

コメントのフェーズは以下を示します。

  • Given: 事前に準備する値の設定
  • When: 実行(実行のタイミング)
  • Then: 検証コード

又、Swift(Objective-C)はメソッド名をキャメルケースで書くのが一般的ですが、
テストメソッドは長文になりやすいため スネークケースで書いたほうが読みやすいと感じました。

以上について書きましたが、Xcode9ではテスト機能がアップデートされ、
Activitiesという処理をクロージャでグルーピングできる機能が追加されたのでそちらを使えば解決できるかもしれません。

参考: 【まとめ】What’s New in Testing【WWDC 2017】 - Qiita

Quick

Quickを初めて使ってみて感じたメリデメを共有します。
Quickのmatcherとして使われているNimbleのことも含んでいます。

メリット、デメリット

メリット

  • テストメソッド名を分割して書ける(describecontextit
  • 似ている describecontext をまとめることで可読性が上がる
  • 必要な箇所で事前/事後処理ができる
  • テストコードの書き方が少し短くなる(Nimble)
    • エラーメッセージを自前で書く必要がなくなる

デメリット

  • テストメソッドレベルで成功/失敗マークが付かない
  • ライブラリに依存する
  • 学習コストが掛かる
    • ドキュメントも丁寧に書かれてるのでハードルは低いです
    • rspecなどで慣れている場合は寧ろ書きやすくなるかも?

メリデメの補足

メソッド名を分割して書ける

XCTestで書く場合

func testDolphin_click_whenTheDolphinIsNearSomethingInteresting_isEmittedThreeTimes() {
  // ...
}

Quickで書く場合

describe("a dolphin") {
  describe("its click") {
    context("when the dolphin is near something interesting") {
      it("is emitted three times") {
        // ...
      }
    }
  }
}
  • Exapmlesの it と Example groups の describecontext
    • describe には「何のテストをするか」を記述する
    • context には「どういった条件のテストをするのか」を記述する
    • it には「期待される結果」を記述する
  • describe, contextに関しては入れ子にして複数回使用できる

「〜について(describe) 〜の場合(context) 〜であること」(it)」というように 文章として読めるとよさそう

必要な箇所で事前/事後処理ができる

beforeEach と afterEach を使って Setup/Teardown のコードを共有することができます。

下記の例ではits clickの Example group のテストを実行する前に beforeEachを使って新しい Dolphin のインスタンスを生成しています。
各 Example において "新しい" 状態でテストが行えます。

import Quick
import Nimble

class DolphinSpec: QuickSpec {
  override func spec() {
    describe("a dolphin") {
      var dolphin: Dolphin!
      beforeEach { dolphin = Dolphin() }

      describe("its click") {
        context("when the dolphin is not near anything interesting") {
          it("is only emitted once") {
            expect(dolphin!.click().count).to(equal(1))
          }
        }

        context("when the dolphin is near something interesting") {
          beforeEach {
            let ship = SunkenShip()
            Jamaica.dolphinCove.add(ship)
            Jamaica.dolphinCove.add(dolphin)
          }

          it("is emitted three times") {
            expect(dolphin.click().count).to(equal(3))
          }
        }
      }
    }
  }
}
テストの書き方が少し短くなる(Nimble)
// XCTAssert
XCTAssertEqual(1 + 1, 2, "expected one plus one to equal two")

// Nimble
expect(1 + 1).to(equal(2))

他にも以下のような書き方ができる。詳しくは Nimble ページを参照

expect(1 + 1).to(equal(2))
expect(1.2).to(beCloseTo(1.1, within: 0.1))
expect(3) > 2
expect("seahorse").to(contain("sea"))
expect(["Atlantic", "Pacific"]).toNot(contain("Mississippi"))
expect(ocean.isClean).toEventually(beTruthy())
エラーメッセージを自前で書く必要がなくなる

Nimbleで用意されているAssertion(テストコード)をうまく使うことでクリティカルなエラーメッセージを吐き出せる。

Nimble では自分でメッセージを指定しなくても Nimble がとても読みやすいメッセージを返してくれます。
Nimble は具体的な失敗メッセージを返してくれる多くの種類の Assertion を提供します。
XCTAssert と違って毎回自分でメッセージを指定することはありません。

終わりに

Clean Architectureを採用しているとProtocolでのI/Fの用意はできているので代替物の導入がしやすいですね。
私自身テストを書くことに慣れていないため、おかしい点などがありましたら指摘していただけると幸いです。

参考記事 🙇

stub/spy

XCTests

Quick

UITest