21
7

More than 3 years have passed since last update.

テスト可能なコードを書くために改善したこと

Last updated at Posted at 2020-06-08

はじめに

最近、業務でパイセンが書いたコードをレビューした時に、こんな書き方できるんだ!と勉強させていただいたので、実際にどのような挙動になるのかテストコードを書いてみたことがありました:hammer_pick::gear:

CountablePartialRangeFromっていうのが使われていたのですが、初見でした:eyes:

理解を深めるためにもテストコードを書いてみよう!と思いましたが、
テストコードを書きづらいところがあったので、その要因とテストを書くために改善したことを整理したいと思います。

本記事で扱わないこと

本記事では、Swiftで書かれたiOSプロジェクトについて扱いますので、
他の言語については扱いませんm(_ _)m

また、テストコードについてはXCTestを使用することを想定した内容となりますが
XCUnitTestの書き方(Assertの種類や使い方など)については本記事では扱いません。
(ソースコードのリンクは付けていますので、そちらを見ていただければテストコード自体は確認できます。)

テスト対象の処理(仕様)

業務で開発しているプロダクトコードをそのまま載せるわけにはいかないので、サンプルを実装しました。
サンプルの処理は、以下のとおりです。

1. 初回起動から1日以上経過していたら、アプリ継続利用報酬を付与する処理を実行する。
2. 初回報酬付与から3日以上経過していたら、二回目の報酬を付与する処理を実行する。
3. 二回目の報酬付与から5日以上経過していたら、三回目の報酬を付与する処理を実行する。
4. 以降は、前回報酬付与から30日以上経過していたら、報酬を付与する処理を実行する。

初回起動を起動日の09:00とか固定の時間にした方がアプリの日付の更新が分かりやすくなりそうだーとか思いつつ、
サンプルなのでその辺りは考慮していません:relaxed:

実装

テストコードを書く前に実装したコード

(本記事では、ソースコード全量は載せていないので、詳細なプロダクトコード&テストコードは、GitHubをご確認ください。)

基準となる日時と比較対象の日時で何日間経過したか計算する処理をDateのExtensionで実装しています。

Date+.swift
import Foundation

extension Date {

    /// 起算日(from)から日付(self)までの経過日数(n日間)を取得
    /// - Parameter from: 起算日
    /// - Returns: 経過日数
    func getIntervalDays(from: Date) -> Int {
        let interval = self.timeIntervalSince(from)
        let intervalDays = interval / (60 * 60 * 24)
        return intervalDays >= 0 ? Int(floor(intervalDays)) : Int(ceil(intervalDays))
    }
}

実際の計算処理は以下のように実装していました。

/// 報酬を付与するかどうか判定する
func shouldGiveRewards() -> Bool {
    // 初回起動日時(nilではない想定)
    let firstLaunchingDate = // <UserDefaultsから取得した初回起動日時>

    // 前回報酬付与日時
    let lastRewardDate = // <UserDefaultsから取得した前回報酬付与日時(未付与の場合はnil)>

    // 前回報酬付与日時がnilの場合は、初回起動日時を起算日とする
    let referenceDate = lastRewardDate ?? firstLaunchingDate

    // referenceDateから現在日時までの経過日数を取得する
    let elapsedDays = Date().getIntervalDays(from: referenceDate)

    ...
}

この処理のあたりをテストコード書いて、検証しようとしたところ、以下の問題がありました。

処理を実行する時刻によってDate()の値が変わってしまうので、テストコードを書けない:scream:

例えば、今は2020年6月8日の09:30だから、、テストコードの期待値はこうなるはず・・・
といった感じでテストを流す度に毎回テストコードをいじる必要が出てきそうで、これではテストできているとは言えません:sob:

変更したコード

テストを書くためにメソッド内で暗黙的に入ってくる情報(今回のケースで言うと、現在時刻の情報)を
明示的にメソッドの引数で受け取るようにしてあげます。

func shouldGiveRewards(currentDate: Date) -> Bool {
    ...

    // referenceDateから現在日時までの経過日数を取得する
    let elapsedDays = currentDate.getIntervalDays(from: referenceDate)

    ...
}

ちょっとした変更ですが、これで現在時刻を引数で受け取るようにできたので、
テストコードでは前提条件に合わせてcurrentDateに値をセットすることでテストできるようになりました:raised_hands:
(一連の処理の中で現在時刻を取得するタイミングが修正前後で変わってしまっていますが、今回の処理においては特に影響はありません。)

また、テストコードとは直接関係ありませんが、
経過日数の計算はCalendarのdateComponentsでもう少し簡単に実装できることが後から分かったので、
DateのExtensionではなくCalendarのExtensionで以下のように書き直しました。

Calendar+.swift
import Foundation

extension Calendar {

    /// 経過日数を計算する
    /// - Parameters:
    ///   - starting: 起算日
    ///   - ending: 日付
    /// - Returns: 日数の差分
    /// - Note: 24時間経過していない場合は1日と見なさない
    /// - Warning: endingの方がstartingより過去の場合は、マイナスになる
    func elapsedDays(from starting: Date, to ending: Date) -> Int {
        let days = self.dateComponents([.day], from: starting, to: ending)
        guard let day = days.day else {
            assertionFailure("dateComponents.day is nil. (Calendar instance: \(self))")
            return 0
        }
        return day
    }
}

