anz blog

SwiftGenで生成されたコードのテストを自動生成する

2021-04-30 #Swift

SwiftGen 使っていますか?便利ですよね。
xcassets で設定したものをコードで利用するときにとても助かるので重宝しています。
そんな SwiftGen 関連の話です。

SwiftGen で生成されるコードがちゃんと利用できるかテストしたい

見出しのとおりなのですが、どうしようかなと考えた結果 SwiftGen のカスタムテンプレート機能をつかって生成させてみました。
そのテンプレートがこちらです。

SwiftGen は stensil というテンプレートエンジンを採用しているらしいので、その書き方に則って書く形になります。
詳しいことはこちらのドキュメントなんかを参照してもらえるといいかと思います。

今回のは短いですしちゃんとは理解してないのですが、少しだけ説明をすると...

{% macro allValuesBlock assets prefix %}
  {% for asset in assets %}
    {% if asset.items %}
      {% set newPrefix %}{{prefix}}{{asset.name|swiftIdentifier:"pretty"|escapeReservedKeywords}}.{% endset %}
      {% call allValuesBlock asset.items newPrefix %}
    {% elif asset.type != "group" %}
      _ = Asset.{{prefix}}{{asset.name|swiftIdentifier:"pretty"|lowerFirstWord}}.{{asset.type}}
    {% endif %}
  {% endfor %}
{% endmacro %}

この部分が stencil でのメソッド定義みたいなもので、assets を受け取って、それを回して実際のテストコードを出力するみたいなことしています。
なので、下の方で {% call allValuesBlock catalog.assets "" %} とすることで先程のやつを呼び出してここにテストコードが吐かれるという感じです。
catalogs というのは SwiftGen が用意して渡してくれるものです。他にどんなものがあるかは先に紹介したドキュメントのとこをたどっていくと記載があります。
(input が xcassets の場合はこんな感じです)

あとはこれを swiftgen.yml の方で使えるように指定をしてあげたら、SwiftGen が差分を検出してコードを生成するついでに、テストコードも自動生成してくれるということが実現できます。

なぜこんなことを?

SwiftGen を信用していないとかそういうことではなく(笑)、
生成されるコードって別にプロテクトされてるわけでもなんでも無いので、意図していない編集を加えてしまうことがなくはないのです。
たとえば、キーボードのミスタッチでたまたま name を指定してるとこにスペースが混入してしまうとか。
この場合どうなるかわかるでしょうか?
ビルドはすんなり通ります。ただ該当の物を使用するところで実行時エラーとなりアプリはクラッシュします。
(生成されたコード的には存在しない name の場合は fatalerror を呼ぶようになっている)

もちろん各々レビューなりQAなりがあるだろうから、そのままそれが出ていくことは無いとは思いますが、機械的に潰せるなら潰したくなりますよね。
(HotFix とかで例外的な手順で急いでるときとか何かしら起こりがちですし)

普通に書いたらいいのでは?

わざわざ stencil を利用して自動生成しなくても普通に書いたらいいのでは?...

最初は僕も普通に手動で生成しようと思っていました。確か enum で生成されていたので、普通にそれをぶん回して書けばよさそうだなぁと。
ただ生成されたコードをよくみてみると、大枠は enum として定義されてるものそれぞれはそれに列挙されているというものではなかったですし、そもそも CaseIterable もついていなかったので、無理じゃないですか...となったわけです。

internal enum Asset {
  internal enum Colors {
    internal static let primary = ColorAsset(name: "Colors/primary")
  }
  internal enum Icons {
    internal static let xmarkCircle = ImageAsset(name: "Icons/XmarkCircle")
    internal static let safariOutline = ImageAsset(name: "Icons/safari_outline")
  }
}

(オリジナルのテンプレートを使ってない場合は、こういう感じで書かれているはず)

流石に手動で全部を書いていくのはだるすぎますし、画像とかColorとか追加したときに漏れに漏れる未来が容易に想像できます。

実際に使ったときのイメージ

プロジェクトにこういう assets を用意しました。

Assets.xcassets

そして swiftgen.yml はこういう感じです。

xcassets:
  inputs:
    x-sandbox/Assets.xcassets
  outputs:
    - templateName: swift5
      output: x-sandbox/Assets.swift
    - templatePath: swiftgen_template.stencil
      output: x-sandboxTests/AssetTests.swift

x-sandbox とかはプロジェクト名なので、適当に読み替えてください。
outputs の1つ目( templateName: swift5 )が普通の利用するためのコード生成させる設定です。
そのしたのもの( templatePath: swiftgen_template.stencil )が今回のテストコードを生成させるための設定です。
swiftgen_template.stencil 任意に設定して置き場所もお好きなところにおいてください。こちらの設定と齟齬がなければOKです。

そしてこれが実行されて生成されるテストがこちらです。

//
//  Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen
//
import XCTest

@testable import x_sandbox

class AssetTests: XCTestCase {
    func testAssetsAreAvailable() throws {
      _ = Asset.Colors.primary.color
      _ = Asset.Icons.xmarkCircle.image
      _ = Asset.Icons.safariOutline.image
    }
}

ちゃんと color と image で分けられて書かれています。
あとはちゃんとテスト実行できるように生成されたファイルをUnitTestの方に組み込んでください(初回だけの手順)。
これで予期せぬ変更とか、何らかの理由でファイルや設定が見つからないとなってしまっても、テストさせ実行できていれば気づくことができます。

余談

やり終わったあとに思ったのですが、SwiftGen によって生成されるコードをプロジェクトには含めるけどコミットに含めないのも手だなと。
そうすると意図せぬ編集を加えて気づかないままコミットされちゃうというのは防げるので、今回の対応がいらなくなる。(あってもいいけど 笑)
clone してきてビルドすると生成されるように設定したらいいですし...。
もしかして含めないのが普通??🤔
(Cocoapodsとかで生成物を含めないのと同じイメージ)

参考