|
| 1 | +# ✍️ Stencil Templates |
| 2 | + |
| 3 | +Prefire generates both snapshot tests and Playbook models by rendering a [Stencil](https://stencil.fuller.li/) template with a context built from your project. The default templates cover the most common case, but every team has its own conventions — you can fully replace either template with your own `.stencil` file. |
| 4 | + |
| 5 | +This page describes the template engine, the variables available inside templates, the filters you can use, and how to wire a custom template into your project. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 1. Configuration |
| 10 | + |
| 11 | +Point Prefire at a custom template through `template_file_path` in `.prefire.yml`. The path is resolved relative to your target — `test_target_path` for the tests plugin, `target` for the playbook plugin. If the key is omitted, Prefire uses its built-in template. |
| 12 | + |
| 13 | +```yaml |
| 14 | +# .prefire.yml |
| 15 | + |
| 16 | +test_configuration: |
| 17 | + target: MyApp |
| 18 | + test_target_path: ${PROJECT_DIR}/MyAppTests |
| 19 | + test_file_path: MyAppTests/PreviewTests.generated.swift |
| 20 | + template_file_path: MyAppTests/CustomPreviewTests.stencil |
| 21 | + |
| 22 | +playbook_configuration: |
| 23 | + target: ${PROJECT_DIR}/MyApp |
| 24 | + template_file_path: MyApp/CustomPreviewModels.stencil |
| 25 | +``` |
| 26 | +
|
| 27 | +Notes: |
| 28 | +
|
| 29 | +- The path is relative to the resolved `test_target_path` / `target` directory, **not** to the location of `.prefire.yml`. |
| 30 | +- Both `test_configuration.template_file_path` and `playbook_configuration.template_file_path` are independent — you can override one and keep the default for the other. |
| 31 | +- The same `template_file_path` can be reused by multiple targets if the relative path resolves correctly from each. |
| 32 | + |
| 33 | +--- |
| 34 | + |
| 35 | +## 2. Template engine |
| 36 | + |
| 37 | +Prefire uses the Stencil templating language with a few extra filters and Swift-language extensions shipped by [Sourcery](https://github.com/SourceryProtocol/Sourcery). If you've written a SwiftGen or Sourcery template before, the syntax will feel familiar. |
| 38 | + |
| 39 | +- [Stencil syntax](https://stencil.fuller.li/) — `{% ... %}` for logic, `{{ ... }}` for output, `{# ... #}` for comments. |
| 40 | +- [Sourcery template extensions](https://github.com/SourceryProtocol/Sourcery/blob/master/Documentation/Templates.md) — additional filters and helpers for Swift code generation. |
| 41 | + |
| 42 | +The context rendered into your template has three top-level objects: `argument`, `types`, and (implicitly) the `previewsMacrosDict` array attached to `argument`. |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +## 3. Context reference |
| 47 | + |
| 48 | +### 3.1 `argument.*` — configuration values |
| 49 | + |
| 50 | +These keys are produced by `GenerateTestsCommand` and `GeneratePlaybookCommand` and are always available inside templates: |
| 51 | + |
| 52 | +| Key | Type | Plugin | Description | |
| 53 | +| ---------------------------------------- | ---------------------- | ------ | -------------------------------------------------------------------------------------------------------- | |
| 54 | +| `argument.imports` | `[String]` (or absent) | both | Extra `import` lines from `imports:` in the config. | |
| 55 | +| `argument.testableImports` | `[String]` (or absent) | both | Extra `@testable import` lines from `testable_imports:` in the config. | |
| 56 | +| `argument.mainTarget` | `String` (or absent) | tests | The `target:` value; emitted as `@testable import {{ argument.mainTarget }}`. | |
| 57 | +| `argument.file` | `String` (or absent) | tests | Resolved output path for the generated test file; used as the `file:` argument of `assertSnapshot`. | |
| 58 | +| `argument.simulatorDevice` | `String` (or absent) | tests | Device model identifier (e.g. `iPhone15,2`) from `simulator_device:`. | |
| 59 | +| `argument.simulatorOSVersion` | `String` (or absent) | tests | Major iOS version from `required_os:`. | |
| 60 | +| `argument.snapshotDevices` | `String` (or absent) | tests | Snapshot device names joined by `\|`. Iterate by applying `\|split:"\|"`. See §4. | |
| 61 | +| `argument.drawHierarchyInKeyWindowDefaultEnabled` | `String` `"true"`/`"false"` (or absent) | tests | Value of `draw_hierarchy_in_key_window_default_enabled:` as a string. | |
| 62 | +| `argument.previewsMacrosDict` | `[[String: Any]]` | both | Array of `#Preview` macro models. See §3.3. | |
| 63 | + |
| 64 | +`NSNull` values (i.e. when a config key is missing) should be guarded with `{% if argument.foo %}` — Stencil treats both `nil` and `NSNull` as falsy. |
| 65 | + |
| 66 | +### 3.2 `types.*` — `PreviewProvider`-based previews |
| 67 | + |
| 68 | +The `types` collection comes from Sourcery's `Types` wrapper around the parsed Swift AST. The default templates iterate over it to generate one `func test_*()` per `PreviewProvider`/`PrefireProvider` type: |
| 69 | + |
| 70 | +```stencil |
| 71 | +{% for type in types.types where type.implements.PrefireProvider or type.based.PrefireProvider or type|annotated:"PrefireProvider" %} |
| 72 | +func test_{{ type.name|lowerFirstLetter|replace:"_Previews", "" }}() { |
| 73 | + for preview in {{ type.name }}._allPreviews { |
| 74 | + // ... |
| 75 | + } |
| 76 | +} |
| 77 | +{% endfor %} |
| 78 | +``` |
| 79 | + |
| 80 | +Commonly used fields on a `type`: |
| 81 | + |
| 82 | +| Field | Description | |
| 83 | +| ----------- | ---------------------------------------------------------------- | |
| 84 | +| `name` | Fully-qualified type name. | |
| 85 | +| `implements.X` | `true` if the type conforms to protocol `X` (e.g. `PrefireProvider`). | |
| 86 | +| `based.X` | `true` if the type inherits from a class (or protocol) named `X`. | |
| 87 | +| `localName` | Short (unqualified) name of the type. | |
| 88 | + |
| 89 | +For the full Sourcery `Type` API, see the [Sourcery type reference](https://github.com/SourceryProtocol/Sourcery/blob/master/Documentation/Templates.md#types). |
| 90 | + |
| 91 | +### 3.3 `macroModel.*` — `#Preview` macro models |
| 92 | + |
| 93 | +When the source file contains `#Preview { ... }` blocks, each one is parsed into a `RawPreviewModel` and exposed as a dictionary inside `argument.previewsMacrosDict`. The default templates iterate over the array and emit one test per element: |
| 94 | + |
| 95 | +```stencil |
| 96 | +{% for macroModel in argument.previewsMacrosDict %} |
| 97 | +func test_{{ macroModel.componentTestName }}_Preview() { |
| 98 | + // ... |
| 99 | + {{ macroModel.body|indent:12 }} |
| 100 | + // ... |
| 101 | +} |
| 102 | +{% endfor %} |
| 103 | +``` |
| 104 | + |
| 105 | +Each element of `previewsMacrosDict` has the following keys (see `RawPreviewModel.makeStencilDict()`): |
| 106 | + |
| 107 | +| Key | Type | Description | |
| 108 | +| --------------------- | --------- | ----------------------------------------------------------------------------------------------------------------- | |
| 109 | +| `displayName` | `String` | Human-readable name of the preview, derived from the first string literal in the macro (e.g. `"Default" → Default`). | |
| 110 | +| `componentTestName` | `String` | Sanitized identifier (non-alphanumerics stripped). Safe to embed in Swift identifiers. | |
| 111 | +| `isScreen` | `Bool` | `true` if the preview contains the `.device` trait, `false` otherwise. | |
| 112 | +| `body` | `String` | The Swift body of the preview, as a raw string. Use `\|indent:N` to align it inside a generated closure. | |
| 113 | +| `properties` | `String?` | Captured `@Previewable` property declarations (joined with newlines), or `nil` if there are none. | |
| 114 | +| `traits` | `[String]`| Raw trait tokens, e.g. `["device"]`, `[".myTrait(\"x\")"]`. | |
| 115 | + |
| 116 | +--- |
| 117 | + |
| 118 | +## 4. Filters reference |
| 119 | + |
| 120 | +Only filters that the default templates actually use are listed below. For the complete set, see the [Stencil built-in filters](https://stencil.fuller.li/) and the [Sourcery template extensions](https://github.com/SourceryProtocol/Sourcery/blob/master/Documentation/Templates.md#filters). |
| 121 | + |
| 122 | +| Filter | Origin | Purpose | Example | |
| 123 | +| --------------------- | -------- | ----------------------------------------------------------------------- | -------------------------------------------------------------- | |
| 124 | +| `lowerFirstLetter` | Stencil | Lowercase the first character (for test function names). | `{{ type.name\|lowerFirstLetter }}` → `myView` from `MyView` | |
| 125 | +| `replace:OLD,NEW` | Stencil | Replace literal substring `OLD` with `NEW`. | `{{ type.name\|replace:"_Previews", "" }}` | |
| 126 | +| `split:SEP` | Sourcery | Split a string by `SEP` and emit a Swift array literal. | `{{ argument.snapshotDevices\|split:"\|" }}` → `["iPhone 14"]` | |
| 127 | +| `indent:N` | Stencil | Indent every line of the input by `N` spaces. | `{{ macroModel.body\|indent:12 }}` | |
| 128 | +| `default:VALUE` | Stencil | Use `VALUE` when the variable is missing. | `{{ argument.simulatorDevice\|default:nil }}` | |
| 129 | +| `annotated:NAME` | Sourcery | Inside a `{% for type in types.types %}`, filter types annotated with `NAME`. | `{% for type in types.types where type\|annotated:"PrefireProvider" %}` | |
| 130 | +| `forloop.last` | Stencil | `true` on the last iteration of a `{% for %}` loop. Useful for separators. | `{%- if not forloop.last %}\n\n{% endif %}` | |
| 131 | + |
| 132 | +--- |
| 133 | + |
| 134 | +## 5. Default templates |
| 135 | + |
| 136 | +The built-in templates are the most useful starting point — copy them, change what you need, and reference the new file from `.prefire.yml`. |
| 137 | + |
| 138 | +- **Tests template** — `EmbeddedTemplates.previewTests` in [`PrefireExecutable/Sources/PrefireCore/Templates/PreviewTestsTemplate.swift`](../PrefireExecutable/Sources/PrefireCore/Templates/PreviewTestsTemplate.swift) |
| 139 | +- **Playbook template** — `EmbeddedTemplates.previewModels` in [`PrefireExecutable/Sources/PrefireCore/Templates/PreviewModelsTemplates.swift`](../PrefireExecutable/Sources/PrefireCore/Templates/PreviewModelsTemplates.swift) |
| 140 | + |
| 141 | +Key blocks worth studying: |
| 142 | + |
| 143 | +- **Class-name placeholder** — the tests template references `{PREVIEW_FILE_NAME}` so the same file works for both `use_grouped_snapshots: true` and `use_grouped_snapshots: false` modes: |
| 144 | + ```stencil |
| 145 | + @MainActor class {PREVIEW_FILE_NAME}Tests: XCTestCase, Sendable { |
| 146 | + ``` |
| 147 | + When `use_grouped_snapshots: false`, Prefire replaces `{PREVIEW_FILE_NAME}` in **both** the output path and the template body with the source-file name before rendering. The same placeholder is also valid in `test_file_path`. |
| 148 | +- **`snapshotDevices` array** — the config stores a pipe-joined string; the template splits it back into a Swift array: |
| 149 | + ```stencil |
| 150 | + private let snapshotDevices: [String]{% if argument.snapshotDevices %} = {{ argument.snapshotDevices|split:"|" }}{% else %} = []{% endif %} |
| 151 | + ``` |
| 152 | +- **Per-device iteration** — guarded by an empty-check, so a single test runs once on the default device and once per `snapshot_devices` entry: |
| 153 | + ```stencil |
| 154 | + {% if argument.file %} |
| 155 | + private var file: StaticString { .init(stringLiteral: "{{ argument.file }}") } |
| 156 | + {% endif %} |
| 157 | + ``` |
| 158 | +- **`@Previewable` properties** — captured as raw Swift source and embedded verbatim into a generated `PreviewWrapper` view: |
| 159 | + ```stencil |
| 160 | + {% if macroModel.properties %} |
| 161 | + struct PreviewWrapper{{ macroModel.componentTestName }}: SwiftUI.View { |
| 162 | + {{ macroModel.properties }} |
| 163 | + var body: some View { |
| 164 | + {{ macroModel.body|indent:12 }} |
| 165 | + } |
| 166 | + } |
| 167 | + {% endif %} |
| 168 | + ``` |
| 169 | +- **Macro/preview-provider coexistence** — the `{% if argument.previewsMacrosDict %}` guard is required; an empty array still produces a `for` block, but the guard skips it cleanly when there are no `#Preview` macros in the file. |
| 170 | + |
| 171 | +--- |
| 172 | + |
| 173 | +## 6. Worked example |
| 174 | + |
| 175 | +Suppose you don't use AccessibilitySnapshot and you want a leaner tests file. Save the following as `MyAppTests/MinimalPreviewTests.stencil`: |
| 176 | + |
| 177 | +```stencil |
| 178 | +// swiftlint:disable all |
| 179 | +// swiftformat:disable all |
| 180 | +
|
| 181 | +import XCTest |
| 182 | +import SwiftUI |
| 183 | +import Prefire |
| 184 | +{% for import in argument.imports %} |
| 185 | +import {{ import }} |
| 186 | +{% endfor %} |
| 187 | +{% if argument.mainTarget %} |
| 188 | +@testable import {{ argument.mainTarget }} |
| 189 | +{% endif %} |
| 190 | +{% for import in argument.testableImports %} |
| 191 | +@testable import {{ import }} |
| 192 | +{% endfor %} |
| 193 | +import SnapshotTesting |
| 194 | +
|
| 195 | +@MainActor class {PREVIEW_FILE_NAME}Tests: XCTestCase { |
| 196 | + private let deviceConfig: DeviceConfig = ViewImageConfig.iPhoneX.deviceConfig |
| 197 | +
|
| 198 | + {% if argument.previewsMacrosDict %} |
| 199 | + {% for macroModel in argument.previewsMacrosDict %} |
| 200 | + func test_{{ macroModel.componentTestName }}_Preview() { |
| 201 | + let prefireSnapshot = PrefireSnapshot( |
| 202 | + { |
| 203 | + {{ macroModel.body|indent:12 }} |
| 204 | + }, |
| 205 | + name: "{{ macroModel.displayName }}", |
| 206 | + isScreen: {% if macroModel.isScreen == 1 %}true{% else %}false{% endif %}, |
| 207 | + device: deviceConfig |
| 208 | + ) |
| 209 | +
|
| 210 | + if let failure = assertSnapshot(of: prefireSnapshot.loadViewWithPreferences().0, as: .image) { |
| 211 | + XCTFail(failure) |
| 212 | + } |
| 213 | + } |
| 214 | + {% endfor %} |
| 215 | + {% endif %} |
| 216 | +} |
| 217 | +``` |
| 218 | + |
| 219 | +Wire it up in `.prefire.yml`: |
| 220 | + |
| 221 | +```yaml |
| 222 | +test_configuration: |
| 223 | + target: MyApp |
| 224 | + test_target_path: ${PROJECT_DIR}/MyAppTests |
| 225 | + test_file_path: MyAppTests/PreviewTests.generated.swift |
| 226 | + template_file_path: MyAppTests/MinimalPreviewTests.stencil |
| 227 | + imports: |
| 228 | + - UIKit |
| 229 | +``` |
| 230 | + |
| 231 | +For a preview like: |
| 232 | + |
| 233 | +```swift |
| 234 | +#Preview("Default") { |
| 235 | + Button("Submit") { } |
| 236 | +} |
| 237 | +``` |
| 238 | + |
| 239 | +Prefire will produce (skeleton): |
| 240 | + |
| 241 | +```swift |
| 242 | +import XCTest |
| 243 | +import SwiftUI |
| 244 | +import Prefire |
| 245 | +import UIKit |
| 246 | +@testable import MyApp |
| 247 | +import SnapshotTesting |
| 248 | +
|
| 249 | +@MainActor class PreviewTests: XCTestCase { |
| 250 | + private let deviceConfig: DeviceConfig = ViewImageConfig.iPhoneX.deviceConfig |
| 251 | +
|
| 252 | + func test_Default_Preview() { |
| 253 | + let prefireSnapshot = PrefireSnapshot( |
| 254 | + { |
| 255 | + Button("Submit") { } |
| 256 | + }, |
| 257 | + name: "Default", |
| 258 | + isScreen: false, |
| 259 | + device: deviceConfig |
| 260 | + ) |
| 261 | +
|
| 262 | + if let failure = assertSnapshot(of: prefireSnapshot.loadViewWithPreferences().0, as: .image) { |
| 263 | + XCTFail(failure) |
| 264 | + } |
| 265 | + } |
| 266 | +} |
| 267 | +``` |
| 268 | + |
| 269 | +--- |
| 270 | + |
| 271 | +## 7. Caveats and tips |
| 272 | + |
| 273 | +- **Required imports** are not auto-added. For tests you almost always need `XCTest`, `SwiftUI`, `Prefire`, and `SnapshotTesting`; for playbook you need `SwiftUI` and `Prefire`. Add them at the top of your template literally. |
| 274 | +- **`{PREVIEW_FILE_NAME}` is a real placeholder.** It is replaced in the template string *and* in `test_file_path` *only* when `use_grouped_snapshots: false`. The class declared in your template must be named `{PREVIEW_FILE_NAME}Tests`, otherwise the generated file won't compile. |
| 275 | +- **`snapshotDevices` is a pipe-joined string**, not an array. Use `|split:"|"`. Forgetting the filter produces `snapshotDevices = iPhone 14|iPad` in the generated file. |
| 276 | +- **Guard `previewsMacrosDict`.** An empty array will still produce a (broken) `for` block. Always wrap the macro loop in `{% if argument.previewsMacrosDict %}`. |
| 277 | +- **Cache invalidation.** `PrefireCacheManager` keys the cache on source content. When you change a template, delete `~/.prefire-cache/` to force a full re-render. |
| 278 | +- **`isScreen` is a `Bool` but Stencil may serialize it as `1`/`0`.** Compare with `{% if macroModel.isScreen == 1 %}` rather than `{% if macroModel.isScreen %}`, as in the default template. |
| 279 | +- **Stuck on a syntax error?** Run `prefire tests` once with the default template and look at the generated file — it's the fastest way to see which context keys actually have values for your project. |
0 commit comments