今回は、Dateに着目しましたが、ほかにもテストコードを書きづらくする要因としては
 ・ネットワークの状態(通信環境)
 ・非同期処理の結果が返ってくる順序性
 ・DBやファイルなどの状態(テスト実行前にどんな値が何件保存されているか等)
などいろいろなものが考えられますね。
(これらについても、もう少し整理できた段階でまとめて記事にしていきたいと思います:pencil2:

テストコードを書くためによく使うTips

iOSのプロジェクトでテストコードを書く時に自分がよく使うExtensionなどを書いていきます。
こういうのも便利だよっていうのがあれば、教えていただきたいですm(_ _)m

テスト中かどうか

XCUnitTest実行中かどうか判定する時に使用します。
テスト実行中だったら、ユニットテスト用のDBにアクセスするようにしたりとか。

Environment.swift
import Foundation

final class Environment {

    static let shared = Environment()
    private init() {}
}

extension Environment {

    /// UnitTest実行中かどうか
    func isTesting() -> Bool {
        return ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
    }
}

// Usage
// if Environment.shared.isTesting() {
//     テスト実行中の処理
// } else {
//     プロダクトコード実行中の処理
// }

Dateまわり

Dateのインスタンスは、Stringから作るのが簡単で良いかなーと思います!

String+.swift

import Foundation

extension String {

    func toDate(with format: String) -> Date? {
        let formatter: DateFormatter = DateFormatter()
        formatter.calendar = Calendar.gregorianJST()
        formatter.dateFormat = format
        return formatter.date(from: self)
    }
}

// Usage
// let theDate = "2020/06/08 09:00:00 +09:00".toDate(with: "yyyy/MM/dd HH:mm:ss Z")!

ある日付からn日後のDateが欲しいっていう時は、Calendarに以下のようなExtensionを作ったりもします。

Calendar+.swift

import Foundation

extension Calendar {

    /// 起算日からn日後のDateを取得する
    /// - Parameters:
    ///   - date: 起算日
    ///   - days: n日
    /// - Returns: 起算日からn日後のDate
    func move(_ date: Date, byDays days: Int) -> Date {
        return self.date(byAdding: .day, value: days, to: date)!
    }
}

// Usage
// let theDate = Date()
// let twoDaysLater = Calendar.current.move(theDate, byDays: 2)

ストレージまわり

テスト実行中かどうかの判定をして、アクセスするDBファイルを変えたりします。

UserDefaults

static let ud: UserDefaults = {
    // UnitTestの場合は、UserDefaultsの保存先をLibrary/Preferences/UT.plistにする
    if Environment.shared.isTesting() {
        let suiteName = "UT"
        // UnitTest用のUserDefaultsのデータを削除する
        UserDefaults().removePersistentDomain(forName: suiteName)
        return UserDefaults(suiteName: suiteName)!
    }
    else {
        return UserDefaults.standard
    }
}()

RealmSwift

プロトコルを定義して、プロダクトコードとテストコードそれぞれでRealmのファイルを分けたりします。
※ 最近、Realmをさわる機会が少なかったので、最新のバージョンでもうまく使えるか微妙ですが、、

RealmInitializerCompatible.swift
import Foundation
import RealmSwift

protocol RealmInitializerCompatible {
    var configuration: Realm.Configuration? { get }

    static func defaultConfiguration() -> Realm.Configuration
    static func encryptionKey() -> Data?
    func initializeRealm() -> Realm
}
RealmInitializer.swift
import Foundation
import RealmSwift

final class RealmInitializer: RealmInitializerCompatible {

    let configuration: Realm.Configuration?

    init(configuration: Realm.Configuration? = defaultConfiguration()) {
        self.configuration = configuration
    }

    static func defaultConfiguration() -> Realm.Configuration {
        let configuration = Realm.Configuration(encryptionKey: encryptionKey(),
                                                schemaVersion: RealmMigrator.version,
                                                migrationBlock: RealmMigrator.migrationBlock())
        return configuration
    }

    /// 暗号化キーを取得する
    static func encryptionKey() -> Data? {
        // テキトーな暗号化キー
        let keyString = "ssuMMd3a97IIGbGxF4kLP6y0Vf723qklg8IaIZHEQgUNnb9lE1W1wx4nlLCgQa0p"
        let keyData = keyString.data(using: .utf8)

        #if DEBUG
        print("Realm encryptionKey -> " + keyData!.map { String(format: "%.2hhx", $0) }.joined())
        #endif

        return keyData
    }

    func initializeRealm() -> Realm {
        do {
            var realm: Realm
            if let configuration = configuration {
                realm = try Realm(configuration: configuration)
            } else {
                realm = try Realm()
            }
            return realm

        } catch {
            fatalError("Realm initialize error: \(error)")
        }
    }
}

さいごに

まとまりの無い内容になってしまいましたが、今回はテストコードを書くために考慮するべきことを考えてみました。
静的なインプットでテストしたつもりでも、テストを実行する環境や状態によって結果が変わってしまうということがあるので
それらも意識してテスト可能なコードを書けるようにしていきたいなーと思いました。

記事内ではプロダクトコード・テストコードともに全量載せていませんでしたが、
GitHubにpushしていますので、そちらを参照していただければと思います:bow_tone1:

21
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
7