『入る学科間違えた高専生』の日記

プログラミングのコードを書いたりする予定です。あとは日記等。あといつまで高専生やねん

ユニットテストを書いて夜以外にも寝られるようになりたい人生だった🤖

この記事は フラー Advent Calendar 2020 の14日目の記事です。
13日目は @furusax0621 さんで「新入社員のトレーニングを担当する上で気をつけたこと、気付かされたこと」でした。


はじめに

みなさんはユニットテスト書いてますか?
すでに書いていらっしゃる方はもう読まなくても大丈夫です。おやすみなさい。

ユニットテスト書いてない人、いるんですか?

Nimble/Quick を使ったテストコードの実装をよくあるパターンに則って、実装しつつ紹介していければと思います。

前提

なぜテストを書くのか

  • 自分自身、テストを書く意味は心の安寧です。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)]
            }
        }
    }
}

とりあえず、上記のようなテストを書いてみました。するとどうでしょう?テストを実行してみます。

f:id:nanashinodonbee:20201213192923p:plain

何も流れてきていないようです。それもそのはず。 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つずつです。 実行するとどうでしょう?もちろんテストは失敗します。

f:id:nanashinodonbee:20201213194140p:plain

期待値はどちらも

[.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 エンドポイントに送信しているときは、ボタンを無効にしてほしい

投稿があるから、そりゃそうですよね。

github.com

別に使わなくてもできるんですけど、みんな大好き 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 なのか CoreDataRealm 等のデータベースなのかそれとも 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 でテストが実行されます。

参考

github.com

peaks.cc