Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

時間の整形処理 (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

Slide 10

Slide 10 text

時間の整形処理 (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) } テストコード 期待通りに整形されることの確認が容易に✌ ⼊出⼒が明確で⼩さな処理にテストを書いていく✅

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

⼤域変数はテストの敵 よくある⼤域変数 • UserDefaults • Keychain • グローバル変数 • A/Bフラグ etc (⼤域変数が原因でテストしにくい例) enum WorkCategory: String { case 消防士 case インスタグラマー … static func toArray() -> [WorkCategory] { if Keychain.shared.isFemale { // 女性側に表示する職業 return [.インスタグラマー, …] } else { // 男性側に表示する職業 return [.消防士, …] } } } 実装(BEFORE) アプリ内フラグの状態で 結果が変わる

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

例: 性別(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から 性別を取得する

Slide 15

Slide 15 text

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) テストコード

Slide 16

Slide 16 text

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) テストコード ⼤域変数を偽装できた 既存実装を置き換えていくにつれテストしやすくなる⤴

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

ロジック切り出しの例: ニックネーム決定 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にべた書きのロジックを…

Slide 21

Slide 21 text

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 別モジュールに切り出せた✂

Slide 22

Slide 22 text

テストコード 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) } } 責務分割に慣れてきたら他のロジックも分割 = 設計する テストコード 切り出したモジュールをテスト✂

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

参考リンク • 『「改善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