Upgrade to Pro — share decks privately, control downloads, hide ads and more …

iOSアプリのテストを書きたいのに書けないあなたへ / How You Should Start to Write Your First Unit Test for iOS

iOSアプリのテストを書きたいのに書けないあなたへ / How You Should Start to Write Your First Unit Test for iOS

2019年4月16日に株式会社DeNAで行われたiOS Test Night #10で発表した資料です。
iOSで単体テストを始めたいけれど、どこからどう書けばよいか分からないという方のために、実経験を元にテストが書けるようになるためのTipsを紹介しました。

『「改善Dayを作ろう!」って言ってたけど気づいたらなくなったよね…』を繰り返さないために
https://speakerdeck.com/imaizume/things-to-achieve-continuous-improvement-in-your-development

単体テストのハジメ (@yokoyas000)
https://speakerdeck.com/yokoyas000/dan-ti-tesutofalsehazime

iOS でテスト容易な設計を実現するためのデザインパターン (@orga_chem)
https://speakerdeck.com/orgachem/ios-detesutorong-yi-nashe-ji-wo-shi-xian-surutamefalsedezainpatan

節子、それViewControllerやない...、FatViewControllerや...。 (@ktanaka117)
https://www.slideshare.net/kenjitanaka58/viewcontrollerfatviewcontroller-79796852

iOSアプリ設計パターン入門
https://peaks.cc/books/iOS_architecture

Tomohiro Imaizumi

April 16, 2019
Tweet

More Decks by Tomohiro Imaizumi

Other Decks in Programming

