😲「Clean architectureの採用でテストがしやすくなります。」
👶「どうやるの?」
ということで、XCTestsとQuickでテストを書いてみました。
今回は、テストコード自体の書き方とDIをどう利用するかという点で試したかったので
アプリ自体の機能は最低限のものを用意し、「見た海外ドラマのシーズンを記録するアプリ」
という名目の元、ただべた書きされたデータを表示するだけのものとなります。
(もし手元で動かしたいという場合はクローンしてライブラリをインストールするだけで大丈夫です)
リポジトリはこちらになります。
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
から受け取ります。
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
のクロージャで返します。
そうすることで本番データに関係なくテストを行うことができ、更にテストメソッド毎に希望の値を用意することが可能です。
import XCTest
@testable import iOSTestFrameworkComparison
class DramaListUseCaseStub: DramaListUseCase {
// Testsで生成する際にこの値を設定する
var resultToBeReturned: DramaListModel!
func fetchDramaList(_ closure: @escaping (DramaListModel) -> Void) {
// 設定された値をそのまま返す
closure(resultToBeReturned)
}
}
DramaListPresenterTests
のtest_fetchDramaList_success_numberOfDramaList
では、
Presenter内のfetchDramaList
をテスト対象としています。
UseCaseから渡されるデータ数とPresenterで管理するdramaList
に格納されたデータ数が同じかをテストをしています。
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のように状態を分けて書けません。
データの生成、テストターゲットの呼び出し、検証コードが混在していると可読性が悪いです。
上記リポジトリを参考にした際に、テストメソッド内でコメントをフェーズに分けて付けており、
これなら可読性が保てるのではないかと思いました。
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のことも含んでいます。
メリット、デメリット
メリット
- テストメソッド名を分割して書ける(
describe
、context
、it
) - 似ている
describe
、context
をまとめることで可読性が上がる - 必要な箇所で事前/事後処理ができる
- テストコードの書き方が少し短くなる(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 のdescribe
とcontext
-
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
- Quick/Documentation/ja at master · Quick/Quick · GitHub
- Quickを使ってビューコントローラをテストする
- Quick/ OS XとiOSアプリのテスト
- XCTestと比較しつつQuickについて説明する - Qiita