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

スナップショットテスト実戦投入 / Practical Snapshot Testing

スナップショットテスト実戦投入 / Practical Snapshot Testing

2019年9月7日に行われたiOSDC Japan 2019 Day2での発表資料になります。

## 補足

本資料内での「スナップショットテスト」という単語は、より一般的には「画像ベーステスト」や、「ビジュアルリグレッションテスト」と呼ばれています。

## 参考リンク・書籍

株式会社Diverse
https://diverse-inc.co.jp/recruit/environment
働きやすい環境で真剣に出会いのプラットフォーム作ってます!

Poiboy
https://poiboy.jp
@imaizume も開発しているマッチングアプリ ぜひDLしてね!!

株式会社Uzumaki
https://uzumaki-inc.jp
Webクリエイターのギルド集団 アプリやデザインのお仕事お待ちしています!!

iOSSnapshotTestCase
https://github.com/uber/ios-snapshot-test-case/
Facebook製で現在はUberがメンテしているスナップショットテストフレームワーク

SnapshotTesting
https://github.com/pointfreeco/swift-snapshot-testing/
Point-Freeの出しているSwift製スナップショットテストフレームワーク

https://fortee.jp/iosdc-japan-2019/proposal/6c77df58-00f6-4623-8fe4-6bfac879fb00

What is iOSSnapshotTestCase (@tamaki)
https://speakerdeck.com/tamaki/what-is-iossnapshottestcase
基礎的な事項が分かりやすくまとめられているのでぜひ併せて読んでみてください!

Snapshot Testing in iOS (@suieyy)
https://speakerdeck.com/susieyy/snapshot-testing-in-ios
概要から運用ノウハウについて分かりやすく書かれています、こちらも必読です!

iOS Snapshot Testing (Aaina Jain)
https://www.linkedin.com/pulse/ios-snapshot-testing-aaina-jain-
スナップショットでの基礎的な説明内容はこちらを参考にさせていただきました

Instantiate
https://github.com/tarunon/Instantiate
ViewControllerやViewに簡単にDIできるようにするためのライブラリ

iOSアプリのテストを書きたいのに書けないあなたへ (@imaizume)
https://speakerdeck.com/imaizume/how-you-should-start-to-write-your-first-unit-test-for-ios
テストのための設計変更やリポジトリパターンについて解説した過去の発表

folio-sec/Fastfile
https://github.com/folio-sec/Fastfile
スナップショットに役立つ大変便利なlaneとスクリプトが揃ったライブラリです folioさんに感謝して使いましょう!!

@imaizume のMarkdownファイル生成スクリプト
https://gist.github.com/imaizume/6aa2537c1c6778b50873c813eb7a15f1
folioさんのスクリプトを参考に一部を改変させていただきました

はじめてのfastlane Snapshot編 - Qiita (@tamaki)
https://qiita.com/tamaki/items/f5e9f9985a91fb6a0f06
fastlane Snapshotについて詳しく知りたい方はこちらを参考に

CharlesでiOS Simulatorの通信をキャプチャする方法 - Qiita
https://qiita.com/ruwatana/items/93cafe2369faec4b2598
今回解説できなかったChalesのインストールやキャプチャー方法はこちらをご参考に

Wiremockで行うUIテスト - Speaker Deck (@tamaki)
https://speakerdeck.com/tamaki/wiremockdexing-uuitesuto
Wiremockを使ったテストの仕方についてまとまっています

[iOS、Swift] ユニットテストの時に、任意のタイミングでViewDidLoad()、ViewWill(Did)Appear()、ViewWill(Did)Disappear()を呼び出す方法 - Qiita
https://qiita.com/mii-chan/items/a9d8fd420d04b92a1c34
ライフサイクルメソッドを外部から操作する時に参考にさせていただきました

Failure image generated despite no change of code and their diff incorrectly rendered · Issue #99 - uber/ios-snapshot-test-case
https://github.com/uber/ios-snapshot-test-case/issues/99
意図しないdiffが出てしまう問題を報告しています 返信がきたらどこかで共有します

Snapshot testing in XCTest - XCNotes - Medium
https://medium.com/xcnotes/snapshot-testing-in-xcuitest-d18ca9bdeae
iOSSnapshotTestCaseのpixel toleranceについて解説されています

switching to dark mode in UI test - Apple Developer Forums
https://medium.com/xcnotes/snapshot-testing-in-xcuitest-d18ca9bdeae
UIテストでDark Modeをエミュレーションすることはできるのか...

Tomohiro Imaizumi

September 07, 2019
Tweet

More Decks by Tomohiro Imaizumi

Other Decks in Programming