Transcript

  1. iOSアプリのテストを
    書きたいのに書けないあなたへ
    iOS Test Night #10 @ 株式会社DeNA

    View full-size slide

  2. 今泉 智博 (@imaizume)
    株式会社Diverse / 株式会社Uzumaki
    マッチングアプリPoiboy iOS版開発担当
    近況:レーシックで視⼒が0.5 ➡ 2.0になりました
    ⾃⼰紹介

    View full-size slide

  3. テストを書き始めた当時の⾃分
    iOSアプリ(Swift)でテストを書けるようになる視点
    テストを書きやすくするTips x3
    今⽇の内容: 当時の⾃分に⾔いたいこと
    (※以降で「テスト」は「Unitテスト」を指します)
    「テストがうまく書けない」
    結果
    「テストを書きたい❗」
    気持ち

    View full-size slide

  4. TIPS1. テスト対象の選び⽅
    (どこからテストを書くか)

    View full-size slide

  5. テストを書き始める場所
    • 致命的なところ
    • ログイン/登録
    • 課⾦
    • 影響範囲が⼤きいところ
    • APIクライアント
    • Entityのベースクラス
    最初にどこからテストを書けば良いのか❓
    ⾃分が⾒た / 聞いたアドバイス

    View full-size slide

  6. テストを書き始める場所
    • 致命的なところ
    • ログイン/登録
    • 課⾦
    • 影響範囲が⼤きいところ
    • APIクライアント
    • Entityのベースクラス
    最初にどこからテストを書けば良いのか❓
    ⾔われたとおりに書いたが挫折した
    ⾃分が⾒た / 聞いたアドバイス
    重要なコードはテストし易くない (ことが多い)

    View full-size slide

  7. テストを書きやすそうな所から書く
    テストが書き易い場所 (私⾒)
    • なるべく⼩さなコード(=メソッドやstruct等)で
    • 与える⼊⼒と期待する出⼒の関係が明確な所
    • Stringの整形 (URL⽣成や時間のフォーマット)
    • 時間の計算 (Dateの計算)
    • オブジェクトの変換 (toString)
    (例)

    View full-size slide

  8. 具体例: 時間の整形処理
    (⬆ 実際にQAから来たバグ報告)

    View full-size slide

  9. 時間の整形処理 (BEFORE)
    期待する⼊出⼒関係が明確で汎⽤性も⾼いので書きやすい
    入力: 秒
    (Int)
    出力: 整形済時間
    (String)
    • 境界値で正しく動作する❓
    • フォーマットが今後変わっても期待通り動く❓
    • seconds < 0だとどうなる❓
    動作確認⽤のテストコードが存在しなかった
    static func remainingTimeFormat(_ seconds: Int) -> String{
    let minutes = String(format: "%02d", ((seconds % 3600) / 60))
    let seconds = String(format: "%02d", (seconds % 60))
    return "\(minutes):\(seconds)"
    }
    実装(BEFORE)
    remainingTimeFormat

    View full-size slide

  10. 時間の整形処理 (AFTER)
    static func remainingTimeFormat(_ seconds: Int) -> String {
    let absSecond: Int = abs(seconds)
    let hours = String(format: "%02d",(from: absSecond / 3600))
    let minutes = String(format: "%02d",(from: (absSecond % 3600) / 60))
    let seconds = String(format: "%02d",(from: absSecond % 60))
    return "\(negativeSymbol(of: seconds))\(hours):\(minutes):\(seconds)”
    }
    実装(AFTER)
    let testCases: [(actual: Int, expected: String)] = [
    ( -60, "-00:01:00"), ( -1, "-00:00:01"),
    ( 0, "00:00:00"), ( 1, "00:00:01"),
    ( 59, "00:00:59"), ( 60, "00:01:00"),
    (3599, "00:59:59"), (3600, "01:00:00"), …
    ]
    testCases.each {
    XCTAssertEqual(DateTimeUtil.remainingTimeFormat($0.0), $0.1)
    }
    テストコード
    期待通りに整形されることの確認が容易に✌
    ⼊出⼒が明確で⼩さな処理にテストを書いていく✅

    View full-size slide

  11. TIPS2. ⼤域変数を明⽰的な⼊⼒へ
    (リポジトリパターンを使ってみる)

    View full-size slide

  12. ⼤域変数はテストの敵
    よくある⼤域変数
    • UserDefaults
    • Keychain
    • グローバル変数
    • A/Bフラグ etc
    (⼤域変数が原因でテストしにくい例)
    enum WorkCategory: String {
    case 消防士
    case インスタグラマー

    static func toArray() -> [WorkCategory] {
    if Keychain.shared.isFemale {
    // 女性側に表示する職業
    return [.インスタグラマー, …]
    } else {
    // 男性側に表示する職業
    return [.消防士, …]
    }
    }
    }
    実装(BEFORE)
    アプリ内フラグの状態で
    結果が変わる

    View full-size slide

  13. ⼤域変数を明⽰的な⼊⼒にする⽅法
    単体テストのハジメ (@yokoyas000) より改変
    引数 返り値
    func(arg)
    大域変数 大域変数
    func(arg, input, output)
    -> value
    引数 返り値
    大域変数 大域変数
    input output
    • リポジトリパターンを使いオブジェクト経由で参照
    • リポジトリへのアクセスを抽象化して差し替え可能に

    View full-size slide

  14. 例: 性別(Keychain)をリポジトリ化&抽象化
    protocol GenderStoreContract {
    var isFemale: Bool { get }
    }
    class GenderStore: GenderStoreContract {
    var isFemale: Bool {
    return Keychain.shared.isFemale
    }
    }
    class GenderStoreStub: GenderStoreContract {
    let isFemale: Bool
    init(isFemale: Bool) {
    self.isFemale = isFemale
    }
    }
    実装(AFTER)
    必要なインターフェイスを
    protocolで定義
    通常はリポジトリから
    性別を取得する
    テストではStubから
    性別を取得する

    View full-size slide

  15. Stubを受け付ける実装とテストコード
    enum WorkCategory: String {
    case …
    static func toArray(
    gender: GenderStoreContract = GenderStore()
    ) -> [WorkCategory] {
    if gender.isFemale {
    return [.インスタグラマー, …]
    } else {
    return [.消防士, …]
    }
    }
    }
    実装(AFTER)
    let stub = GenderStoreStub(isFemale: true)
    let actual = WorkCategory.toArray(gender: stub)
    let expected: [WorkCategory] = [.インスタグラマー, …]
    XCTAssertEqual(actual, expected)
    テストコード

    View full-size slide

  16. Stubを受け付ける実装とテストコード
    enum WorkCategory: String {
    case …
    static func toArray(
    gender: GenderStoreContract = GenderStore()
    ) -> [WorkCategory] {
    if gender.isFemale {
    return [.インスタグラマー, …]
    } else {
    return [.消防士, …]
    }
    }
    }
    実装(AFTER)
    引数の型はprotocol
    StoreかStubかを知る必要なし
    let stub = GenderStoreStub(isFemale: true)
    let actual = WorkCategory.toArray(gender: stub)
    let expected: [WorkCategory] = [.インスタグラマー, …]
    XCTAssertEqual(actual, expected)
    テストコード ⼤域変数を偽装できた
    既存実装を置き換えていくにつれテストしやすくなる⤴

    View full-size slide

  17. TIPS3. (Fat) ViewControllerをテストしない
    (責務を別モジュールに移してテストする)

    View full-size slide

  18. ViewControllerのテストがし⾟いわけ
    (テスト対象はたくさんあるけれど)
    FatなViewControllerはロジックが複雑
    • ⼊⼒イベント受付
    • 表⽰ロジック
    • ビジネスロジック
    • API通信
    • 状態管理
    • etc
    (Fat ViewControllerの抱える処理)
    (再掲) テストしやすい場所
    • なるべく⼩さなコード(=メソッドやstruct等)で
    • 与える⼊⼒と期待する出⼒の関係が明確な所
    テストを容易にするにはどうすれば良いか

    View full-size slide

  19. 責務毎に⼩さなモジュールへ切り出す(≒設計)
    ビジネスロジック
    表⽰ロジック
    API通信
    ⼊⼒イベント受付
    状態管理
    (ViewControllerが扱う処理)
    (専⽤モジュールが扱う処理)
    ⾼度な設計の前に
    まずは⼩さく切り出す

    View full-size slide

  20. ロジック切り出しの例: ニックネーム決定
    class RegisterViewController: UIViewController {

    fileprivate func setName() {
    var name = ""
    let lastInitial = (self.user.lastName! as NSString).substring(to: 1)
    let firstInitial = (self.user.firstName! as NSString).substring(to: 1)
    if lastInitial.range(of: "[A-Za-z]", options: .regularExpression) != nil &&
    firstInitial.range(of: "[A-Za-z]", options: .regularExpression) != nil {
    if self.user.lastName != "" {
    name += (lastInitial as String) + "."
    }
    if self.user.firstName != "" {
    name += (firstInitial as String) + "."
    }
    } else {
    // ランダムな名前
    name = …
    }
    self.nameLabel.attributedText = name
    実装(BEFORE)
    ViewControllerにべた書きのロジックを…

    View full-size slide

  21. NicknameConverter
    ロジック切り出しの例: ニックネーム決定
    ニックネーム
    toNickname(苗字, 名前, (stub))
    -> ニックネーム
    func setName(of user: UserModelContract) {
    let name: String = NicknameConverter(
    lastName: user.lastName, firstName: user.firstName).nickname
    self.nameLabel.attributedText = name
    }
    実装(AFTER)
    名前
    苗字 デフォルト値
    を返すstub
    別モジュールに切り出せた✂

    View full-size slide

  22. テストコード
    func testConvertFullNameIntoNickname() {
    let stub = NicknameProviderStub(gender: GenderStoreStub(isFemale: false))
    let testCases: [TestCase] = [
    .init(actual: ("Tomohiro", "Imaizumi"), expected: “T.I."),
    .init(actual: ("智博", nil), expected: stub.maleName),
    .init(actual: ("TOMO", "1ma1zu3"), expected: stub.maleName),

    ]
    testCases.forEach { testCase in
    let actualName = testCase.actualName
    let converter: NicknameConverter = .init(
    lastName: actual.0,
    firstName: actual.1,
    provider: provider)
    )
    XCTAssertEqual(converter.nickname, testCase.expectedNickname)
    }
    }
    責務分割に慣れてきたら他のロジックも分割 = 設計する
    テストコード
    切り出したモジュールをテスト✂

    View full-size slide

  23. まとめ
    TIPS1. テスト対象の選び⽅
    ✅ ⼊出⼒が明らかで⼩さなコードから
    TIPS2. ⼤域変数を明⽰的な⼊⼒へ
    ✅ リポジトリパターンと抽象化をうまく使う
    TIPS3. ViewControllerをテストしない
    ✅ 責務ごとにモジュールへ分けてテストする
    ぜひ懇親会でテスト導⼊の⾟み/苦労話を話しましょう

    View full-size slide

  24. 参考リンク
    • 『「改善Dayを作ろう!」って⾔ってたけど気づいたらなくなったよね…』を繰り返
    さないために
    https://speakerdeck.com/imaizume/things-to-achieve-continuous-
    improvement-in-your-development
    • 単体テストのハジメ (@yokoyas000)
    https://speakerdeck.com/yokoyas000/dan-ti-tesutofalsehazime
    • iOS でテスト容易な設計を実現するためのデザインパターン (@orga_chem)
    https://speakerdeck.com/orgachem/ios-detesutorong-yi-nashe-ji-wo-shi-
    xian-surutamefalsedezainpatan
    • 節⼦、それViewControllerやない...、FatViewControllerや...。 (@ktanaka117)
    https://www.slideshare.net/kenjitanaka58/viewcontrollerfatviewcontroller-
    79796852
    • iOSアプリ設計パターン⼊⾨
    https://peaks.cc/books/iOS_architecture

    View full-size slide