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. スナップショットテスト
    実戦投⼊
    iOSDC Japan 2019
    株式会社Diverse 今泉智博 (@imaizume)
    A1

    View Slide

  2. A2
    ⾃分のアプリにスナップショットテストを導⼊する
    (今後より多くのスナップショットテストに関する知⾒が発信される)
    ⽬標
    このトークを聞いてもらいたい⽅
    • アプリの表⽰確認作業で苦労したことがある
    • 予期せぬ表⽰崩れや表⽰バグに悩まされたことがある
    • スナップショットテストについて知らない/知りたい
    以下のいずれかに当てはまる⽅✋

    View Slide

  3. A3
    おことわり
    • 今回は時間の都合上、UIのスナップショット
    に絞ってお話します
    • 表⽰内容は全てテストデータです
    • 本資料はSpeakerDeckにて公開済みです
    ⼀部⾒えにくい箇所等はお⼿元でご覧下さい
    • 「スナップショットテスト」は、より⼀般的
    には「画像ベーステスト」や、「ビジュアル
    リグレッションテスト」と呼ばれています。

    View Slide

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

    View Slide

  5. A5
    ⾃⼰紹介
    Tomohiro Imaizumi - @imaizume
    Androidユーザー歴8年のiOS開発者 (iOS歴: 2年半)
               /
    ♨ 銭湯巡りが趣味 (お遍路88件達成)

    View Slide

  6. A6
    ⼥性から選ぶ
    マッチングアプリ
    運営4年⽬ / 110万DL突破!
    Poiboyの紹介
    https://poiboy.jp

    View Slide

  7. A7
    Diverseブースにもぜひ遊びに来てください!
    マッチングアプリを⾝近に感じてもらえる
    コンテンツを⽤意して待っています!
    緑の看板が⽬印

    View Slide

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

    View Slide

  9. A9
    みなさんに聞きたいこと
    正直ツラくないですか
    リリース前のアプリの表⽰確認って

    View Slide

  10. A10
    表⽰確認での悩み1: 条件網羅が難しい
    • 特定条件下のみで起こる不具合
    • レスポンス / 内部状態 / 機種 など条件が複雑
    • 全条件で⼿動確認するのはコストが⼤きい

    View Slide

  11. A11
    表⽰確認での悩み2: 時間がかかる
    • 状態再現に時間がかかる
    • アーカイブ&beta版配信にも時間がかかる

    View Slide

  12. A12
    表⽰確認での悩み3: 予期せぬ画⾯崩壊
    • Auto Layoutの制約漏れに気づかない
    • ある画⾯のViewや状態変更が別画⾯にも影響

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  16. A16
    スナップショットテストとは?
    ⾃動でアプリの内部状態(UI)を記録(撮影)し
    正しい状態との⽐較・差分検出をする仕組み
    AFTER
    BEFORE
    AFTER
    BEFORE

    View Slide

  17. A17
    指定したUIの状態を再現し⼀括撮影
    • テスト実⾏で指定した画⾯やViewを⾃動撮影
    • 複数の表⽰パターンも⼀括で撮影
    ⼿動での状態再現と撮影が不要に/
    ⾃動で撮影されたスクリーンショットの例

    View Slide

  18. A18
    スナップショットテスト実⾏時の様⼦

    View Slide

  19. A19
    過去の画像と⽐較し差分を検出
    • 対応する過去のUI状態と⽐較
    • Viewの配置や⾊の差分を検出
    • 差分を画像出⼒しテストをFailさせる
    表⽰のデグレを⾃動で検出可能/
    撮影 ⽐較 通知
    元のレイアウト 差分
    撮影した画像

    View Slide

  20. A20
    ؒٝآص، ذأة٦
    • 㹋鄲
    • رغحؚ
    • ذأز
    • ٖؽُ٦
    ،٦ؕ؎ـ
    ⹛⡲然钠
    غؚ㜠デ
    • ⥜姻 ،٦ؕ؎ـ
    ⹛⡲然钠
    غؚ㜠デ
    • 鷄⸇⥜姻
    ،٦ؕ؎ـ ⹛⡲然钠
    全表⽰パターンとデグレ確認✅
    修正確認の
    ループを減らせる
    表⽰確認がツラくない
    スナップショットテスト導⼊後
    スナップショット
    テスト実⾏

    View Slide

  21. A21
    スナップショットテストが動作するレイヤー
    全レイヤーで実⾏可能 (基本はUIより下)
    6*
    窟さ
    ⽃⡤
    䩛⹛ذأز
    テストピラミッドと
    スナップショットテスト
    Viewの撮影/テスト
    ViewControllerの撮影/テスト
    テストの⽬的:
    「ある条件下の表⽰の正しさ確認」

    View Slide

  22. A22
    スナップショットテストのメリット
    基本は単体/統合テスト上で実⾏
    • UI操作での画⾯到達の必要なし
    • UIテストと⽐較して⾼速に動作
    ログインをモックせずに目的の画面を撮影可能

    View Slide

  23. 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

    View Slide

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

    View Slide

  25. 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)

    View Slide

  26. A26
    実⾏の流れ(全体)
    撮影と差分検出の2パートに分かれる
    false
    recordMode
    嫰鯰㼎韋
    ך歗⫷
    true
    嫰鯰㼎韋
    ך歗⫷
    䊴ⴓ
    ז׃
    ֮׶
    ז׃
    ֮׶
    ♳剅ֹ乆䕦 倜鋉乆䕦
    ז׃
    ֮׶
    recordModeⴖ׶剏ִ

    View Slide

  27. A27
    前半: スクリーンショットの撮影
    false
    recordMode
    嫰鯰㼎韋
    ך歗⫷
    true
    嫰鯰㼎韋
    ך歗⫷
    䊴ⴓ
    ז׃
    ֮׶
    ז׃
    ֮׶
    ♳剅ֹ乆䕦 倜鋉乆䕦
    ז׃
    ֮׶
    recordModeⴖ׶剏ִ

    View Slide

  28. A28
    テストファイル作成とrecordModeの設定
    • FBSnapshotTestCaseを継承したテストクラスを作成
    • 撮影時だけ recordMode = true に
    import FBSnapshotTestCase
    @testable import YourApp
    class MySnapshotTestCase: FBSnapshotTestCase {
    override func setUp() {
    super.setUp()
    self.recordMode = true
    }
    ...
    }
    SampleSnapshotTests.swift
    デフォルトでfalseなので撮影後はコメントアウト

    View Slide

  29. 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

    View Slide

  30. A30
    実⾏結果
    "failed - Test ran in record mode. Reference
    image is now saved." と出れば成功
    • (YourAppTests)/ReferenceImages_64以下に
    撮影された画像(参考画像)が⽣成

    View Slide

  31. A31
    後半: 差分の検出
    false
    recordMode
    嫰鯰㼎韋
    ך歗⫷
    true
    嫰鯰㼎韋
    ך歗⫷
    䊴ⴓ
    ז׃
    ֮׶
    ז׃
    ֮׶
    ♳剅ֹ乆䕦 倜鋉乆䕦
    ז׃
    ֮׶
    recordModeⴖ׶剏ִ

    View Slide

  32. 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で再実⾏ → テストが通る

    View Slide

  33. 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に差分画像が出現
    差分 実⾏時 ⽐較元

    View Slide

  34. 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が出⼒されます。)
    差分 実⾏時 ⽐較元

    View Slide

  35. A35
    再掲: 実⾏の流れ(全体)
    基本は「撮影→差分⽐較」の繰り返し
    false
    recordMode
    嫰鯰㼎韋
    ך歗⫷
    true
    嫰鯰㼎韋
    ך歗⫷
    䊴ⴓ
    ז׃
    ֮׶
    ז׃
    ֮׶
    ♳剅ֹ乆䕦 倜鋉乆䕦
    ז׃
    ֮׶
    recordModeⴖ׶剏ִ

    View Slide

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

    View Slide

  37. ؟ٝفٕ 植㹋ך،فٔ
    邌爙勴⟝ א 醱侧
    7JFXך欰䧭 ꫼涸 ⹛涸ז皘䨽֮׶
    Ⰵ⸂ ♶銲 "1*ⰻ鿇朐䡾ח⣛㶷
    A37
    サンプルと現実のアプリ
    必要なこと
    1. 撮影時の表⽰条件・状態整理
    2. 状態再現のための設計修正
    撮影までに超えねばならない壁が存在

    View Slide

  38. A38
    撮影したい状態の確認
    撮影したい表⽰状態が複数ある場合
    どの状態で撮影するかを整理しておく
    課⾦状態
    ログイン状態
    プロフィール
    写真
    親画⾯
    (どの状態を再現するんだっけ?)

    View Slide

  39. A39
    例: Poiboyのプロフィール画⾯
    • 相⼿または⾃分のプロフィールを表⽰
    • 表⽰項⽬が複数で表⽰条件が複雑
    プロフィール画⾯の例

    View Slide

  40. A40
    例: Poiboyのプロフィール画⾯
    • 相⼿または⾃分のプロフィールを表⽰
    • 表⽰項⽬が複数で表⽰条件が複雑
    プロフィール画⾯の例
    年齢 / 住所/ 職業
    名前の⽂字数
    プロフ写真数
    プロフの⻑さ
    共通点の数・内容
    ボタンの表⽰・テキスト
    ページャー
    (画⾯サイズ)
    (OSバージョン)
    タイトルテキスト
    (性別で変化)
    オンライン状態

    View Slide

  41. A41
    表⽰条件と項⽬を整理
    プロフィール画⾯でスナップショット撮影する時の状態のリスト
    どの表⽰項⽬を撮影すべきか・必要条件が何か
    仕様書やテスターと確認する
    条件の漏れ/不要に気づくためにも必要

    View Slide

  42. A42
    表⽰条件と項⽬を整理
    条件の漏れ/不要に気づくためにも必要
    プロフィール画⾯でスナップショット撮影する時の状態のリスト
    どの表⽰項⽬を撮影すべきか・必要条件が何か
    仕様書やテスターと確認する
    職業: ⽂字数の境界値で
    名前: 境界値+0⽂字 (全⾓で)
    共通点: 0~6個のケース全て⾒たい
    プロフ⽂: スクロールの有無を確認
    都道府県:
    考慮不要

    View Slide

  43. A43
    参考: ⼿動表⽰確認はテスターもツラい
    テスター⽤のプロフィール画⾯の確認項⽬リストの例

    View Slide

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

    View Slide

  45. ؟ٝفٕ 植㹋ך،فٔ
    邌爙勴⟝ א 醱侧
    7JFXך欰䧭 ꫼涸 ⹛涸ז皘䨽֮׶
    Ⰵ⸂ ♶銲 "1*ⰻ鿇朐䡾ח⣛㶷
    A45
    再掲: サンプルと現実のアプリ
    撮影までに超えねばならない壁
    必要なこと
    1. 撮影時の表⽰条件・状態整理
    2. 状態再現のための設計修正

    View Slide

  46. A46
    状態再現が易しいケース
    (Viewに値を渡すだけで表⽰が再現できるような場合)
    ⼊⼒が初期化パラメータのみに依存する場合

    View Slide

  47. A47
    例: ブースト完了ポップアップ
    • ブースト(アイテム使⽤)状態の完了を通知する
    • ブースト中に異性にプロフィールが露出した回数を表⽰
    「完了」だけでなく「成功」のパターンも確認したい
    桁が多いケースでの表⽰確認をしたい
    • 「完了」「成功」のフラグ
    • スコア: 1~4桁の数値
    画⾯への⼊⼒

    View Slide

  48. 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ならループで回せる

    View Slide

  49. A49
    実際に撮影された画像
    複数条件でスクリーンショットが撮れた!
    ※表⽰の都合上切り抜いていますが実際はフルスクリーンでキャプチャされています。
    ⾒切れているので修正が必要
    成功 完了/2桁 完了/3桁 完了/4桁

    View Slide

  50. A50
    状態再現が難しいケース
    ⼊⼒が複数モジュール・環境に依存する場合
    • 初期化パラメーター
    • 画⾯サイズ
    • APIレスポンス
    • グローバル変数
    差し替え可能にする必要

    View Slide

  51. A51
    例: プロフィール画⾯
    • APIレスポンス
    • 名前
    • ログイン状態
    • 共通点数
    • etc ...
    • グローバル変数
    • 性別
    • A/Bテストの振り分け
    • 初期化パラメータ
    • 親画⾯の種別
    • 画⾯サイズ
    画⾯への⼊⼒

    View Slide

  52. A52
    ⼊⼒を差し替え可能にするために
    • 基本的にMVPだが⼀部画⾯がFat ViewController
    • 依存注⼊によるモジュールの差し込みが不可
    • グローバル変数の参照箇所あり
    アプリの設計修正が必要
    (現状)

    View Slide

  53. A53
    Fat ViewControllerを分解
    7JFX
    1SFTFOUFS
    .PEFM
    4FSWJDF
    ذأز؝٦س
    'BU7JFX
    4FSWJDF
    ذأز؝٦س
    "1*

    View Slide

  54. A54
    依存注⼊を可能に
    7JFX

    1SFTFOUFS

    .PEFM

    4FSWJDF

    ذأز؝٦س
    "1*
    7JFXⰻדJOJU

    1SFTFOUFSⰻדJOJU

    .PEFMⰻדJOJU

    7JFX

    1SFTFOUFS

    ذأز؝٦س
    1SFTFOUFS4UVC

    View Slide

  55. A55
    Serviceでもモックする場合
    7JFX

    1SFTFOUFS

    .PEFM

    4FSWJDF

    ذأز؝٦س
    "1*
    7JFXⰻדJOJU

    1SFTFOUFSⰻדJOJU

    .PEFMⰻדJOJU

    7JFX

    1SFTFOUFS

    .PEFM

    4FSWJDF
    ذأز؝٦س
    4FSWJDF4UVC
    ٗ٦ٕؕך
    +40/ؿ؋؎ٕ
    1SFTFOUFS4UVC

    View Slide

  56. 7JFX
    1SFTFOUFS
    .PEFM
    4FSWJDF
    ؚٗ٦غٕ
    㢌侧
    ذأز؝٦س
    "1*
    ؒٝس
    ه؎ٝز
    䚍ⴽ
    "#㾩䚍
    7JFX
    1SFTFOUFS
    .PEFM
    4FSWJDF
    ؚٗ٦غٕ
    㢌侧
    ذأز؝٦س
    "1*
    4UPSF
    QSPUPDPM
    4UVC
    A56
    明⽰的⼊⼒と抽象へ依存する構造に
    明⽰的な⼊⼒
    抽象への依存
    暗黙的⼊⼒
    具体への依存
    「iOSアプリのテストを書きたいのに書けないあなたへ」より

    View Slide

  57. 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アプリのテストを書きたいのに書けないあなたへ」より
    任意の性別に差し替える
    アプリ内フラグを参照する

    View Slide

  58. 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 = 抽象へ依存
    任意の性別を依存注⼊

    View Slide

  59. A59
    例: Serviceのメソッドの抽象化
    protocol ProfileServiceInput {
    /// 指定した会員のプロフィールを取得するPromiseオブジェクトを返す
    ///
    /// - parameters:
    /// - targetId: 取得する会員のメンバーID
    func fetchMemberLookupRequest(for targetId: Int)
    -> Promise
    }
    /// APIを叩いてデータを取得するService
    class ProfileService: ProfileServiceInput {
    func fetchMemberLookupRequest(for targetId: Int)
    -> Promise {
    return APIManager.shared.getPromise(
    .memberLookup(targetId),
    apiTargetStore: self.apiTarget)
    }
    }
    SampleSnapshotTests.swift

    View Slide

  60. 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 { resolver in
    resolver.fulfill(model)
    }
    }
    }
    SampleSnapshotTests.swift
    (ORマッピングには tristanhimmelman/ObjectMapper を利⽤)

    View Slide

  61. // テストパラメーターから必要な状態の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
    例: 依存注⼊で画⾯を⽣成し撮影

    View Slide

  62. A62
    撮影に成功
    プロフィール基本表⽰

    View Slide

  63. A63
    撮影に成功
    ボタンの表⽰とテキスト
    共通点とページャーの表⽰

    View Slide

  64. 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定義

    View Slide

  65. A65
    依存注⼊ VS 環境変数/プリプロセッサマクロ
    갪湡 ⣛㶷岣Ⰵ 橆㞮㢌侧
    鏣鎘⥜姻 䗳銲זֿהָ֮׷ קר♶銲
    ذأزٗآحؙך
    㹀纏㜥䨽
    فٗتؙءّٝ
    הכ殯ז׷㜥䨽
    فٗتؙءّٝ
    הずׄ㜥䨽
    ٖ؎َ٦׀הך
    䊴׃剏ִ
    〳腉 ꨇ׃ְ
    柔軟にテストを書いていくなら
    ⼿間はかかるが依存注⼊がおすすめ
    #if TEST
    // テスト時のみ実行
    #endif
    プリプロセッサマクロで分岐する例
    if ProcessInfo().environment["TEST"] != nil { /*テスト時のみ実行*/ }
    環境変数で分岐する例

    View Slide

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

    View Slide

  67. A67
    デフォルトのフォルダ/画像名は分かりづらい
    • そのままでは各画像が何を表現しているか分かりづらい
    • folderNameとidentifierを適切に設定してあげる必要
    デフォルトのフォルダ・画像名の例

    View Slide

  68. A68
    フォルダ・ファイル名の命名規則
    • 例えば
    • {folderName}に期待する状態
    • {identifier}にコンテキストを与える
    • 状態が特に無ければ役割やカテゴリでグループ化
    ㄏせ鋉⵱
    デフォルト
    {Scheme}.{Testクラス}
    (例: YourAppTest.MySnapshotTests)
    指定時
    {folderName}
    (例: プロフ基本表示)
    デフォルト
    {メソッド}_{fileNameOptions}.png
    (例: testView_iPhone_12_4.png)
    指定時 {メソッド}_{identifier}_{fileNameOptions}.png
    (例: testView_スクロール後_iPhone_12_4.png)
    갪湡
    フォルダ
    画像
    fileNameOptionsは
    次ページで解説

    View Slide

  69. 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

    View Slide

  70. A70
    フォルダ/画像名の指定
    期待する状態とコンテキストで画像を整理した例
    確認すべき項⽬と条件の把握が容易に
    self.folderName = "詳細プロフでボタンが非表示になる"
    FBSnapshotVerifyView(vc.view, identifier: "トーク画面から遷移した時")
    ...
    folderNameとidentifierを設定後

    View Slide

  71. A71
    • ⽬的の画像を探しプレビューするのが⼿間
    • テスターやデザイナーと話すにも不便
    • レポートに出⼒したい... が、公式には機能がない
    今度は画像が⼤量⽣成されて分かりづらい
    ⼤量に出⼒されたスクリーンショットの例

    View Slide

  72. 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

    View Slide

  73. A73
    出⼒されるレポートの例
    OSバージョン
    画⾯サイズ
    identifier
    folderName

    View Slide

  74. A74
    参考画像とレポートをGitHub上へUP
    レポートを誰でも簡単に閲覧可能

    View Slide

  75. A75
    条件網羅が難しい問題
    条件整理後にパラメーター化テストへ
    複数状態をループで撮影
    時間がかかる問題
    アーカイブや状態再現のための時間が不要に
    予期せぬ画⾯崩壊
    1度撮影しておけば⾃動テスト実⾏時に通知
    スナップショットテスト導⼊による成果

    View Slide

  76. 条件網羅が難しい問題
    条件整理後にパラメーター化テストへ
    複数状態をループで撮影
    時間がかかる問題
    アーカイブや状態再現のための時間が不要に
    予期せぬ画⾯崩壊
    1度撮影しておけば⾃動テスト実⾏時に通知
    スナップショットテストによる成果
    しかし実戦投⼊では
    さらなるハマりポイントが
    A76

    View Slide

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

    View Slide

  78. 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 ...
    同じシミュレーター環境で撮影

    View Slide

  79. A79
    撮影結果
    ①Storyboardから生成 ②Xibから生成
    Xib⽣成の場合は
    Interface Builderのプレビューサイズで撮影される
    全て同じサイズになる

    View Slide

  80. 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の⽣成⽅法を確認

    View Slide

  81. 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通信を利⽤したデータ取得時
    データ取得が遅れ撮影時に反映されない

    View Slide

  82. 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で適当な時間待ってから撮影
    データが正常に表⽰されないときは
    ⾮同期処理が⾛っていないかを確認

    View Slide

  83. 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に追加する必要あり

    View Slide

  84. 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を定義

    View Slide

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

    View Slide

  86. A86
    状態再現⽤のAPIレスポンスを⽤意する⽅法
    • レスポンスのモックにOpenAPIやApiaryを使えたら理想
    • しかし現状のPoiboyではAPI定義をWikiに書く運⽤
    • そこで通信をCharles Proxyでキャプチャ
    • スタブ⽤JSONを作成し適宜バリエーションを増やす

    View Slide

  87. 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の場合)

    View Slide

  88. 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が呼ばれない

    View Slide

  89. class ProfileViewController: UIViewController {
    ...
    /// 正しい表示を再現するためのヘルパーメソッド
    private func setupView(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をコールする

    View Slide

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

    View Slide

  91. A91
    スナップショットテスト導⼊の効果
    ※残念ながら実際の⼯数計測はまだできていません
    エンジニア
    • 開発段階で表⽰バグに気づけるように
    • 撮影時の状態再現を意識して設計するように
    テスター
    • 表⽰確認の⼿間が減り全体の⼯数が半減
    • 既存画⾯の継続的なデグレチェックが可能に
    • ⼿が回らなかった条件での表⽰バグを発⾒
    互いに表⽰確認作業の改善を実感

    View Slide

  92. A92
    想定と違ったところ
    (現時点では) 差分検知機能があまり使われない
    (導⼊から⽇数が経過していないため)
    倜鋉歗꬗ 傀㶷歗꬗
    湡涸 歗꬗ؕةؚٗ⡲䧭 رؚٖ嗚濼
    邌爙勴⟝ 㢌刿〳腉䚍֮׶ 然㹀幥׫
    何⥜؝أز ⡚ְ 넝ְֿהָ֮׷
    新規画⾯と既存画⾯での導⼊時の条件⽐較
    ⻑期運⽤の中で効果が発揮されそう
    運⽤段階で効果を発揮

    View Slide

  93. A93
    スナップショットテストすべき画⾯
    =「導⼊効果が⾼そう」な画⾯
    考慮する状態が多い
    → とにかく画⾯カタログがほしい
    実⾏時に再現が難しい・不安定
    → 再現のための負荷が減る
    レイアウトが複雑
    → 予期せぬ表⽰崩れが発⽣しやすい

    View Slide

  94. A94
    導⼊の是⾮を検討すべき画⾯
    =「導⼊費⽤が⾼そう」な画⾯
    状態再現のための修正コストが⾼い
    → APIモックなどの戦略を検討する
    仕様が未確定 or ⾼頻度で変わる
    → 仕様が変わるたび再撮影の必要

    View Slide

  95. A95
    今後の課題点
    スクリーンカバレッジの向上
    → 継続してテストを増やしていく必要あり
     (Poiboyは約 10/100 ViewController)
    → 定期的に参考画像の履歴を削除するか
    git submodule化 / Dropbox等へ保存
      (レポートをGitHubで⾒たいので悩む)
    リポジトリサイズ増加問題への対応

    View Slide

  96. A96
    まとめ
    ① スナップショットテストを使うと
    UIの撮影と崩れ検知を⾃動化できる
    ③ ⽬的の表⽰を再現するには
    設計変更やAPIのモックが必要なことがある
    ② 撮影した画像をレポートにすると
    テスターとの情報共有がスムーズになる
    ④ ⻑期的に運⽤することで

    View Slide

  97. A97
    Special Thanks
    株式会社Diverseのみなさん
    株式会社UZUMAKIのみなさん
    株式会社DeNA @kuniwak さん
    この発表を聴きに来てくれた皆さん
    スナップショットテスト実戦投⼊の
    報告をお待ちしています!!

    View Slide

  98. A98
    最後に
    そういえば今週...
    ということは
    iOS 13 で表⽰確認しないと... (Dark Mode)
    なので
    今すぐスナップショットテストを書こう❗
    iOS 13 正式版が配信(されるはず)
    (iOSSnapshotTestCase / SnapshotTesting は XCode beta 6 & iOS 13.0 beta で動作を確認)

    View Slide

  99. 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製スナップショットテストフレームワーク

    View Slide

  100. 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
    テストのための設計変更やリポジトリパターンについて解説した過去の発表

    View Slide

  101. 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を使ったテストの仕⽅についてまとまっています

    View Slide

  102. 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をエミュレーションすることはできるのか...

    View Slide

  103. A103
    補⾜
    Snapshot Testing (swift-snapshot-testing)関連
    発表時省略 ⏭

    View Slide

  104. 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は実⾏時の端末に依存)
    発表時省略 ⏭

    View Slide

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

    View Slide

  106. 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でのフォルダ・画像名の指定
    発表時省略 ⏭

    View Slide

  107. A107
    補⾜
    うまくいかなかったこと
    発表時省略 ⏭

    View Slide

  108. A108
    差がないのにDiffが出⼒されてしまうバグ❓
    • 特定画⾯で、撮影直後に続けて差分⽐較しても
    なぜかテストが失敗し差分も出⼒されてしまう
    • diffではfail側が⼩さく表⽰されてしまう
    reference failure diff
    発表時省略 ⏭

    View Slide

  109. A109
    作者に問い合わせてみたが...
    https://github.com/uber/ios-snapshot-test-case/issues/99
    残念ながら返答なし しかし仲間はいた
    発表時省略 ⏭

    View Slide

  110. 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ならうまく撮影できるかも (未検証)
    発表時省略 ⏭

    View Slide

  111. A111
    補⾜
    その他
    発表時省略 ⏭

    View Slide

  112. A112
    CIへの組み込み (Bitrise)
    発表時省略 ⏭
    単⼀環境で実⾏ 複数環境で実⾏ (Fastlane)
    通常の単体テストと
    同じフローでOK
    (未検証ですが)
    P74のスクリプトの実⾏だけでは
    Attachementにならない
    FastlaneからGitHub APIに
    markdownをPOSTすれば
    PRにレポートを出⼒できるかも
    差分画像がzipでDL可

    View Slide

  113. A113
    Fastlane Snapshotじゃだめなの?
    iOSSnapshotTestCaseなどと同じ
    スクリーンショット撮影⽤ツール
    • ⽬的の画⾯での状態再現が難しい
    • E2Eテストに近いため壊れやすい
    • 差分⽐較の機能がない
    XCUITest上で動作する
    発表時省略 ⏭

    View Slide

  114. 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をインストール
    • ローカルでの動作確認に有効
    発表時省略 ⏭

    View Slide

  115. A115
    JSONファイルの区別がつかなくなる問題
    • 各ファイルが表す表⽰条件を把握しづらい
    • しかしJSONにはコメントが書けない
    {
    "memo": {
    "目的": "長いプロフ検証",
    "職業": "2文字",
    "名前": "15文字",
    "プロフ": "長い",
    "共通点": 6
    },
    "body": {...}
    }
    ProfileResponseModel.1.json
    適当なkeyを掘ってメモするとちょっと便利
    発表時省略 ⏭

    View Slide

  116. 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
    発表時省略 ⏭

    View Slide

  117. 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
    発表時省略 ⏭

    View Slide

  118. A118
    identifierに⼀部の記号が使えない
    ⼀部記号が _ に変換されてしまう
    FBSnapshotVerifyView(vc.view, identifier: "!#$%&'()=-~¥|[]{}`@*+;:/?<>,.")
    identifierに記号を含めた場合
    testXXX___$_____=_~¥|____`__+____<>[email protected]
    identifierに複雑な情報を含めて
    レポートスクリプトでparseするのはおすすめしない
    区切り⽂字に記号を使おうとしたら
    (出⼒される画像名)
    (上記はiOSSnapshotTestCaseの場合)
    発表時省略 ⏭

    View Slide

  119. 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
    発表時省略 ⏭

    View Slide