Transcript

  1. A3 おことわり • 今回は時間の都合上、UIのスナップショット に絞ってお話します • 表⽰内容は全てテストデータです • 本資料はSpeakerDeckにて公開済みです ⼀部⾒えにくい箇所等はお⼿元でご覧下さい

    • 「スナップショットテスト」は、より⼀般的 には「画像ベーステスト」や、「ビジュアル リグレッションテスト」と呼ばれています。
  2. A4 本⽇のindex • @imaizumeとPoiboyの紹介 • スナップショットテスト導⼊の背景 • 初期設定 • 撮影時の表⽰条件・状態整理

    • 状態再現のための設計修正 • 撮影した情報の整理 • 様々なViewの撮影 • TIPS&ハマりポイント • まとめ
  3. A8 本⽇のindex • @imaizumeとDiverseの紹介 • スナップショットテスト導⼊の背景 • 初期設定 • 撮影時の表⽰条件・状態整理

    • 状態再現のための設計修正 • 撮影した情報の整理 • 様々なViewの撮影 • TIPS&ハマりポイント • まとめ
  4. A10 表⽰確認での悩み1: 条件網羅が難しい • 特定条件下のみで起こる不具合 • レスポンス / 内部状態 /

    機種 など条件が複雑 • 全条件で⼿動確認するのはコストが⼤きい
  5. A13 ؒٝآص، ذأة٦ • 㹋鄲 • رغحؚ • ذأز •

    ٖؽُ٦ ،٦ؕ؎ـ ⹛⡲然钠 غؚ㜠デ • ⥜姻 ،٦ؕ؎ـ ⹛⡲然钠 غؚ㜠デ • 鷄⸇⥜姻 ،٦ؕ؎ـ ⹛⡲然钠 ⼿動での表⽰確認時 数⼗分待ち 全パターン ⾒きれない 別画⾯が デグレ エッジケース でバグ発⾒ 表⽰確認がツラい
  6. ؒٝآص، ذأة٦ • 㹋鄲 • رغحؚ • ذأز • ٖؽُ٦

    ،٦ؕ؎ـ ⹛⡲然钠 غؚ㜠デ • ⥜姻 ،٦ؕ؎ـ ⹛⡲然钠 غؚ㜠デ • 鷄⸇⥜姻 ،٦ؕ؎ـ ⹛⡲然钠 現状: 表⽰確認がつらい 数⼗分待ち 全パターン ⾒きれない 別画⾯が デグレ エッジケース でバグ発⾒ この状況を打破するため 新たな武器が必要 A14
  7. ؒٝآص، ذأة٦ • 㹋鄲 • رغحؚ • ذأز • ٖؽُ٦

    ،٦ؕ؎ـ ⹛⡲然钠 غؚ㜠デ • ⥜姻 ،٦ؕ؎ـ ⹛⡲然钠 غؚ㜠デ • 鷄⸇⥜姻 ،٦ؕ؎ـ ⹛⡲然钠 現状 数⼗分待ち 全パターン ⾒きれない 別画⾯が デグレ エッジケース でバグ発⾒ 今こそ スナップショットテスト 実戦投⼊の時 A15
  8. A20 ؒٝآص، ذأة٦ • 㹋鄲 • رغحؚ • ذأز •

    ٖؽُ٦ ،٦ؕ؎ـ ⹛⡲然钠 غؚ㜠デ • ⥜姻 ،٦ؕ؎ـ ⹛⡲然钠 غؚ㜠デ • 鷄⸇⥜姻 ،٦ؕ؎ـ ⹛⡲然钠 全表⽰パターンとデグレ確認✅ 修正確認の ループを減らせる 表⽰確認がツラくない スナップショットテスト導⼊後 スナップショット テスト実⾏
  9. A23 スナップショットテストのためのライブラリ • Facebookが作成したFBSnapshotTestCaseをuberがメンテ • UIViewまたはCALayerの画像を撮影 iOSSnapshotTestCase (FBSnapshotTestCase) SnapshotTesting •

    View階層やURLRequest等の多様なスナップショットが可能 • 完全Swift製ライブラリ 以降はiOSSnapshotTestCaseを例に説明します github.com/uber/ios-snapshot-test-case github.com/pointfreeco/swift-snapshot-testing
  10. A24 本⽇のindex • @imaizumeとDiverseの紹介 • スナップショットテスト導⼊の背景 • 初期設定 • 撮影時の表⽰条件・状態整理

    • 状態再現のための設計修正 • 撮影した情報の整理 • 様々なViewの撮影 • TIPS&ハマりポイント • まとめ
  11. A25 ライブラリの導⼊ • CocoaPods / Carthageでインストール • 環境変数を設定 湡涸 㢌侧せ

    鏣㹀⦼ 嫰鯰欽歗⫷ ך⳿⸂⯓ '#@3&'&3&/$&@*."(&@%*3  4063$&@3005  130+&$5@/".& 5FTUT3FGFSFODF*NBHFT 䊴ⴓ歗⫷ ך⳿⸂⯓ *."(&@%*''@%*3  4063$&@3005  130+&$5@/".& 5FTUT'BJMVSF%JGGT target 'YourAppTests' do inherit! :search_paths pod "iOSSnapshotTestCase" end Podfile github "uber/ios-snapshot-test-case" Cartfile (Swift Package Managerは現在対応中) • 本発表での実⾏環境 Swift 4.2 XCode 10.3 (10G8)
  12. A27 前半: スクリーンショットの撮影 false recordMode 嫰鯰㼎韋 ך歗⫷ true 嫰鯰㼎韋 ך歗⫷

    䊴ⴓ ז׃ ֮׶ ז׃ ֮׶ ♳剅ֹ乆䕦 倜鋉乆䕦 ז׃ ֮׶ recordModeⴖ׶剏ִ
  13. A28 テストファイル作成とrecordModeの設定 • FBSnapshotTestCaseを継承したテストクラスを作成 • 撮影時だけ recordMode = true に

    import FBSnapshotTestCase @testable import YourApp class MySnapshotTestCase: FBSnapshotTestCase { override func setUp() { super.setUp() self.recordMode = true } ... } SampleSnapshotTests.swift デフォルトでfalseなので撮影後はコメントアウト
  14. A29 スナップショットの撮影 • 撮影したいViewを⽣成 • FBSnapshotVerifyView メソッドを呼び出す • identifier に画像名を指定可能

    class MySnapshotTestCase: FBSnapshotTestCase { ... func testSimpleView() { let view = UIView( frame: CGRect(x: 0, y: 0, width: 64, height: 64)) view.backgroundColor = .blue FBSnapshotVerifyView(view, identifier: "simple_view_snapshot") } } SampleSnapshotTests.swift
  15. A30 実⾏結果 "failed - Test ran in record mode. Reference

    image is now saved." と出れば成功 • (YourAppTests)/ReferenceImages_64以下に 撮影された画像(参考画像)が⽣成
  16. A31 後半: 差分の検出 false recordMode 嫰鯰㼎韋 ך歗⫷ true 嫰鯰㼎韋 ך歗⫷

    䊴ⴓ ז׃ ֮׶ ז׃ ֮׶ ♳剅ֹ乆䕦 倜鋉乆䕦 ז׃ ֮׶ recordModeⴖ׶剏ִ
  17. A32 コードを変えずにテストを実⾏ class MySnapshotTestCase: FBSnapshotTestCase { override func setUp() {

    super.setUp() // self.recordMode = true } func testSimpleView() { let view = UIView( frame: CGRect(x: 0, y: 0, width: 64, height: 64)) view.backgroundColor = .blue FBSnapshotVerifyView(view, identifier: "simple_view_snapshot") } } SampleSnapshotTests.swift recordMode = falseで再実⾏ → テストが通る
  18. A33 UILabelを加えてテストを実⾏ func testSimpleView() { let view = UIView( frame:

    CGRect(x: 0, y: 0, width: 64, height: 64)) view.backgroundColor = .blue let label = UILabel( frame: CGRect(x: 0, y: 16, width: 64, height: 32)) label.text = "Snapshot!!" label.textColor = .white view.addSubview(label) FBSnapshotVerifyView(view, identifier: "simple_view_snapshot") } SampleSnapshotTests.swift テストが失敗しFailureDiffsに差分画像が出現 差分 実⾏時 ⽐較元
  19. A34 背景⾊を変えてテストを実⾏ func testSimpleView() { let view = UIView( frame:

    CGRect(x: 0, y: 0, width: 64, height: 64)) view.backgroundColor = .red FBSnapshotVerifyView(view) } SampleSnapshotTests.swift テストが失敗しFailureDiffsに差分画像が出現 (この場合は判りにくいですが、⾊の差でもdiffが出⼒されます。) 差分 実⾏時 ⽐較元
  20. A35 再掲: 実⾏の流れ(全体) 基本は「撮影→差分⽐較」の繰り返し false recordMode 嫰鯰㼎韋 ך歗⫷ true 嫰鯰㼎韋

    ך歗⫷ 䊴ⴓ ז׃ ֮׶ ז׃ ֮׶ ♳剅ֹ乆䕦 倜鋉乆䕦 ז׃ ֮׶ recordModeⴖ׶剏ִ
  21. A36 本⽇のindex • @imaizumeとDiverseの紹介 • スナップショットテスト導⼊の背景 • 初期設定 • 撮影時の表⽰条件・状態整理

    • 状態再現のための設計修正 • 撮影した情報の整理 • 様々なViewの撮影 • TIPS&ハマりポイント • まとめ
  22. ؟ٝفٕ 植㹋ך،فٔ 邌爙勴⟝ א 醱侧 7JFXך欰䧭 ꫼涸 ⹛涸ז皘䨽֮׶ Ⰵ⸂ ♶銲

    "1*ⰻ鿇朐䡾ח⣛㶷 A37 サンプルと現実のアプリ 必要なこと 1. 撮影時の表⽰条件・状態整理 2. 状態再現のための設計修正 撮影までに超えねばならない壁が存在
  23. A40 例: Poiboyのプロフィール画⾯ • 相⼿または⾃分のプロフィールを表⽰ • 表⽰項⽬が複数で表⽰条件が複雑 プロフィール画⾯の例 年齢 /

    住所/ 職業 名前の⽂字数 プロフ写真数 プロフの⻑さ 共通点の数・内容 ボタンの表⽰・テキスト ページャー (画⾯サイズ) (OSバージョン) タイトルテキスト (性別で変化) オンライン状態
  24. A44 本⽇のindex • @imaizumeとDiverseの紹介 • スナップショットテスト導⼊の背景 • 初期設定 • 撮影時の表⽰条件・状態整理

    • 状態再現のための設計修正 • 撮影した情報の整理 • 様々なViewの撮影 • TIPS&ハマりポイント • まとめ
  25. ؟ٝفٕ 植㹋ך،فٔ 邌爙勴⟝ א 醱侧 7JFXך欰䧭 ꫼涸 ⹛涸ז皘䨽֮׶ Ⰵ⸂ ♶銲

    "1*ⰻ鿇朐䡾ח⣛㶷 A45 再掲: サンプルと現実のアプリ 撮影までに超えねばならない壁 必要なこと 1. 撮影時の表⽰条件・状態整理 2. 状態再現のための設計修正
  26. A48 パラメーター化テストにする func testBoostComplete() { self.folderName = "ブースト完了ポップアップ" let testCases:

    [(dependency: Dependency, identifier: String)] = [ (.init(rateValue: 0.1, poied: false), "ブーストスコア01"), (.init(rateValue: 1.2, poied: false), "ブーストスコア12"), (.init(rateValue: 12.3, poied: false), "ブーストスコア123"), (.init(rateValue: 123.4, poied: false), "ブーストスコア1234"), (.init(rateValue: 1.2, poied: true), "ブーストポイされた"), ] testCases.forEach { testCase in let vc: BoostCompleteViewController = .init(with: testCase.dependency) FBSnapshotVerifyView(vc.view, identifier: testCase.identifier) } } SampleSnapshotTests.swift 保存先のフォルダ名を指定可能 依存先が1つでかつパラメータ数も少ないため簡単 (ViewControllerへの依存注⼊に github.com/tarunon/Instantiate を利⽤) 異なるidentifierならループで回せる
  27. A51 例: プロフィール画⾯ • APIレスポンス • 名前 • ログイン状態 •

    共通点数 • etc ... • グローバル変数 • 性別 • A/Bテストの振り分け • 初期化パラメータ • 親画⾯の種別 • 画⾯サイズ 画⾯への⼊⼒
  28. A55 Serviceでもモックする場合 7JFX 1SFTFOUFS .PEFM 4FSWJDF ذأز؝٦س "1* 7JFXⰻדJOJU 1SFTFOUFSⰻדJOJU

    .PEFMⰻדJOJU 7JFX 1SFTFOUFS .PEFM 4FSWJDF ذأز؝٦س 4FSWJDF4UVC ٗ٦ٕؕך +40/ؿ؋؎ٕ 1SFTFOUFS4UVC
  29. 7JFX 1SFTFOUFS .PEFM 4FSWJDF ؚٗ٦غٕ 㢌侧 ذأز؝٦س "1* ؒٝس ه؎ٝز

    䚍ⴽ "#㾩䚍 7JFX 1SFTFOUFS .PEFM 4FSWJDF ؚٗ٦غٕ 㢌侧 ذأز؝٦س "1* 4UPSF QSPUPDPM 4UVC A56 明⽰的⼊⼒と抽象へ依存する構造に 明⽰的な⼊⼒ 抽象への依存 暗黙的⼊⼒ 具体への依存 「iOSアプリのテストを書きたいのに書けないあなたへ」より
  30. A57 例: 性別をリポジトリパターンに protocol GenderStoreContract { var isFemale: Bool {

    get } var isMale: Bool { get } } class GenderStore: GenderStoreContract { var isFemale: Bool { return KeychainManager.shared.isFemale() } var isMale: Bool { return KeychainManager.shared.isMale() } } class GenderStoreStub: GenderStoreContract { let isFemale: Bool let isMale: Bool init(isFemale: Bool) { self.isFemale = isFemale self.isMale = !isFemale } } GenderStore.swift 「iOSアプリのテストを書きたいのに書けないあなたへ」より 任意の性別に差し替える アプリ内フラグを参照する
  31. A58 例: Presenterに性別を依存⼊⼒ class ProfilePresenter: NSObject { init(_ gender: GenderStoreContract,

    _ dataSource: ProfileDataSource) { self.gender = gender self.dataSource = dataSource // データソース super.init() self.dataSource.output = self } } let stub: GenderStoreStub = .init(isFemale: true) // 性別=女性 let presenter: ProfilePresenter = .init(stub, dataSource) ProfilePresenter.swift protocol = 抽象へ依存 任意の性別を依存注⼊
  32. A59 例: Serviceのメソッドの抽象化 protocol ProfileServiceInput { /// 指定した会員のプロフィールを取得するPromiseオブジェクトを返す /// ///

    - parameters: /// - targetId: 取得する会員のメンバーID func fetchMemberLookupRequest(for targetId: Int) -> Promise<ProfileModel> } /// APIを叩いてデータを取得するService class ProfileService: ProfileServiceInput { func fetchMemberLookupRequest(for targetId: Int) -> Promise<ProfileModel> { return APIManager.shared.getPromise( .memberLookup(targetId), apiTargetStore: self.apiTarget) } } SampleSnapshotTests.swift
  33. A60 例: JSONファイルからレスポンスを返す import ObjectMapper /// ローカルのJSONからデータを取得するService class ProfileServiceStub: ProfileServiceInput

    { private let json: [String: Any] init(_ json: [String: Any]) { self.json = json super.init() } // Snapshot Test時のみ実行 func fetchMemberLookupRequest(for targetId: Int) { let model = ProfileResponseModel(JSON: self.json)! return Promise<ProfileResponseModel> { resolver in resolver.fulfill(model) } } } SampleSnapshotTests.swift (ORマッピングには tristanhimmelman/ObjectMapper を利⽤)
  34. // テストパラメーターから必要な状態のViewを生成して返す private func createView(_ json: [String: Any], _ input:

    ProfilePresenterInput) -> ProfileViewController { let serviceStub: ProfileServiceStub = .init(json) let dataSource: ProfileDataSource = .init(serviceStub) let presenterInput: ProfilePresenterInput = .init(...) let presenter: ProfilePresenter = .init(input, dataSource) return ProfileViewController(with: .init(presenter: presenter)).view } // テストパラメーターをタプルにまとめて let testCases: [(json: [String: Any], input: (origin: OriginType, gender: GenderStoreStub), identifier: String)] = [ (JSONFiles.Profile1.result, (.talk, .female), "女性がトーク画面から遷移"), (JSONFiles.Profile2.result, (.appeal, .male), "男性がアピール画面から遷移"), ... ] // ループで回して撮影 testCases.forEach { testCase in let view = self.createView(testCase.result, input) FBSnapshotVerifyView(view, identifier: testCase.identifier) } パラメータと共通処理をまとめる A61 例: 依存注⼊で画⾯を⽣成し撮影
  35. A64 • ViewControllerの場合シミュレーターのサイズで撮 影 (iOSSnapshotTestCaseの場合) • ローカル実⾏ではデバイス切り替えの⼿間がやや⾯倒 撮影時の画⾯サイズ・OSバージョンの変更 FolioさんのFastfileでパラメータ化しテスト実⾏ github.com/folio-sec/Fastfile

    より func snapshot_test(workspace, scheme, device, os_version, only_testing) { system("xcodebuild test-without-building -workspace #{workspace} -scheme #{scheme} RECORD_MODE_ENV=true -destination 'name=#{device},OS=#{os_version}' -only-testing:#{only_testing}") } snapshot_testのlane定義
  36. A65 依存注⼊ VS 環境変数/プリプロセッサマクロ 갪湡 ⣛㶷岣Ⰵ 橆㞮㢌侧 鏣鎘⥜姻 䗳銲זֿהָ֮׷ קר♶銲

    ذأزٗآحؙך 㹀纏㜥䨽 فٗتؙءّٝ הכ殯ז׷㜥䨽 فٗتؙءّٝ הずׄ㜥䨽 ٖ؎َ٦׀הך 䊴׃剏ִ 〳腉 ꨇ׃ְ 柔軟にテストを書いていくなら ⼿間はかかるが依存注⼊がおすすめ #if TEST // テスト時のみ実行 #endif プリプロセッサマクロで分岐する例 if ProcessInfo().environment["TEST"] != nil { /*テスト時のみ実行*/ } 環境変数で分岐する例
  37. A66 本⽇のindex • @imaizumeとDiverseの紹介 • スナップショットテスト導⼊の背景 • 初期設定 • 撮影時の表⽰条件・状態整理

    • 状態再現のための設計修正 • 撮影した情報の整理 • 様々なViewの撮影 • TIPS&ハマりポイント • まとめ
  38. A68 フォルダ・ファイル名の命名規則 • 例えば • {folderName}に期待する状態 • {identifier}にコンテキストを与える • 状態が特に無ければ役割やカテゴリでグループ化

    ㄏせ鋉⵱ デフォルト {Scheme}.{Testクラス} (例: YourAppTest.MySnapshotTests) 指定時 {folderName} (例: プロフ基本表示) デフォルト {メソッド}_{fileNameOptions}.png (例: testView_iPhone_12_4.png) 指定時 {メソッド}_{identifier}_{fileNameOptions}.png (例: testView_スクロール後_iPhone_12_4.png) 갪湡 フォルダ 画像 fileNameOptionsは 次ページで解説
  39. A69 fileNameOptionsについて • ファイル名に出⼒するテスト時の環境情報 • 表⽰確認時に必要な環境情報はここで指定しておく self.fileNameOptions = [.device, .OS,

    .screenSize, .screenScale] fileNameOptionsの指定例 画像名: testRecoveryPopup_残りアピール数1個[email protected] ؔفءّٝ 嚊銲 ⦼ך《䖤⯋ ⢽ .none ז׃ - - .device رغ؎أ䞔㜠 UIDevice.current.model iPhone, iPad .OS 04غ٦آّٝ UIDevice.current.systemVersion 11_3, 12_4 .screenSize 歗꬗؟؎ؤ UIScreen.main.bounds.size 375x812 .screenScale ⦓桦 UIScreen.main.scale @2x, @3x
  40. A72 ⾃前でレポートへ出⼒するスクリプトを作成 https://gist.github.com/imaizume/6aa2537c1c6778b50873c813eb7a15f1 Folioさんの⼒を再び借りる → screenshots-preview-generator.rb • 画像⽣成後に実⾏→Markdownでレポートを出⼒ • 場合により⽂字列マッチのパターンを変える必要

    • ファイル・フォルダの命名規則に応じて • 出⼒先やフォーマットを変えたい時 ※上記を参考に @imaizume の作成したスクリプトも公開中 https://raw.githubusercontent.com/folio-sec/Fastfile/master/Scripts/screenshots-preview-generator.rb
  41. A77 本⽇のindex • @imaizumeとDiverseの紹介 • スナップショットテスト導⼊の背景 • 初期設定 • 撮影時の表⽰条件・状態整理

    • 状態再現のための設計修正 • 撮影した情報の整理 • 様々なViewの撮影 • TIPS&ハマりポイント • まとめ
  42. A78 ViewController撮影時のサイズについて class FromXibVC: UIViewController { init() { super.init(nibName: "FromXibVC",

    bundle: .main) } } let vc = FromXibVC() Xibから⽣成した画⾯ let sb = UIStoryboard( name: "FromStoryboardVC", bundle: .main) let vc = sb .instantiateInitialViewController() as! FromStoryboardVC Storyboardから⽣成した画⾯ ⽣成⽅法の異なる2つのViewControllerを ①Storyboardから生成 ②Xibから生成 Xib/Storyboardのプレビュー: iPhone SE シミュレータ: iPhone SE / 6s / Xs ... 同じシミュレーター環境で撮影
  43. A80 対策: Root Viewのサイズを指定する class ChanceTimePopupView: UIView { ... }

    class PopupSnapshotTest: FBSnapshotTestCase { ... func testMalePopups() { let vc: ChanceTimePopupViewController = .init() // Root Viewのサイズを指定 vc.view.frame = UIScreen.main.bounds FBSnapshotVerifyView(vc.view, identifier: "5つ星") } } Xibから作ったViewControllerのサイズを指定する 画⾯サイズが変化しない時は ViewControllerの⽣成⽅法を確認
  44. class ProfileViewController: UIViewController { override func viewDidLoad() { // 非同期の通信

    self.service.callApi( params: params, success: { model in // メインスレッドでのUIの更新 DispatchQueue.main.async { self.imageView?.image = model.isCampaigning ? Asset.campaignImage.image : Asset.defaultImage.image } }, failure: { res, error in print(error) }) ...} ⾮同期処理が発⽣する画⾯ A81 ⾮同期実⾏が必要な場合 • DispatchQueueによる処理 • 実際のAPI通信を利⽤したデータ取得時 データ取得が遅れ撮影時に反映されない
  45. A82 対策: XCTExpectationで⾮同期にテストを実⾏ func testFemaleProfileRegister() { let vc = MyAsyncViewController()

    self.setupView(vc: vc) let exp = self.expectation(description: "UI描画") if XCTWaiter.wait(for: [exp], timeout: 1.0) == .timedOut { FBSnapshotVerifyView(vc.view, identifier: "非同期処理") } } ⾮同期処理を1.0秒待ってから撮影するテストの例 • ⾮同期処理完了でXCExpectation#fulfillを呼ぶ • またはXCTWaiter#waitで適当な時間待ってから撮影 データが正常に表⽰されないときは ⾮同期処理が⾛っていないかを確認
  46. A83 Navigation Barを含めての画⾯撮影 let nc: UINavigationController = .init(rootViewController: viewController) nc.title

    = "プロフィール登録" let window: UIWindow = .init(frame: nc.view.bounds) window.addSubview(nc.view) FBSnapshotVerifyView(nc.view, identifier: "ナビゲーション") Navigation Barを表⽰させる UIWindow.addSubviewしない場合 UIWindow.addSubviewした場合 UIWindowのsubViewに追加する必要あり
  47. class CustomCellSnapshotTests: UITableViewDelegate, UITableViewDataSource { func testCustomCell() { let tableView:

    UITableView = .init(of: sizeOfTable) tableView.register(nib, forCellReuseIdentifier identifier) tableView.delegate = self tableView.dataSource = self tableView.reloadData() FBSnapshotVerifyView(self.tableView, identifier: identifier) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {...} func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 1 } } UITableViewCellの撮影 A84 UITable(Collection)ViewCellの撮影 • Cellは単独で初期化できない • CellのコンテンツをカスタムViewに • またはテストクラスにDelegate/DataSourceを定義
  48. A85 本⽇のindex • @imaizumeとDiverseの紹介 • スナップショットテスト導⼊の背景 • 初期設定 • 撮影時の表⽰条件・状態整理

    • 状態再現のための設計修正 • 撮影した情報の整理 • 様々なViewの撮影 • TIPS&ハマりポイント • まとめ
  49. A87 viewWill/DidAppearが呼ばれない override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.presenter.setupViews()

    } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.presenter.setupViewsAgain() } viewWill/DidAppearでpresenterをコールする例 上記のような場合 presenterのメソッド が呼ばれず空のViewが撮影される (iOSSnapshotTestCaseの場合)
  50. A88 ライフサイクルメソッドのコールを確認 class MyViewController: UIViewController { override func loadView() {

    super.loadView() print("Called loadView") } ... // 以下同様にviewDidDisappearまでコールを確認する } SampleSnapshotTests.swift Called loadView Called viewDidLoad Called viewWillLayoutSubview Called viewDidLayoutSubview Output オブジェクトは⽣成されるが階層に追加されないため viewWill/DidAppearが呼ばれない
  51. class ProfileViewController: UIViewController { ... /// 正しい表示を再現するためのヘルパーメソッド private func setupView<T:

    UIViewController>(vc: T) -> T { // 他の設定も必要に応じて vc.modalTransitionStyle = .crossDissolve vc.modalPresentationStyle = .overCurrentContext // viewWillAppearの呼び出し vc.beginAppearanceTransition(true, animated: false) // viewDidAppearの呼び出し vc.endAppearanceTransition() } } ProfileViewController.swift A89 viewWill/DidAppearを呼ぶには begin/endAppearanceTransitionをコールする
  52. A90 本⽇のindex • @imaizumeとDiverseの紹介 • スナップショットテスト導⼊の背景 • 初期設定 • 撮影時の表⽰条件・状態整理

    • 状態再現のための設計修正 • 撮影した情報の整理 • 様々なViewの撮影 • TIPS&ハマりポイント • まとめ
  53. A91 スナップショットテスト導⼊の効果 ※残念ながら実際の⼯数計測はまだできていません エンジニア • 開発段階で表⽰バグに気づけるように • 撮影時の状態再現を意識して設計するように テスター •

    表⽰確認の⼿間が減り全体の⼯数が半減 • 既存画⾯の継続的なデグレチェックが可能に • ⼿が回らなかった条件での表⽰バグを発⾒ 互いに表⽰確認作業の改善を実感
  54. A92 想定と違ったところ (現時点では) 差分検知機能があまり使われない (導⼊から⽇数が経過していないため) 倜鋉歗꬗ 傀㶷歗꬗ 湡涸 歗꬗ؕةؚٗ⡲䧭 رؚٖ嗚濼

    邌爙勴⟝ 㢌刿〳腉䚍֮׶ 然㹀幥׫ 何⥜؝أز ⡚ְ 넝ְֿהָ֮׷ 新規画⾯と既存画⾯での導⼊時の条件⽐較 ⻑期運⽤の中で効果が発揮されそう 運⽤段階で効果を発揮
  55. A98 最後に そういえば今週... ということは iOS 13 で表⽰確認しないと... (Dark Mode) なので

    今すぐスナップショットテストを書こう❗ iOS 13 正式版が配信(されるはず) (iOSSnapshotTestCase / SnapshotTesting は XCode beta 6 & iOS 13.0 beta で動作を確認)
  56. A99 参考リンク・書籍1 • 株式会社Diverse https://diverse-inc.co.jp/recruit/environment 働きやすい環境で真剣に出会いのプラットフォーム作ってます! • Poiboy https://poiboy.jp @imaizume

    も開発しているマッチングアプリ ぜひDLしてね!! • 株式会社Uzumaki https://uzumaki-inc.jp Webクリエイターのギルド集団 アプリやデザインのお仕事お待ちしています!! • iOSSnapshotTestCase https://github.com/uber/ios-snapshot-test-case/ Facebook製で現在はUberがメンテしているスナップショットテストフレームワーク • SnapshotTesting https://github.com/pointfreeco/swift-snapshot-testing/ Point-Freeの出しているSwift製スナップショットテストフレームワーク
  57. A100 参考リンク・書籍2 • What is iOSSnapshotTestCase (@tamaki) https://speakerdeck.com/tamaki/what-is-iossnapshottestcase 基礎的な事項が分かりやすくまとめられているのでぜひ併せて読んでみてください! •

    Snapshot Testing in iOS (@suieyy) https://speakerdeck.com/susieyy/snapshot-testing-in-ios 概要から運⽤ノウハウについて分かりやすく書かれています、こちらも必読です! • iOS Snapshot Testing (Aaina Jain) https://www.linkedin.com/pulse/ios-snapshot-testing-aaina-jain- スナップショットでの基礎的な説明内容はこちらを参考にさせていただきました • Instantiate https://github.com/tarunon/Instantiate ViewControllerやViewに簡単にDIできるようにするためのライブラリ • iOSアプリのテストを書きたいのに書けないあなたへ (@imaizume) https://speakerdeck.com/imaizume/how-you-should-start-to-write-your-first-unit- test-for-ios テストのための設計変更やリポジトリパターンについて解説した過去の発表
  58. A101 参考リンク・書籍3 • folio-sec/Fastfile https://github.com/folio-sec/Fastfile スナップショットに役⽴つ⼤変便利なlaneとスクリプトが揃ったライブラリです folioさんに感謝して使いましょう!! • @imaizume のMarkdownファイル⽣成スクリプト

    https://gist.github.com/imaizume/6aa2537c1c6778b50873c813eb7a15f1 folioさんのスクリプトを参考に⼀部を改変させていただきました • はじめてのfastlane Snapshot編 - Qiita (@tamaki) https://qiita.com/tamaki/items/f5e9f9985a91fb6a0f06 fastlane Snapshotについて詳しく知りたい⽅はこちらを参考に • CharlesでiOS Simulatorの通信をキャプチャする⽅法 - Qiita https://qiita.com/ruwatana/items/93cafe2369faec4b2598 今回解説できなかったChalesのインストールやキャプチャー⽅法はこちらをご参考に • Wiremockで⾏うUIテスト - Speaker Deck (@tamaki) https://speakerdeck.com/tamaki/wiremockdexing-uuitesuto Wiremockを使ったテストの仕⽅についてまとまっています
  59. A102 参考リンク・書籍4 • [iOS、Swift] ユニットテストの時に、任意のタイミングでViewDidLoad()、 ViewWill(Did)Appear()、ViewWill(Did)Disappear()を呼び出す⽅法 - Qiita https://qiita.com/mii-chan/items/a9d8fd420d04b92a1c34 ライフサイクルメソッドを外部から操作する時に参考にさせていただきました

    • Failure image generated despite no change of code and their diff incorrectly rendered · Issue #99 - uber/ios-snapshot-test-case https://github.com/uber/ios-snapshot-test-case/issues/99 意図しないdiffが出てしまう問題を報告しています 返信がきたらどこかで共有します • Snapshot testing in XCTest - XCNotes - Medium https://medium.com/xcnotes/snapshot-testing-in-xcuitest-d18ca9bdeae iOSSnapshotTestCaseのpixel toleranceについて解説されています • switching to dark mode in UI test - Apple Developer Forums https://medium.com/xcnotes/snapshot-testing-in-xcuitest-d18ca9bdeae UIテストでDark Modeをエミュレーションすることはできるのか...
  60. A104 iOSSnapshotTestCase VS SnapshotTesting // UIViewControllerの撮影時は画面サイズをassociated valueに渡せる let vc: UIViewController

    = .init() // iPhone SEで撮影 assertSnapshot(matching: vc, as: .image(on: .iPhoneSe)) // iPhone 8のランドスケープで撮影 assertSnapshot(matching: vc, as: .image(on: .iPhone8(.landscape))) // UIViewの撮影時は渡せない let view: UIView = .init(frame: size) // ERROR: Generic parameter 'Format' could not be inferred assertSnapshot(matching: view, as: .image(on: .iPhone8)) SnapshotTestingでのフォルダ・画像名の指定 端末サイズと回転情報をメソッド引数に渡せる (OSは実⾏時の端末に依存) 発表時省略 ⏭
  61. ㄏせ鋉⵱ デフォルト (指定不可) {Testクラス} (例: MySnapshotTests) デフォルト {メソッド}.{通し番号}.png (例: testView.1.png)

    指定時 (namedなし) {testName}.{通し番号}.png (例: プロフ基本表示.1.png) 指定時 (namedあり) {testName}.{named}.{通し番号}.png (例: プロフ基本表示.スクロール後.1.png) 갪湡 A105 フォルダ・ファイル名の命名規則 • フォルダ名指定が不可 • 画⾯サイズなどの環境情報は⾃前で付与する必要あり • メンバではなくテストメソッドの引数に指定する フォルダ 画像 発表時省略 ⏭
  62. A106 フォルダ/画像名の指定 func testView() { // testView.1.png assertSnapshot(matching: vc, as:

    .image) // プロフィール.1.png assertSnapshot(matching: vc, as: .image, testName: "プロフィール") // プロフィール.スクロール後.1.png assertSnapshot(matching: vc, as: .image, named: "スクロール後", testName: "プロフィール") } SnapshotTestingでのフォルダ・画像名の指定 発表時省略 ⏭
  63. A110 モザイクをかけたViewがうまく撮影されない 本来の表⽰ スナップショットで撮影された表⽰ let frame: CGRect = .init(origin: .zero,

    size: .init(width: 150, height: 30)) let label: UILabel = .init(frame: frame) label.layer.shouldRasterize = true label.layer.magnificationFilter = .nearest label.layer.minificationFilter = .trilinear label.layer.rasterizationScale = 0.3 assertSnapshot(matching: label.layer, as: .image) ラベルにモザイクをかける iOSSnapshotTestCase / SnapshotTestingともに不可 XCUITestならうまく撮影できるかも (未検証) 発表時省略 ⏭
  64. A112 CIへの組み込み (Bitrise) 発表時省略 ⏭ 単⼀環境で実⾏ 複数環境で実⾏ (Fastlane) 通常の単体テストと 同じフローでOK

    (未検証ですが) P74のスクリプトの実⾏だけでは Attachementにならない FastlaneからGitHub APIに markdownをPOSTすれば PRにレポートを出⼒できるかも 差分画像がzipでDL可
  65. import WiremockClient class SnapshotTestWithWiremockClient: FBSnapshotTestCase { override func setUp() {

    WiremockClient.postMapping(stubMapping: StubMapping .stubFor(requestMethod: .GET, urlMatchCondition: .urlPathMatching, url: "/api/v3/fetch/some") .willReturn(ResponseDefinition() .withStatus(200) .withLocalJsonBodyFile(fileName: "Profile1", fileBundleId: bundleIdentifier, fileSubdirectory: nil)))} ... } WiremockClientの設定例 A114 Wiremockを使ったAPIのモック • WiremockはローカルでAPIをモックできるツール • クライアント側はWiremockClientをインストール • ローカルでの動作確認に有効 発表時省略 ⏭
  66. A115 JSONファイルの区別がつかなくなる問題 • 各ファイルが表す表⽰条件を把握しづらい • しかしJSONにはコメントが書けない { "memo": { "目的":

    "長いプロフ検証", "職業": "2文字", "名前": "15文字", "プロフ": "長い", "共通点": 6 }, "body": {...} } ProfileResponseModel.1.json 適当なkeyを掘ってメモするとちょっと便利 発表時省略 ⏭
  67. A116 ローカルのJSONファイルを読み込む { "result": { "member": { "age": 26, "name":

    0, "work": "IT", "prefecture": "東京", "introduction": "プロフィールを見ていただきありがとう...", ... }, ... } Profile.json // Bundle.main.pathで取得 let json = Bundle.main.path(forResource: "Profile", ofType: "json")! // SwiftGenで取得 let json = JSONFiles.Profile.result Sample.swift 発表時省略 ⏭
  68. A117 リソース管理ライブラリ(SwiftGen) • SwiftGenやR.swiftだとローカルのJSONファイルを Type Safeに取得できるので便利 • SwiftGenは定義した値をループで回せないので注意 (static定数に定義するので) {

    "body": { "name": "今泉", "age": 28 }, "extra": true } Profile.json internal enum JSONFiles { internal enum Profile { internal static let extra: Bool = true internal static let body: [String: Any] = ["age": 28, "name": "今泉"] } } JSON.swift SwiftGenを利⽤してJSONをType Safeに取得する例 github.com/SwiftGen/SwiftGen 発表時省略 ⏭
  69. A118 identifierに⼀部の記号が使えない ⼀部記号が _ に変換されてしまう FBSnapshotVerifyView(vc.view, identifier: "!#$%&'()=-~¥|[]{}`@*+;:/?<>,.") identifierに記号を含めた場合 testXXX___$_____=_~¥|____`__+____<>[email protected]

    identifierに複雑な情報を含めて レポートスクリプトでparseするのはおすすめしない 区切り⽂字に記号を使おうとしたら (出⼒される画像名) (上記はiOSSnapshotTestCaseの場合) 発表時省略 ⏭
  70. A119 pixel toleranceについて • 特定のOSや端末でわずかにフォントが変わる • 振動するようなアニメーションを撮影する 差分検知で許容される表⽰のずれ pixel toleranceを利⽤するシーン

    // 画像全体で5%のピクセルが合致しないことを許容する FBSnapshotVerifyView(vc.view, identifier: "view" , overallTolerance: 0.05) // 各ピクセル単位でRGBAによる色距離の差を5%まで許容する FBSnapshotVerifyView(vc.view, identifier: "view" , perPixelTolerance: 0.05) pixel toleranceを指定する例 https://medium.com/xcnotes/snapshot-testing-in-xcuitest-d18ca9bdeae https://github.com/facebookarchive/ios-snapshot-test-case/blob/ master/FBSnapshotTestCase/FBSnapshotTestCase.h 発表時省略 ⏭