この記事は フラー Advent Calendar 2020 の14日目の記事です。
13日目は @furusax0621 さんで「新入社員のトレーニングを担当する上で気をつけたこと、気付かされたこと」でした。
はじめに
みなさんはユニットテスト書いてますか?
すでに書いていらっしゃる方はもう読まなくても大丈夫です。おやすみなさい。
ユニットテスト書いてない人、いるんですか?
Nimble/Quick を使ったテストコードの実装をよくあるパターンに則って、実装しつつ紹介していければと思います。
前提
- iOS アプリ開発
- 基本コードは Swift 5
- Xcode 12.2
- RxSwift の完全理解
- MVVM ベースのアーキテクチャ
- Nimble/Quick を使用
- TDD ではないです
- ユニットテストは神ではありません
- テストは、「質の高いコードを書くための一部」でしかない by @t_wada
なぜテストを書くのか
- 自分自身、テストを書く意味は心の安寧です。100% 自分の書いたコードは信用できません。
- リファクタリングをしたときにテストが成功したら大体間違っていない
- 機能追加をしたとき以下同文
- テストが書けるということは設計が良いと感じる
今回実装すること
よくあるテキストのみの投稿画面
UIViewController に UITextView
とナビゲーションバーに rightBarButtonItem
を配置しました。
ここまでの実装はこちら
https://bitbucket.org/nnsnodnb/fuller-advent-calendar-20/src/0.0.1
ちなみに今の時点でバグがあります。なので直しましょう。
また、記事内でたくさん急に仕様変更がされますが記事内だけなので悪しからず。
テキストを入力していないときは送信ボタンを無効にしてほしい
今までの実装で多分だけど、送信ボタンが無効になると思う実装ができた!って心の中では思っています。
しかし、先程紹介した時点でバグがありますと書きました。
実際にテストを書いてみてバグを炙り出しましょう。
import Nimble import Quick import RxSwift import RxTest @testable import App final class ViewModelIsSendButtonEnabledSpec: QuickSpec { override func spec() { var disposeBag: DisposeBag! var testScheduler: TestScheduler! beforeEach { disposeBag = .init() testScheduler = .init(initialClock: 0) } afterEach { disposeBag = nil testScheduler = nil } describe("送信ボタンの状態") { it("何も入力されていないので無効になる") { let viewModel = ViewModel() let observer = testScheduler.createObserver(Bool.self) viewModel.outputs.isSendButtonEnabled.drive(observer).disposed(by: disposeBag) expect(observer.events) == [.next(0, false)] } } } }
とりあえず、上記のようなテストを書いてみました。するとどうでしょう?テストを実行してみます。
何も流れてきていないようです。それもそのはず。 PublishRelay<String>
をそのまま購読しているので初期値がなにもありませんでした。
今回は ViewModel に以下のプロパティを追加してそちらを購読する方式をとってみました。
private let postText: BehaviorRelay<String> = .init(value: "") self.isSendButtonEnabled = postText .map { !$0.isEmpty } .distinctUntilChanged() .asDriver(onErrorDriveWith: .empty()) text.bind(to: postText).disposed(by: disposeBag)
もう一度、テストを実行してみると無事テストが通りました。やったね!
ここまでの実装はこちら
https://bitbucket.org/nnsnodnb/fuller-advent-calendar-20/src/0.0.2
改行及びスペースのみのときも送信ボタンを無効にしてほしい
偉い人からこんな要件が来ました。
まずテストから書いてみましょう。
it("改行のみの入力なので無効のまま") { let viewModel = ViewModel() let observer = testScheduler.createObserver(Bool.self) viewModel.outputs.isSendButtonEnabled.drive(observer).disposed(by: disposeBag) viewModel.inputs.text.accept("\n") expect(observer.events) == [.next(0, false)] } it("スペースのみの入力なので無効のまま") { let viewModel = ViewModel() let observer = testScheduler.createObserver(Bool.self) viewModel.outputs.isSendButtonEnabled.drive(observer).disposed(by: disposeBag) viewModel.inputs.text.accept(" ") expect(observer.events) == [.next(0, false)] }
こんなテストを追加してみました。
distinctUntilChanged
をつけているので false -> false に変わるときはストリームに値は流れてこないのでそれぞれの observer のイベントは1つずつです。
実行するとどうでしょう?もちろんテストは失敗します。
期待値はどちらも
[.next(0, false), .next(0, true)]
のようです。有効になってしまいました。
self.isSendButtonEnabled = postText .map { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
こちらの形に変更してみましょう。こうすると改行及びスペースをトリミングして空文字ではないかの判定に切り替わりました。
テストを実行してみると成功になりました!!
ここまでの実装はこちら
https://bitbucket.org/nnsnodnb/fuller-advent-calendar-20/src/0.0.3
文字数200文字の制限をつけたい
また、急にこんな仕様の追加をされました。
テスト書いてみましょう。
it("200文字を越えたので無効にする") { let viewModel = ViewModel() let observer = testScheduler.createObserver(Bool.self) viewModel.outputs.isSendButtonEnabled.drive(observer).disposed(by: disposeBag) let text: String = .createRandomCharacters(with: 200) // ref: https://bitbucket.org/nnsnodnb/fuller-advent-calendar-20/src/0.0.4/AppTests/StringExtension.swift viewModel.inputs.text.accept(text) viewModel.inputs.text.accept(text + " ") expect(observer.events) == [.next(0, false), .next(0, true), .next(0, false)] }
ここでは 200文字から201文字目の値の変更をみたいので2回データを流しています。また、スペースも文字数カウントに含めたいので201文字目にスペースを追加しています。
self.isSendButtonEnabled = postText .map { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && $0.count <= 200 }
こんな感じに実装を変更してみました。
改行及びスペースが空文字ではない && 文字列が200文字以内 という条件に変更されました。(正確には Bool 値を生成する条件)
ここまでの実装はこちら
https://bitbucket.org/nnsnodnb/fuller-advent-calendar-20/src/0.0.4
WebAPI エンドポイントに送信しているときは、ボタンを無効にしてほしい
投稿があるから、そりゃそうですよね。
別に使わなくてもできるんですけど、みんな大好き Action を使っていきます。
private let postAction: Action<String, Void> = .init { _ in .just(()) }
とりあえず、送信先がないのでこれで Action を実装しました。
送信ボタンのハンドリングは
post.asObservable() .withLatestFrom(isSendButtonEnabled.asObservable()) { $1 } .filter { $0 } .withLatestFrom(postText.asObservable()) { $1 } .bind(to: postAction.inputs) .disposed(by: disposeBag)
という感じ。 UI からは isSendButtonEnabled
が真の場合ではないとデータは流れこない実装にはなっていますが、一応 filter 通しておきます。
it("送信ボタンが押されて完了するまで無効になる") { let viewModel = ViewModel() let observer = testScheduler.createObserver(Bool.self) viewModel.outputs.isSendButtonEnabled.drive(observer).disposed(by: disposeBag) viewModel.inputs.text.accept("投稿するよぉぉ〜〜") viewModel.inputs.post.accept(()) expect(observer.events) == [.next(0, false), .next(0, true), .next(0, false), .next(0, true)] }
ちょっと複雑な期待値になってしまいました。
初期値→文字入力→送信中→送信完了(文字列が存在してしまっている)
という状況になっています。
実装は Observable.combineLatest
を使っていきます。
self.isSendButtonEnabled = Observable.combineLatest( postText.map { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && $0.count <= 200 }, postAction.executing.map { !$0 } ) { $0 && $1 } .distinctUntilChanged()
というような実装にしてみました。
改行及びスペースが空文字ではない && 文字列が200文字以内 という条件にさらに Action が実行中か の判断が追加されました。
ここまではとりあえず、いいでしょう。
しかしお客様は怒りました。 送信できたのにテキストそのままだよ?
僕「忘れてませんよ(半分忘れてた)」
ViewController.swift
にえいやって追加して終わり
viewModel.outputs.didCompleted.map { _ in nil }.drive(textView.rx.text).disposed(by: disposeBag)
UI 周りもテスト書きたいんだけどね。記事作成に時間がかかりすぎているので今回はパスで🙏
ここまでの実装はこちら
https://bitbucket.org/nnsnodnb/fuller-advent-calendar-20/src/0.0.5
外部との通信が発生するときは?
例えば iOS アプリ開発で WebAPI との通信が発生しないことはほぼありません。(あんまり聞かない)
サーバは死なないと思っている人はソフトウェアエンジニアの中にはいないと思います。
例えば WebAPI と URLSession を行うテストがあるとします。たまたまサーバ側がアップデートやメンテナンスのためにシャットダウンされている場合どうでしょうか?
アプリのユニットテストは失敗してしまうでしょう。
そういうことにならないようにテストダブルを使用して部品を置き換えて自分がほしいデータを受け取るようにする実装を行うこともできます。
今回、上記で実装した ViewModel
クラスでは内部でそのまま WebAPI との通信が発生してしまったりします。
ViewModel がデータが取得してくるところは WebAPI なのか CoreData
や Realm
等のデータベースなのかそれとも Photos.framework
等なのかわかりませんが別に知らないでもいいのではないでしょうか?
Clean Architecture で出てくる Repository という概念を使ってみてもいいかもしれません。
すぐに導入が難しいのであれば OHHTTPStubs を使って実際の HTTP 通信を発生させないようにすることもできます。
この記事で作成している実装コードでも DI という手法で ViewController
に部品を差し込んでいます。 ViewModel にも同じように責務の分解が可能です。
試しに PostRepository というプロトコルを作成して具象クラスを作ってみます。
import Foundation import RxSwift protocol PostRepository: AnyObject { func createPost(text: String) -> Single<Void> } final class PostRepositoryImpl: PostRepository { func createPost(text: String) -> Single<Void> { return .just(()) } }
ViewModel に DI してみます。
init(postRepository: PostRepository = PostRepositoryImpl()) { self.postAction = .init { postRepository.createPost(text: $0) } … }
こうすることで欲しいデータをテストコードに埋め込むことができるようになりました。
失敗させるのも自由自在です!
https://bitbucket.org/nnsnodnb/fuller-advent-calendar-20/src/0.0.6
まとめ
Nimble/Quick を使って、実際のよくあるパターンを例にユニットテストを実装してみました。
自分自身、 Nimble/Quick を使ったテストコードの実装は初めてでしたがとても書きやすくてとても良かったと感じています。
デメリットももちろんありました。設定が悪いのかもしれませんが、大元の XCTest.framework
を使ったテストコードの場合は、メソッドごとの実行が容易ですが Quick
では spec
に全てを書いていくので必然的にすべてが実行対象になってしまいます。
もしこの記事を見てテストを書きたい!って思っていただけれる人が1人でも現れたら嬉しいです。そんな人は是非、末尾に紹介している本を読んでいただければ嬉しいです。(僕は一切関わっていません)
テストコードは神ではありません!!
おまけ
iOS バージョンによる条件分岐が発生するパターンがあるときに CI でいろんな端末でテストしたい。
fastlane を使用していますが Bitrise でもステップを導入することで導入が可能です。
Fastfile にテストレーンを追加しています。
platform :ios do desc "Run tests" lane :test do scan( build_for_testing: true, skip_slack: true ) scan( clean: false, test_without_building: true, device: "iPhone 8 Plus (13.5)" ) scan( clean: false, test_without_building: true, device: "iPhone 8 Plus (14.1)" ) end end
$ fastlane ios test
をするとテスト用のビルドが実行されて、AppTests.xctest
ファイルが生成され、このファイルを使って iOS 13.5 と iOS 14.1 でテストが実行されます。