@zaunt/zest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +590 -0
- package/dist/index.d.mts +187 -0
- package/dist/index.mjs +118 -0
- package/dist/quick-reference-BHuN8iCv.mjs +419 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
# @zaunt/zest
|
|
2
|
+
|
|
3
|
+
A scenario-based testing framework for TypeScript. Write tests as natural-language Markdown with embedded facts and tables, and let Zest extract, parse, and assert them for you.
|
|
4
|
+
|
|
5
|
+
Zest produces colour-coded HTML reports showing which facts passed, failed, or weren't checked.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install --save-dev @zaunt/zest
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Zest currently integrates with [Vitest](https://vitest.dev/). You'll need Vitest installed as well.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import {scenario, eachRow, stringCodec, intCodec} from '@zaunt/zest';
|
|
19
|
+
|
|
20
|
+
scenario({
|
|
21
|
+
markdown: 'When made uppercase `fred` becomes `FRED`.',
|
|
22
|
+
execute({facts: [input]}) {
|
|
23
|
+
return input.asString().toUpperCase();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Zest extracts `fred` and `FRED` as facts, passes them via the `execute` argument, and compares the return value against the last fact.
|
|
29
|
+
|
|
30
|
+
## Core concepts
|
|
31
|
+
|
|
32
|
+
### Exhibits, facts, and tables
|
|
33
|
+
|
|
34
|
+
Zest extracts **exhibits** from the Markdown in document order. An exhibit is either a `Fact` or a `Table`.
|
|
35
|
+
|
|
36
|
+
**Facts** (`kind: 'fact'`) come from:
|
|
37
|
+
|
|
38
|
+
- Backtick-wrapped text: `` `value` ``
|
|
39
|
+
- Bold text: `**value**`
|
|
40
|
+
- Italic text: `*value*`
|
|
41
|
+
- Fenced code blocks (with or without a language tag)
|
|
42
|
+
- Indented code blocks
|
|
43
|
+
|
|
44
|
+
All of these produce a single string value. Leading and trailing blank lines are trimmed, but internal whitespace is preserved. Code block facts have a `metadata` property: `{ language: 'ts' }` for fenced blocks with a language tag, or `{}` otherwise. Non-code-block facts always have `metadata: {}`.
|
|
45
|
+
|
|
46
|
+
**Tables** (`kind: 'table'`) come from GFM Markdown tables. Cell content is plain text — backticks, bold, and italic inside cells don't produce separate facts.
|
|
47
|
+
|
|
48
|
+
**Restrictions:** Nested facts (e.g. ``**`value`**``) throw an error. Lists (bulleted or numbered) throw an error; they're reserved for future use.
|
|
49
|
+
|
|
50
|
+
### Codecs
|
|
51
|
+
|
|
52
|
+
A codec converts between strings and typed values. Zest provides several built-in codecs:
|
|
53
|
+
|
|
54
|
+
| Codec | Description |
|
|
55
|
+
| ----------------------------------------------- | -------------------------------------------- |
|
|
56
|
+
| `stringCodec` | Identity — no conversion |
|
|
57
|
+
| `intCodec` | Parses/formats integers |
|
|
58
|
+
| `floatCodec(fractionDigits?)` | Parses/formats floating-point numbers |
|
|
59
|
+
| `booleanCodec(truthy?, falsy?, ignoreCase?)` | Defaults to `'yes'`/`'no'`, case-insensitive |
|
|
60
|
+
| `enumCodec(values, ignoreCase?)` | Matches against a set of string literals |
|
|
61
|
+
| `mappedCodec(parseMap, formatMap, ignoreCase?)` | Arbitrary string↔value mapping |
|
|
62
|
+
|
|
63
|
+
Use codecs to read typed values from facts:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
fact.asString(); // string
|
|
67
|
+
fact.asInt(); // number (integer)
|
|
68
|
+
fact.as(floatCodec(2)); // number (float)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Writing scenarios
|
|
72
|
+
|
|
73
|
+
### The `execute` function
|
|
74
|
+
|
|
75
|
+
The `execute` function receives a `Context` object with three properties, all in document order:
|
|
76
|
+
|
|
77
|
+
- `facts: FactList` — all facts extracted from the Markdown.
|
|
78
|
+
- `tables: Table[]` — all tables extracted from the Markdown.
|
|
79
|
+
- `exhibits: Exhibit[]` — all exhibits (facts and tables interleaved).
|
|
80
|
+
|
|
81
|
+
These are views over the same underlying objects, not copies.
|
|
82
|
+
|
|
83
|
+
`FactList` is iterable and supports destructuring, so you can write `execute({facts: [a, b]})` for simple cases. For scenarios with many facts, it also supports indexed access and language-based filtering (see below).
|
|
84
|
+
|
|
85
|
+
### Accessing facts by section
|
|
86
|
+
|
|
87
|
+
When a scenario has multiple sections under different headings, positional fact indices can become hard to follow. The `Context` object's `section` method lets you scope into a heading's content instead.
|
|
88
|
+
|
|
89
|
+
A section is everything underneath a heading up to the next heading at the same level or higher. The returned object is itself a `Context`, with its own `facts`, `tables`, `exhibits`, and `section` method — so you can drill down through nested headings.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
scenario({
|
|
93
|
+
name: 'createProdViteAssetTagsService',
|
|
94
|
+
markdown: `
|
|
95
|
+
# createProdViteAssetTagsService()
|
|
96
|
+
|
|
97
|
+
Given this manifest:
|
|
98
|
+
|
|
99
|
+
\`\`\`json
|
|
100
|
+
{ "src/app.ts": { "file": "js/app.js", "css": ["css/app.css"] } }
|
|
101
|
+
\`\`\`
|
|
102
|
+
|
|
103
|
+
## Entry with CSS
|
|
104
|
+
|
|
105
|
+
Requesting entry \`src/app.ts\` produces:
|
|
106
|
+
|
|
107
|
+
\`\`\`html
|
|
108
|
+
<link rel="stylesheet" href="/css/app.css" />
|
|
109
|
+
<script type="module" src="/js/app.js"></script>
|
|
110
|
+
\`\`\`
|
|
111
|
+
|
|
112
|
+
## Custom base path
|
|
113
|
+
|
|
114
|
+
Using base path \`/static/\` for entry \`src/app.ts\` produces:
|
|
115
|
+
|
|
116
|
+
\`\`\`html
|
|
117
|
+
<script type="module" src="/static/js/app.js"></script>
|
|
118
|
+
\`\`\`
|
|
119
|
+
`,
|
|
120
|
+
async execute(context) {
|
|
121
|
+
const manifest = JSON.parse(context.facts.json(0).asString());
|
|
122
|
+
const defaultService = createProdViteAssetTagsService(manifest);
|
|
123
|
+
|
|
124
|
+
const {
|
|
125
|
+
facts: [entry, expected]
|
|
126
|
+
} = context.section('Entry with CSS');
|
|
127
|
+
expected.assertEquals(await defaultService(entry.asString()));
|
|
128
|
+
|
|
129
|
+
const {
|
|
130
|
+
facts: [basePath, customEntry, customExpected]
|
|
131
|
+
} = context.section('Custom base path');
|
|
132
|
+
const customService = createProdViteAssetTagsService(
|
|
133
|
+
manifest,
|
|
134
|
+
basePath.asString()
|
|
135
|
+
);
|
|
136
|
+
customExpected.assertEquals(await customService(customEntry.asString()));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`section` accepts either an exact string or a regular expression. It throws if no heading matches, or if multiple headings match — there must be exactly one unambiguous match.
|
|
142
|
+
|
|
143
|
+
#### Discovering sections programmatically
|
|
144
|
+
|
|
145
|
+
`Context` exposes two properties for inspecting available sections:
|
|
146
|
+
|
|
147
|
+
- `sectionNames` — the heading texts of the immediate child sections within the current scope. A heading is an "immediate child" if no shallower heading (ignoring `h1`) sits between the scope start and that heading. For example, if `### Foo` appears before any `##` heading, both are immediate children of the root scope.
|
|
148
|
+
- `allSectionNames` — all descendant headings within the scope (excluding `h1`), in document order, each with its `name` and `level`.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
scenario({
|
|
152
|
+
markdown: `
|
|
153
|
+
# My service
|
|
154
|
+
|
|
155
|
+
### Setup
|
|
156
|
+
\`config-value\`
|
|
157
|
+
|
|
158
|
+
## Addition
|
|
159
|
+
Input: \`3\`, output: \`6\`
|
|
160
|
+
|
|
161
|
+
## Subtraction
|
|
162
|
+
Input: \`10\`, output: \`7\`
|
|
163
|
+
|
|
164
|
+
### Edge case
|
|
165
|
+
Input: \`0\`, output: \`-3\`
|
|
166
|
+
`,
|
|
167
|
+
execute(context) {
|
|
168
|
+
// context.sectionNames → ['Setup', 'Addition', 'Subtraction']
|
|
169
|
+
// context.allSectionNames → [
|
|
170
|
+
// { name: 'Setup', level: 3 },
|
|
171
|
+
// { name: 'Addition', level: 2 },
|
|
172
|
+
// { name: 'Subtraction', level: 2 },
|
|
173
|
+
// { name: 'Edge case', level: 3 },
|
|
174
|
+
// ]
|
|
175
|
+
|
|
176
|
+
const sub = context.section('Subtraction');
|
|
177
|
+
// sub.sectionNames → ['Edge case']
|
|
178
|
+
// sub.facts contains the facts from both '## Subtraction' and '### Edge case'
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Accessing facts by language
|
|
184
|
+
|
|
185
|
+
When your Markdown contains several code blocks with language tags, positional destructuring can become unwieldy. `FactList` lets you filter by language instead:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
scenario({
|
|
189
|
+
markdown: `
|
|
190
|
+
Given this config:
|
|
191
|
+
|
|
192
|
+
\`\`\`json
|
|
193
|
+
{"port": 3000}
|
|
194
|
+
\`\`\`
|
|
195
|
+
|
|
196
|
+
It produces:
|
|
197
|
+
|
|
198
|
+
\`\`\`yaml
|
|
199
|
+
port: 3000
|
|
200
|
+
\`\`\`
|
|
201
|
+
`,
|
|
202
|
+
execute({facts}) {
|
|
203
|
+
const config = JSON.parse(facts.json(0).asString());
|
|
204
|
+
facts.byLanguage('yaml', 0).assertEquals(toYaml(config));
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`facts.json(0)` returns the first JSON code block, `facts.html(2)` returns the third HTML block, and so on. You can get facts by their language tag, using e.g. `facts.byLanguage('python', 0)`.
|
|
210
|
+
|
|
211
|
+
These methods throw if the requested block doesn't exist, with a message indicating how many blocks of that language were found.
|
|
212
|
+
|
|
213
|
+
### Simple assertion by return value
|
|
214
|
+
|
|
215
|
+
If `execute` returns a non-undefined value, Zest compares it to the **last fact** using the `result` codec (which defaults to `stringCodec`).
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
scenario({
|
|
219
|
+
markdown: 'The result of `3` + `4` is `7`.',
|
|
220
|
+
result: intCodec,
|
|
221
|
+
execute({facts: [a, b]}) {
|
|
222
|
+
return a.asInt() + b.asInt();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Manual assertions
|
|
228
|
+
|
|
229
|
+
Instead of returning a result, you can assert facts manually using `assertEquals`.
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
scenario({
|
|
233
|
+
name: 'Boolean negation',
|
|
234
|
+
markdown: 'The input is `Aye` the output is `Nay`.',
|
|
235
|
+
execute({facts: [input, output]}) {
|
|
236
|
+
const codec = booleanCodec('Aye', 'Nay');
|
|
237
|
+
output.assertEquals(!input.as(codec), codec);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Each fact can only be asserted once.
|
|
243
|
+
|
|
244
|
+
### Tables
|
|
245
|
+
|
|
246
|
+
Tables in the Markdown are available via the `tables` array.
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
scenario({
|
|
250
|
+
markdown: `
|
|
251
|
+
Input | Result
|
|
252
|
+
--------|--------
|
|
253
|
+
foo | FOO
|
|
254
|
+
bar | BAR
|
|
255
|
+
`,
|
|
256
|
+
execute({tables: [table]}) {
|
|
257
|
+
for (const row of table) {
|
|
258
|
+
row.fact('Result').assertEquals(row.value('Input').toUpperCase());
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Multiple tables and inline facts
|
|
265
|
+
|
|
266
|
+
Tables and inline facts are interleaved in document order. You can mix setup data, test cases, and assertions freely.
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
scenario({
|
|
270
|
+
markdown: `
|
|
271
|
+
Name | Age
|
|
272
|
+
-------|------
|
|
273
|
+
Fred | 35
|
|
274
|
+
Wilma | 33
|
|
275
|
+
|
|
276
|
+
Input | Expected
|
|
277
|
+
-----------|----------
|
|
278
|
+
fred | FRED
|
|
279
|
+
wilma | WILMA
|
|
280
|
+
|
|
281
|
+
The author is *Betty*.
|
|
282
|
+
`,
|
|
283
|
+
execute({facts: [author], tables: [people, testCases]}) {
|
|
284
|
+
const map = people.toMap({Age: intCodec});
|
|
285
|
+
// map = { "Fred": 35, "Wilma": 33 }
|
|
286
|
+
|
|
287
|
+
for (const row of testCases) {
|
|
288
|
+
row.fact('Expected').assertEquals(row.value('Input').toUpperCase());
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
author.assertEquals('Betty');
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Code block facts
|
|
297
|
+
|
|
298
|
+
Code blocks are treated as single-string facts, not parsed for tables or other exhibits.
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
scenario({
|
|
302
|
+
markdown: `
|
|
303
|
+
The template produces:
|
|
304
|
+
|
|
305
|
+
\`\`\`html
|
|
306
|
+
<div>
|
|
307
|
+
<p>Hello</p>
|
|
308
|
+
</div>
|
|
309
|
+
\`\`\`
|
|
310
|
+
`,
|
|
311
|
+
execute({facts: [output]}) {
|
|
312
|
+
// output.metadata is { language: 'html' }
|
|
313
|
+
return renderTemplate();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## API reference
|
|
319
|
+
|
|
320
|
+
### `Context`
|
|
321
|
+
|
|
322
|
+
The `execute` function receives a `Context` object. It has the same `facts`, `tables`, and `exhibits` properties as previous versions' `ExecuteArgs`, so all existing destructuring patterns continue to work.
|
|
323
|
+
|
|
324
|
+
| Method / Property | Description |
|
|
325
|
+
| ------------------------- | ----------------------------------------------------------- |
|
|
326
|
+
| `context.facts` | `FactList` — all facts in the current scope |
|
|
327
|
+
| `context.tables` | `Table[]` — all tables in the current scope |
|
|
328
|
+
| `context.exhibits` | `Exhibit[]` — all exhibits in the current scope |
|
|
329
|
+
| `context.section(name)` | Returns a `Context` scoped to the named section |
|
|
330
|
+
| `context.section(regex)` | Same, but matches heading text against a regular expression |
|
|
331
|
+
| `context.sectionNames` | `string[]` — heading texts of the immediate child sections |
|
|
332
|
+
| `context.allSectionNames` | `{name: string, level: number}[]` — all descendant headings |
|
|
333
|
+
|
|
334
|
+
`section` throws if no heading matches or if multiple headings match. Heading level 1 is ignored for section scoping (there should be at most one, covering the entire scenario).
|
|
335
|
+
|
|
336
|
+
### `FactList`
|
|
337
|
+
|
|
338
|
+
| Method / Property | Description |
|
|
339
|
+
| --------------------------- | -------------------------------------------------- |
|
|
340
|
+
| `facts.length` | Number of facts |
|
|
341
|
+
| `facts.at(index)` | Fact by 0-based index (throws if out of bounds) |
|
|
342
|
+
| `facts.byLanguage(lang, n)` | The *n*th code block with the given language tag |
|
|
343
|
+
| `facts.json(n)` | Shorthand for `byLanguage('json', n)` |
|
|
344
|
+
| `facts.html(n)` | Shorthand for `byLanguage('html', n)` |
|
|
345
|
+
| `facts.css(n)` | Shorthand for `byLanguage('css', n)` |
|
|
346
|
+
| `facts.js(n)` | Shorthand for `byLanguage('js', n)` |
|
|
347
|
+
| `facts.ts(n)` | Shorthand for `byLanguage('ts', n)` |
|
|
348
|
+
| `facts.xml(n)` | Shorthand for `byLanguage('xml', n)` |
|
|
349
|
+
| `facts.sql(n)` | Shorthand for `byLanguage('sql', n)` |
|
|
350
|
+
| `for (const f of facts)` | Iterable — works with destructuring and `for...of` |
|
|
351
|
+
|
|
352
|
+
`FactList` supports destructuring (e.g. `{facts: [a, b]}`).
|
|
353
|
+
|
|
354
|
+
### `Fact`
|
|
355
|
+
|
|
356
|
+
| Method / Property | Description |
|
|
357
|
+
| ---------------------------------- | ---------------------------------------------- |
|
|
358
|
+
| `fact.asString()` | Returns the raw string value |
|
|
359
|
+
| `fact.asInt()` | Parses as integer |
|
|
360
|
+
| `fact.as(codec)` | Parses with a codec |
|
|
361
|
+
| `fact.assertEquals(actual)` | Asserts equality using `stringCodec` |
|
|
362
|
+
| `fact.assertEquals(actual, codec)` | Asserts equality using the given codec |
|
|
363
|
+
| `fact.metadata` | `{}` or `{ language: string }` for code blocks |
|
|
364
|
+
| `fact.kind` | Always `'fact'` |
|
|
365
|
+
|
|
366
|
+
### `Table`
|
|
367
|
+
|
|
368
|
+
| Method / Property | Description |
|
|
369
|
+
| --------------------------------- | ---------------------------------------------------------------------------------- |
|
|
370
|
+
| `table.kind` | Always `'table'` |
|
|
371
|
+
| `table.headers` | Column names |
|
|
372
|
+
| `table.rowCount` | Number of body rows |
|
|
373
|
+
| `table.columnCount` | Number of columns |
|
|
374
|
+
| `table.row(index)` | Body row by 0-based index, returns `Row` |
|
|
375
|
+
| `table.cell(col, row)` | Fact by column and row index (row 0 = headers) |
|
|
376
|
+
| `for (const row of table)` | Iterate body rows |
|
|
377
|
+
| `table.toMap()` | 2-column → `Record<string, string>`, 3+ → `Record<string, Record<string, string>>` |
|
|
378
|
+
| `table.toMap({ Col: codec })` | Same but with codecs applied to named columns |
|
|
379
|
+
| `table.toRecords()` | Array of `Record<string, string>` |
|
|
380
|
+
| `table.toRecords({ Col: codec })` | Array of `Record<string, unknown>` with codecs applied |
|
|
381
|
+
| `table.eachRow(execute)` | Run a callback for every body row (see below) |
|
|
382
|
+
| `table.eachRow(options)` | Same, with additional options (see below) |
|
|
383
|
+
|
|
384
|
+
The first column is always the key for `toMap`. Duplicate keys throw an error.
|
|
385
|
+
|
|
386
|
+
### `table.eachRow`
|
|
387
|
+
|
|
388
|
+
Runs a callback for every body row of a table. Each column becomes a positional `Fact` argument, just like the top-level `eachRow`. Returns a `Promise`, so you must `await` it inside a `scenario` execute function.
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
// Simple form — just a callback
|
|
392
|
+
await table.eachRow((input, expected) => {
|
|
393
|
+
expected.assertEquals(input.asString().toUpperCase());
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Options form
|
|
397
|
+
await table.eachRow({
|
|
398
|
+
expectedColumn: 'Sum',
|
|
399
|
+
codecs: {A: intCodec, B: intCodec, Sum: intCodec},
|
|
400
|
+
result: intCodec,
|
|
401
|
+
failFast: false,
|
|
402
|
+
execute(a, b) {
|
|
403
|
+
return a.asInt() + b.asInt();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
If `execute` returns a non-undefined value, it's compared against the expected column (the last column by default). You can also assert manually instead of returning.
|
|
409
|
+
|
|
410
|
+
When `failFast` is false (the default, falling back to the global config), all rows are processed before errors are surfaced. One failure is rethrown directly. Multiple failures produce a summary with the count and first error's details.
|
|
411
|
+
|
|
412
|
+
### `Row`
|
|
413
|
+
|
|
414
|
+
| Method / Property | Description |
|
|
415
|
+
| ------------------------- | ------------------------------- |
|
|
416
|
+
| `row.value('Col')` | String value |
|
|
417
|
+
| `row.value('Col', codec)` | Parsed value |
|
|
418
|
+
| `row.fact('Col')` | Returns `Fact` (for assertions) |
|
|
419
|
+
|
|
420
|
+
## Data-driven tests with `eachRow`
|
|
421
|
+
|
|
422
|
+
The top-level `eachRow` is a standalone function that parses a Markdown string, finds the first table, and runs `execute` for every row. Each column becomes a positional `Fact` argument. It registers a Vitest test directly.
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
eachRow({
|
|
426
|
+
name: 'uppercasing',
|
|
427
|
+
markdown: `
|
|
428
|
+
Input | Expected Output
|
|
429
|
+
-----------|----------------
|
|
430
|
+
a | A
|
|
431
|
+
abc | ABC
|
|
432
|
+
An example | AN EXAMPLE
|
|
433
|
+
`,
|
|
434
|
+
execute(input) {
|
|
435
|
+
return input.asString().toUpperCase();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
If `execute` returns a value, it's compared against the expected column (the last column by default). You can specify a different expected column, per-column codecs, and a result codec:
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
eachRow({
|
|
444
|
+
markdown: `
|
|
445
|
+
A | B | Sum | Notes
|
|
446
|
+
----|-----|-----|------
|
|
447
|
+
1 | 2 | 3 | simple
|
|
448
|
+
10 | 20 | 30 | tens
|
|
449
|
+
`,
|
|
450
|
+
expectedColumn: 'Sum',
|
|
451
|
+
codecs: {A: intCodec, B: intCodec, Sum: intCodec},
|
|
452
|
+
result: intCodec,
|
|
453
|
+
execute(a, b) {
|
|
454
|
+
return a.asInt() + b.asInt();
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
You can also assert manually instead of returning:
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
execute(input, expected) {
|
|
463
|
+
expected.assertEquals(input.asString().toUpperCase());
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
## A more involved example
|
|
468
|
+
|
|
469
|
+
When you have a setup table alongside a test-cases table, use `scenario` and call `table.eachRow` on the test-cases table.
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
import {LocalizedSlugs} from '@src/services/i18n/localized-slugs';
|
|
473
|
+
import {scenario} from '@zaunt/zest';
|
|
474
|
+
|
|
475
|
+
scenario({
|
|
476
|
+
name: 'getFullLocalizedPath',
|
|
477
|
+
markdown: `
|
|
478
|
+
Given the following localized slug data:
|
|
479
|
+
|
|
480
|
+
Locale | sign-in | now | yes
|
|
481
|
+
-------|------------|-------------|-----
|
|
482
|
+
en | sign-in | now | yes
|
|
483
|
+
fr | connexion | maintenant | oui
|
|
484
|
+
de | anmelden | jetzt | ja
|
|
485
|
+
|
|
486
|
+
The full localized path for each case is:
|
|
487
|
+
|
|
488
|
+
Locale | Path | Expected
|
|
489
|
+
-------|---------------------|-------------------------
|
|
490
|
+
en | / | /
|
|
491
|
+
fr | / | /fr/
|
|
492
|
+
de | / | /de/
|
|
493
|
+
en | /sign-in | /sign-in
|
|
494
|
+
en | /sign-in/ | /sign-in/
|
|
495
|
+
fr | /sign-in | /fr/connexion
|
|
496
|
+
fr | /sign-in/ | /fr/connexion/
|
|
497
|
+
de | /sign-in | /de/anmelden
|
|
498
|
+
de | /sign-in/now | /de/anmelden/jetzt
|
|
499
|
+
de | /sign-in/now/ | /de/anmelden/jetzt/
|
|
500
|
+
de | /sign-in/now/yes | /de/anmelden/jetzt/ja
|
|
501
|
+
en | /sign-in/now/yes | /sign-in/now/yes
|
|
502
|
+
es | /sign-in | [ERROR] Locale "es" not found
|
|
503
|
+
fr | /missing | [ERROR] Slug key "missing" not found for locale "fr"
|
|
504
|
+
de | /sign-in/missing | [ERROR] Slug key "missing" not found for locale "de"
|
|
505
|
+
`,
|
|
506
|
+
async execute({tables: [setup, expectations]}) {
|
|
507
|
+
const slugKeys = setup.headers.slice(1);
|
|
508
|
+
|
|
509
|
+
const localizedSlugData: Record<string, Record<string, string>> = {};
|
|
510
|
+
for (const row of setup) {
|
|
511
|
+
const loc = row.value('Locale');
|
|
512
|
+
localizedSlugData[loc] = {};
|
|
513
|
+
for (const key of slugKeys) {
|
|
514
|
+
localizedSlugData[loc][key] = row.value(key);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const localizedSlugs = new LocalizedSlugs(localizedSlugData);
|
|
519
|
+
|
|
520
|
+
await expectations.eachRow({
|
|
521
|
+
execute(locale, path) {
|
|
522
|
+
try {
|
|
523
|
+
return localizedSlugs.getFullLocalizedPath(
|
|
524
|
+
locale.asString(),
|
|
525
|
+
path.asString()
|
|
526
|
+
);
|
|
527
|
+
} catch (e) {
|
|
528
|
+
return `[ERROR] ${(e as Error).message}`;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
## Configuration
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
import {setConfig} from '@zaunt/zest';
|
|
540
|
+
|
|
541
|
+
setConfig({
|
|
542
|
+
outputEnabled: true, // write HTML reports (default: true)
|
|
543
|
+
outputDir: './reports', // report output directory
|
|
544
|
+
failFast: false // stop on first failure (default: false)
|
|
545
|
+
});
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
These can also be set via environment variables:
|
|
549
|
+
|
|
550
|
+
| Variable | Default |
|
|
551
|
+
| --------------------- | -------------------------------------- |
|
|
552
|
+
| `ZEST_OUTPUT_ENABLED` | `true` |
|
|
553
|
+
| `ZEST_OUTPUT_DIR` | System temp directory + `/zest-output` |
|
|
554
|
+
| `ZEST_FAIL_FAST` | `false` |
|
|
555
|
+
|
|
556
|
+
Per-scenario `failFast` overrides the global setting:
|
|
557
|
+
|
|
558
|
+
```ts
|
|
559
|
+
scenario({
|
|
560
|
+
markdown: '...',
|
|
561
|
+
failFast: true,
|
|
562
|
+
execute({ facts, tables, exhibits }) { ... }
|
|
563
|
+
});
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
## HTML reports
|
|
567
|
+
|
|
568
|
+
After each test, Zest writes a self-contained HTML file showing the scenario with every fact colour-coded:
|
|
569
|
+
|
|
570
|
+
- 🟢 **Green** — asserted and passed
|
|
571
|
+
- 🔴 **Red** — asserted and failed (shows expected and actual values)
|
|
572
|
+
- **Neutral** — read but not asserted, or never accessed
|
|
573
|
+
|
|
574
|
+
Reports are written to the configured output directory. The filename is derived from the test name, or a hash of the Markdown if no name is given.
|
|
575
|
+
|
|
576
|
+
## Markdown format
|
|
577
|
+
|
|
578
|
+
Zest uses a proper Markdown parser ([markdown-it](https://github.com/markdown-it/markdown-it)). Tables follow GFM syntax:
|
|
579
|
+
|
|
580
|
+
```
|
|
581
|
+
Header1 | Header2
|
|
582
|
+
--------|--------
|
|
583
|
+
value1 | value2
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
The separator row needs at least three dashes per column. Inline facts can use any of these delimiters:
|
|
587
|
+
|
|
588
|
+
- Backticks: `` `value` ``
|
|
589
|
+
- Bold: `**value**`
|
|
590
|
+
- Italic: `*value*`
|