@shopify/ui-extensions-tester 0.0.1-alpha.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/README.md +411 -0
- package/package.json +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# ๐งช @shopify/ui-extensions-tester
|
|
2
|
+
|
|
3
|
+
Write unit tests for [Shopify UI extensions](../ui-extensions) to ensure correctness and prevent regressions.
|
|
4
|
+
|
|
5
|
+
This testing library provides strongly typed mocks of the extension API--like the `shopify` global--so you can verify the correctness of your extension without needing a real Shopify host.
|
|
6
|
+
|
|
7
|
+
## ๐ Requirements
|
|
8
|
+
|
|
9
|
+
- **API version `2025-10` or later** in your `shopify.extension.toml`
|
|
10
|
+
- **Node.js v20.20.0** or later
|
|
11
|
+
- **a mock DOM** such as [`environment: 'jsdom'`](https://vitest.dev/config/environment.html) in [`vitest`](https://vitest.dev/)
|
|
12
|
+
- **Test isolation** โ extensions rely on the `shopify` global, so each test file must run in its own environment. We recommend [`vitest`](https://vitest.dev/) in [isolate mode](https://vitest.dev/config/isolate.html#isolate) (enabled by default).
|
|
13
|
+
|
|
14
|
+
## ๐ Recommendations
|
|
15
|
+
|
|
16
|
+
- **TypeScript** โ we recommend TypeScript to enforce API compliance against mock objects
|
|
17
|
+
- **@testing-library/preact** โ if your extension uses [Preact](https://preactjs.com/), we recommend installing [`@testing-library/preact`](https://preactjs.com/guide/v10/preact-testing-library/) for its `fireEvent` and `waitFor` helpers
|
|
18
|
+
|
|
19
|
+
## ๐ฆ Installation
|
|
20
|
+
|
|
21
|
+
Install the tester as a dev dependency alongside your preferred test runner:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install --save-dev @shopify/ui-extensions-tester vitest
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If your extension renders with Preact, also install `@testing-library/preact`:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install --save-dev @testing-library/preact
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## ๐๏ธ Adding to an existing extension
|
|
34
|
+
|
|
35
|
+
If your extension was seeded from an older template using <a href="https://shopify.dev/docs/api/shopify-cli/app/app-generate-extension"><code>shopify app generate extension</code></a>, follow these steps.
|
|
36
|
+
|
|
37
|
+
<details>
|
|
38
|
+
<summary>Expand for details</summary>
|
|
39
|
+
|
|
40
|
+
The template gives you a project structure like this:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
my-app/
|
|
44
|
+
โโโ extensions/
|
|
45
|
+
โ โโโ my-extension/
|
|
46
|
+
โ โโโ src/
|
|
47
|
+
โ โ โโโ Checkout.jsx
|
|
48
|
+
โ โโโ package.json
|
|
49
|
+
โ โโโ shopify.d.ts
|
|
50
|
+
โ โโโ shopify.extension.toml
|
|
51
|
+
โ โโโ tsconfig.json
|
|
52
|
+
โโโ package.json
|
|
53
|
+
โโโ shopify.app.toml
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You need to add a few things:
|
|
57
|
+
|
|
58
|
+
### 1. Add dependencies to the root `package.json`
|
|
59
|
+
|
|
60
|
+
Your extension's own `package.json` (inside `extensions/my-extension/`) already lists `@shopify/ui-extensions` for Shopify CLI. However, tests and typechecking run from the **root** project directory, so you also need `@shopify/ui-extensions` in the root `package.json` โ otherwise TypeScript and vitest won't be able to resolve it.
|
|
61
|
+
|
|
62
|
+
```jsonc
|
|
63
|
+
{
|
|
64
|
+
"scripts": {
|
|
65
|
+
// ...existing scripts
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"typecheck": "tsc --noEmit --project extensions/my-extension/tsconfig.json"
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"@shopify/ui-extensions": "latest"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@shopify/ui-extensions-tester": "latest",
|
|
74
|
+
"@testing-library/preact": "^3.2.0",
|
|
75
|
+
"typescript": "^5.0.0",
|
|
76
|
+
"vitest": "^3.0.0"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 2. Create `vitest.config.ts` at the project root
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import {defineConfig} from 'vitest/config';
|
|
85
|
+
|
|
86
|
+
export default defineConfig({
|
|
87
|
+
esbuild: {
|
|
88
|
+
jsx: 'automatic',
|
|
89
|
+
jsxImportSource: 'preact',
|
|
90
|
+
},
|
|
91
|
+
test: {
|
|
92
|
+
environment: 'jsdom',
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 3. Update the extension's `tsconfig.json`
|
|
98
|
+
|
|
99
|
+
Add the `tests` directory to the `include` array so your test files are typechecked:
|
|
100
|
+
|
|
101
|
+
```jsonc
|
|
102
|
+
{
|
|
103
|
+
"compilerOptions": {
|
|
104
|
+
"jsx": "react-jsx",
|
|
105
|
+
"jsxImportSource": "preact",
|
|
106
|
+
"target": "ES2020",
|
|
107
|
+
"strict": true,
|
|
108
|
+
"checkJs": true,
|
|
109
|
+
"allowJs": true,
|
|
110
|
+
"moduleResolution": "node",
|
|
111
|
+
"esModuleInterop": true,
|
|
112
|
+
"skipLibCheck": true
|
|
113
|
+
},
|
|
114
|
+
"include": [
|
|
115
|
+
"./src",
|
|
116
|
+
"./tests",
|
|
117
|
+
"./shopify.d.ts"
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 4. Create a `tests/` directory inside your extension
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
extensions/
|
|
126
|
+
โโโ my-extension/
|
|
127
|
+
โโโ src/
|
|
128
|
+
โ โโโ Checkout.jsx
|
|
129
|
+
โโโ tests/
|
|
130
|
+
โโโ Checkout.test.ts โ your tests go here
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 5. Add a triple-slash reference in each test file
|
|
134
|
+
|
|
135
|
+
Add a [triple-slash directive](https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html) for your target so TypeScript knows about Shopify element types (`s-button`, `s-text`, etc.):
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
/// <reference types="@shopify/ui-extensions/purchase.checkout.block.render" />
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The path must match the target you pass to `getExtension()`.
|
|
142
|
+
|
|
143
|
+
</details>
|
|
144
|
+
|
|
145
|
+
## ๐โโ๏ธ Getting started
|
|
146
|
+
|
|
147
|
+
Every test file follows the same pattern: create an extension harness, call `extension.setUp()` before each test, call `extension.tearDown()` after.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import {getExtension} from '@shopify/ui-extensions-tester';
|
|
151
|
+
import {beforeEach, afterEach} from 'vitest';
|
|
152
|
+
|
|
153
|
+
const extension = getExtension(
|
|
154
|
+
'purchase.checkout.block.render',
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
extension.setUp();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
extension.tearDown();
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`setUp()` creates a complete mock `shopify` global compliant with the target. `tearDown()` clears the DOM and removes the global, among other surface-specific things.
|
|
167
|
+
|
|
168
|
+
### ๐ Rendering and querying elements
|
|
169
|
+
|
|
170
|
+
Call `extension.render()` to import and execute your extension's callback, then query the DOM with standard DOM APIs like [`document.body.querySelector()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector) and [`document.body.querySelectorAll()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll):
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
test('it handles an empty cart', async () => {
|
|
174
|
+
await extension.render();
|
|
175
|
+
|
|
176
|
+
const text =
|
|
177
|
+
document.body.querySelector('s-text')!;
|
|
178
|
+
expect(text.textContent).toEqual(
|
|
179
|
+
'No items in cart',
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### ๐จ Mocking shopify API values
|
|
185
|
+
|
|
186
|
+
The test setup will create a `shopify` global with sensible defaults for the target. You can mutate global property values on `extension.shopify` before rendering.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
test('it handles an empty order', async () => {
|
|
190
|
+
extension.shopify.order.value = undefined;
|
|
191
|
+
|
|
192
|
+
await extension.render();
|
|
193
|
+
|
|
194
|
+
const text =
|
|
195
|
+
document.body.querySelector('s-text')!;
|
|
196
|
+
expect(text.textContent).toEqual(
|
|
197
|
+
'Order not found',
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### ๐ฑ๏ธ Triggering events
|
|
203
|
+
|
|
204
|
+
To simulate how a user would interact with your UI extension, you can call [`dispatchEvent()`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) or use `fireEvent` from `@testing-library/preact`. When an event triggers an async state change (like a Preact re-render), wrap follow-up assertions in `await waitFor()` to wait for the DOM to settle:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import {
|
|
208
|
+
fireEvent,
|
|
209
|
+
waitFor,
|
|
210
|
+
} from '@testing-library/preact';
|
|
211
|
+
|
|
212
|
+
test('it handles date field changes', async () => {
|
|
213
|
+
await extension.render();
|
|
214
|
+
|
|
215
|
+
const dateField = document.body.querySelector(
|
|
216
|
+
's-date-field',
|
|
217
|
+
)!;
|
|
218
|
+
dateField.value = '1990-05-20';
|
|
219
|
+
fireEvent.change(dateField);
|
|
220
|
+
|
|
221
|
+
const button =
|
|
222
|
+
document.body.querySelector('s-button')!;
|
|
223
|
+
fireEvent.click(button);
|
|
224
|
+
|
|
225
|
+
await waitFor(() => {
|
|
226
|
+
const banner =
|
|
227
|
+
document.body.querySelector('s-banner')!;
|
|
228
|
+
expect(banner.textContent).toEqual('Saved');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### ๐ Safely mocking mutation functions
|
|
234
|
+
|
|
235
|
+
When mocking without strict typing, like with [`vitest` mocks](https://vitest.dev/api/vi.html#mocking-functions-and-objects), you can use a surface-specific `createResult()` helper to return type-safe values:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import {createResult} from '@shopify/ui-extensions-tester/checkout';
|
|
239
|
+
|
|
240
|
+
const applyMetafieldChange = vi
|
|
241
|
+
.fn()
|
|
242
|
+
.mockResolvedValue(
|
|
243
|
+
createResult('applyMetafieldChange', {
|
|
244
|
+
type: 'error',
|
|
245
|
+
message:
|
|
246
|
+
'Could not apply metafield changes',
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
extension.shopify.applyMetafieldChange =
|
|
250
|
+
applyMetafieldChange;
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The first argument is the mutation API name. The second is an optional result override โ omit it to get sensible defaults (like `{type: 'success'}`).
|
|
254
|
+
|
|
255
|
+
### โก Testing extension code that relies on signals
|
|
256
|
+
|
|
257
|
+
Extensions typically subscribe to signal-like objects such as [`shopify.lines.value`](https://shopify.dev/docs/api/checkout-ui-extensions/latest/apis/cart-lines#standardapi-propertydetail-lines).
|
|
258
|
+
The mock Shopify API **does not implement working signals** so you'll need to test each state change separately.
|
|
259
|
+
|
|
260
|
+
For example, let's say your extension has a button that updates the quantity of an item in the cart. Test the button first:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
import {createCartLine} from '@shopify/ui-extensions-tester/checkout';
|
|
264
|
+
|
|
265
|
+
test('increments cart line quantity on click', async () => {
|
|
266
|
+
const line = createCartLine();
|
|
267
|
+
extension.shopify.lines.value = [line];
|
|
268
|
+
const applyCartLinesChange = vi.spyOn(
|
|
269
|
+
extension.shopify,
|
|
270
|
+
'applyCartLinesChange',
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
await extension.render();
|
|
274
|
+
|
|
275
|
+
const button =
|
|
276
|
+
document.body.querySelector('s-button')!;
|
|
277
|
+
fireEvent.click(button);
|
|
278
|
+
|
|
279
|
+
// Make sure clicking the button updates the quantity:
|
|
280
|
+
await waitFor(() => {
|
|
281
|
+
expect(
|
|
282
|
+
applyCartLinesChange,
|
|
283
|
+
).toHaveBeenCalledWith({
|
|
284
|
+
type: 'updateCartLine',
|
|
285
|
+
id: line.id,
|
|
286
|
+
quantity: line.quantity + 1,
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Next, simulate the state change for updated quantities that would have happened on the checkout host:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
test('it renders the cart line quantity', async () => {
|
|
296
|
+
extension.shopify.lines.value = [
|
|
297
|
+
createCartLine({quantity: 2}),
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
await extension.render();
|
|
301
|
+
|
|
302
|
+
const text =
|
|
303
|
+
document.body.querySelector('s-text')!;
|
|
304
|
+
expect(text.textContent).toContain('2');
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### ๐ Working with translations
|
|
309
|
+
|
|
310
|
+
The default `shopify.i18n.translate()` mock returns key names as-is to make assertions easier.
|
|
311
|
+
|
|
312
|
+
For example, if you render `shopify.i18n.translate('headings.orderNotFound')` in extension code, you can test by looking for the rendered key name:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
test('it renders a banner when the order does not exist', async () => {
|
|
316
|
+
extension.shopify.order.value = undefined;
|
|
317
|
+
|
|
318
|
+
await extension.render();
|
|
319
|
+
|
|
320
|
+
const banner =
|
|
321
|
+
document.body.querySelector('s-banner')!;
|
|
322
|
+
// Check for the translation key, not the actual translation:
|
|
323
|
+
expect(banner.getAttribute('heading')).toEqual(
|
|
324
|
+
'headings.orderNotFound',
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## โฏ๏ธ Surface-specific guides
|
|
330
|
+
|
|
331
|
+
Each surface exports some helpers:
|
|
332
|
+
|
|
333
|
+
- โ๏ธ [Admin](./src/admin/README.md)
|
|
334
|
+
- ๐ [Checkout](./src/checkout/README.md)
|
|
335
|
+
- ๐ [Customer Account](./src/customer-account/README.md)
|
|
336
|
+
- ๐๏ธ [Point of Sale](./src/point-of-sale/README.md)
|
|
337
|
+
|
|
338
|
+
## ๐ API reference
|
|
339
|
+
|
|
340
|
+
Exports from `@shopify/ui-extensions-tester`:
|
|
341
|
+
|
|
342
|
+
### `getExtension(target, options?)`
|
|
343
|
+
|
|
344
|
+
Returns an extension test harness for the given target. It reads `shopify.extension.toml`, finds the module for the given target, and provides helpers to mock the environment and render the extension. It locates `shopify.extension.toml` by walking up from the calling test file's directory, and falls back to searching `extensions/` under the current working directory.
|
|
345
|
+
|
|
346
|
+
| Option | Type | Default | Description |
|
|
347
|
+
| ----------------- | -------- | ----------------------------- | ---------------------------------------------------------- |
|
|
348
|
+
| `configSearchDir` | `string` | calling test file's directory | Directory to start searching for `shopify.extension.toml`. |
|
|
349
|
+
|
|
350
|
+
By default `getExtension` walks up from the test file's directory to find `shopify.extension.toml`.
|
|
351
|
+
|
|
352
|
+
**Returns** an `Extension<T>` object with the following members:
|
|
353
|
+
|
|
354
|
+
#### `extension.setUp()`
|
|
355
|
+
|
|
356
|
+
Sets up an extension environment for testing. Creates a mock `shopify` global with some defaults.
|
|
357
|
+
|
|
358
|
+
#### `extension.tearDown()`
|
|
359
|
+
|
|
360
|
+
Tears down the extension environment. Resets the `shopify` global and clears `document.body`.
|
|
361
|
+
|
|
362
|
+
#### `extension.render()`
|
|
363
|
+
|
|
364
|
+
Imports and executes the extension module's default export, rendering the extension into `document.body`. Returns a `Promise<void>`.
|
|
365
|
+
|
|
366
|
+
#### `extension.shopify`
|
|
367
|
+
|
|
368
|
+
A mock `shopify` global, typed correctly for the target under test. You can mutate any property.
|
|
369
|
+
|
|
370
|
+
#### `extension.fetch`
|
|
371
|
+
|
|
372
|
+
A mock `fetch()` function installed as `globalThis.fetch` during `setUp()` and removed during `tearDown()`.
|
|
373
|
+
|
|
374
|
+
Override it with a mock to control responses:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
extension.fetch = vi
|
|
378
|
+
.fn()
|
|
379
|
+
.mockResolvedValue(
|
|
380
|
+
new Response(JSON.stringify({ok: true})),
|
|
381
|
+
);
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Assigning to `extension.fetch` also updates `globalThis.fetch`, so extension code that calls `fetch()` directly will use the mock.
|
|
385
|
+
|
|
386
|
+
#### `extension.navigation`
|
|
387
|
+
|
|
388
|
+
A mock [`Navigation`](https://developer.mozilla.org/en-US/docs/Web/API/Navigation) object installed as `globalThis.navigation` during `setUp()` and removed during `tearDown()`. Typed using the `Navigation` interface from `@shopify/ui-extensions/customer-account`.
|
|
389
|
+
|
|
390
|
+
Override its properties with mocks to control navigation behaviour:
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
import {createNavigationHistoryEntry} from '@shopify/ui-extensions-tester';
|
|
394
|
+
|
|
395
|
+
extension.navigation.navigate = vi.fn();
|
|
396
|
+
extension.navigation.currentEntry =
|
|
397
|
+
createNavigationHistoryEntry({
|
|
398
|
+
url: '/cart',
|
|
399
|
+
state: {items: 3},
|
|
400
|
+
});
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Assigning to `extension.navigation` also updates `globalThis.navigation`, so extension code that calls `navigation.navigate()` directly will use the mock.
|
|
404
|
+
|
|
405
|
+
### `createNavigationHistoryEntry(options?)`
|
|
406
|
+
|
|
407
|
+
Creates a [`NavigationHistoryEntry`](https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry) for mocking `navigation.currentEntry` or other navigation values.
|
|
408
|
+
|
|
409
|
+
- `url` โ URL of the history entry (default `''`)
|
|
410
|
+
- `key` โ key of the history entry (default `''`)
|
|
411
|
+
- `state` โ developer-defined state retrieved via `getState()` (default `undefined`). Each `getState()` call returns a structured clone, matching real browser behaviour.
|