KINTO Tech Blog
iOS

[iOS] Snapshot Test のリファレンス画像を任意のディレクトリに作成する

Cover Image for [iOS] Snapshot Test のリファレンス画像を任意のディレクトリに作成する

KINTOテクノロジーズで my route(iOS) を開発しているRyommです。
Snapshot Test のリファレンス画像を任意のディレクトリに作成する方法の解説です。

結論

verifySnapshot(of:as:named:record:snapshotDirectory:timeout:file:testName:line:) メソッドを使えばディレクトリを指定できます。

背景

先日 Snapshot Test を導入した記事を書きましたが、しばらく運用してテストファイルが非常に多くなり、目的のテストファイルを探すのがとても大変な状況になりました。

大量のSnapshotTestファイル
大量の Snapshot Test ファイル

そこで Snapshot Test ファイルを適当なサブディレクトリで分けることにしましたが、使用している Snapshot Test のライブラリ pointfreeco/swift-snapshot-testingassertSnapshots(of:as:record:timeout:file:testName:line:) メソッドではリファレンス画像の作成場所を指定することはできません。

既存の Snapshot Test 関連のディレクトリ構造は以下のようになっています。

App/
  └── AppTests/
    └── Snapshot/
      ├── TestVC1.swift
      ├── TestVC2.swift
      │
      └── __Snapshots__/
        ├── TestVC1/
        │ └── Refarence.png
        └── TestVC2/
          └── Refarence.png

サブディレクトリにテストファイルを移動したとき、上記のメソッドでは以下のようにサブディレクトリ内に __Snapshots__ ディレクトリが作成され、さらにその中にテストファイルと同じ名前のディレクトリとリファレンス画像が作成される形になってしまいます。

App/
  └── AppTests/
    └── Snapshot/
      ├── TestVC1/
      │ ├── TestVC1.swift
      │ └── __Snapshots__/
      │   └── Refarence.png ← ここに作られる😕
      │
      └── TestVC2/
        ├── TestVC2.swift
        └── __Snapshots__/
          └── Refarence.png ← ここに作られる😕

すでに存在しているCIの仕組みとして App/AppTests/Snapshot/__Snapshots__/ ディレクトリ以下を丸ごとS3に反映させているため、リファレンス画像の置き場所は変えたくありません。
目標とするディレクトリ構成は以下の形です。

App/
  └── AppTests/
    └── Snapshot/
      ├── TestVC1/
      │ └── TestVC1.swift
      ├── TestVC2/
      │ └── TestVC2.swift
      │
      └── __Snapshots__/ ← リファレンス画像はここに入れたい😣
          ├── TestVC1/
          │ └── Refarence.png
          └── TestVC2/
            └── Refarence.png

リファレンス画像のディレクトリを指定して Snapshot Test を行う

verifySnapshot(of:as:named:record:snapshotDirectory:timeout:file:testName:line:) メソッドを利用すると、ディレクトリを指定することができます。

Snapshot Test に用意されている3つのメソッドは、それぞれ以下の関係になっています。

public func assertSnapshots<Value, Format>(
  matching value: @autoclosure () throws -> Value,
  as strategies: [String: Snapshotting<Value, Format>],
  record recording: Bool = false,
  timeout: TimeInterval = 5,
  file: StaticString = #file,
  testName: String = #function,
  line: UInt = #line
  ) { ... }

as strategies に渡した比較する形式に対してforEachで実行する

public func assertSnapshot<Value, Format>(
  matching value: @autoclosure () throws -> Value,
  as snapshotting: Snapshotting<Value, Format>,
  named name: String? = nil,
  record recording: Bool = false,
  timeout: TimeInterval = 5,
  file: StaticString = #file,
  testName: String = #function,
  line: UInt = #line
  ) { ... }

↓ を実行して返ってきた値を元にテストする

verifySnapshot(of:as:named:record:snapshotDirectory:timeout:file:testName:line:)

実際のコードはこちらで確認することができます。

つまり、内部的に同じことをしていれば直接 verifySnapshot(of:as:named:record:snapshotDirectory:timeout:file:testName:line:) を使っても問題ないということです!

ドン!

SnapshotConfig.swift
extension XCTestCase {
  var precision: Float { 0.985 }

  func testSnapshot(vc: UIViewController, record: Bool = false,
                    file: StaticString, function: String, line: UInt) {
    assert(UIDevice.current.name == "iPhone 15", "Please run the test by iPhone 15")
    SnapshotConfig.allCases.forEach {
      let failure = verifySnapshot(
          matching: vc,
          as: .image(on: $0.viewImageConfig, precision: precision),
          record: record,
          snapshotDirectory: "任意のパス",
          file: file,
          testName: function + $0.rawValue,
          line: line)
      guard let message = failure else { return }
      XCTFail(message, file: file, line: line)
    }
  }
}

my route では元々strategiesに一つの値しか渡していなかったため、strategiesでループさせる処理は端折りました。
さて、ディレクトリを指定することはできたものの、既存の Snapshot Test に準じて、テストファイル名に応じたディレクトリを作成して、その内部にリファレンス画像が作られるようにしたいです。
verifySnapshot(of:as:named:record:snapshotDirectory:timeout:file:testName:line:)に渡すパスは絶対パスにする必要があり、チームで開発しているとそれぞれ環境が異なるため、環境に合わせてパスを生成する処理が必要となります。

非常に愚直でかわいいコードになってしまいましたが、以下のように実装してみました。

SnapshotConfig.swift
extension XCTestCase {
  var precision: Float { 0.985 }

  private func getDirectoryPath(from file: StaticString) -> String {
    let fileUrl = URL(fileURLWithPath: "\(file)", isDirectory: false)
    let fileName = fileUrl.deletingPathExtension().lastPathComponent
    var separatedPath = fileUrl.pathComponents.dropFirst() // ここで [String]? 型になる

    // Snapshotフォルダ以降のパスを削除
    let targetIndex = separatedPath.firstIndex(where: { $0 == "Snapshot"})!
    separatedPath.removeSubrange(targetIndex+1...separatedPath.count)

    let snapshotPath = separatedPath.joined(separator: "/")

    // verifySnapshotに渡すときはStringにするので、URL型に戻さずString型で書いちゃう
    return "/\(snapshotPath)/__Snapshots__/\(fileName)"
  }

  func testSnapshot(vc: UIViewController, record: Bool = false,
                    file: StaticString, function: String, line: UInt) {
    assert(UIDevice.current.name == "iPhone 15", "Please run the test by iPhone 15")
    SnapshotConfig.allCases.forEach {
      let failure = verifySnapshot(
          matching: vc,
          as: .image(on: $0.viewImageConfig, precision: precision),
          record: record,
          snapshotDirectory: getDirectoryPath(from: file),
          file: file,
          testName: function + $0.rawValue,
          line: line)
      guard let message = failure else { return }
      XCTFail(message, file: file, line: line)
    }
  }
}

これでリファレンス画像は従来の場所のまま、Snapshot Test をサブディレクトリに分けることができるようになりました。
スナップショットテストを修正したいのに、中々ファイルが見つからない!という不便さを解消することができました。
まだまだ改善の余地はあると思うので、より快適な開発ライフを送れるようにしていきたいです♪

Facebook

関連記事 | Related Posts

We are hiring!

【iOSエンジニア】モバイルアプリ開発G/大阪

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。

【iOS/Androidエンジニア】モバイルアプリ開発G/東京

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。