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

1a74617b91d2757b839b9cf3614648ce?s=128

Tomohiro Imaizumi

April 16, 2019
Tweet

Transcript

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

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

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

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

  5. テストを書き始める場所 • 致命的なところ • ログイン/登録 • 課⾦ • 影響範囲が⼤きいところ •

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

    APIクライアント • Entityのベースクラス 最初にどこからテストを書けば良いのか❓ ⾔われたとおりに書いたが挫折した ⾃分が⾒た / 聞いたアドバイス 重要なコードはテストし易くない (ことが多い)
  7. テストを書きやすそうな所から書く テストが書き易い場所 (私⾒) • なるべく⼩さなコード(=メソッドやstruct等)で • 与える⼊⼒と期待する出⼒の関係が明確な所 • Stringの整形 (URL⽣成や時間のフォーマット)

    • 時間の計算 (Dateの計算) • オブジェクトの変換 (toString) (例)
  8. 具体例: 時間の整形処理 (⬆ 実際にQAから来たバグ報告)

  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
  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) } テストコード 期待通りに整形されることの確認が容易に✌ ⼊出⼒が明確で⼩さな処理にテストを書いていく✅
  11. TIPS2. ⼤域変数を明⽰的な⼊⼒へ (リポジトリパターンを使ってみる)

  12. ⼤域変数はテストの敵 よくある⼤域変数 • UserDefaults • Keychain • グローバル変数 • A/Bフラグ

    etc (⼤域変数が原因でテストしにくい例) enum WorkCategory: String { case 消防士 case インスタグラマー … static func toArray() -> [WorkCategory] { if Keychain.shared.isFemale { // 女性側に表示する職業 return [.インスタグラマー, …] } else { // 男性側に表示する職業 return [.消防士, …] } } } 実装(BEFORE) アプリ内フラグの状態で 結果が変わる
  13. ⼤域変数を明⽰的な⼊⼒にする⽅法 単体テストのハジメ (@yokoyas000) より改変 引数 返り値 func(arg) 大域変数 大域変数 func(arg,

    input, output) -> value 引数 返り値 大域変数 大域変数 input output • リポジトリパターンを使いオブジェクト経由で参照 • リポジトリへのアクセスを抽象化して差し替え可能に
  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から 性別を取得する
  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) テストコード
  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) テストコード ⼤域変数を偽装できた 既存実装を置き換えていくにつれテストしやすくなる⤴
  17. TIPS3. (Fat) ViewControllerをテストしない (責務を別モジュールに移してテストする)

  18. ViewControllerのテストがし⾟いわけ (テスト対象はたくさんあるけれど) FatなViewControllerはロジックが複雑 • ⼊⼒イベント受付 • 表⽰ロジック • ビジネスロジック •

    API通信 • 状態管理 • etc (Fat ViewControllerの抱える処理) (再掲) テストしやすい場所 • なるべく⼩さなコード(=メソッドやstruct等)で • 与える⼊⼒と期待する出⼒の関係が明確な所 テストを容易にするにはどうすれば良いか
  19. 責務毎に⼩さなモジュールへ切り出す(≒設計) ビジネスロジック 表⽰ロジック API通信 ⼊⼒イベント受付 状態管理 (ViewControllerが扱う処理) (専⽤モジュールが扱う処理) ⾼度な設計の前に まずは⼩さく切り出す

  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にべた書きのロジックを…
  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 別モジュールに切り出せた✂
  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) } } 責務分割に慣れてきたら他のロジックも分割 = 設計する テストコード 切り出したモジュールをテスト✂
  23. まとめ TIPS1. テスト対象の選び⽅ ✅ ⼊出⼒が明らかで⼩さなコードから TIPS2. ⼤域変数を明⽰的な⼊⼒へ ✅ リポジトリパターンと抽象化をうまく使う TIPS3.

    ViewControllerをテストしない ✅ 責務ごとにモジュールへ分けてテストする ぜひ懇親会でテスト導⼊の⾟み/苦労話を話しましょう
  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