@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.
Files changed (2) hide show
  1. package/README.md +411 -0
  2. 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.
package/package.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@shopify/ui-extensions-tester",
3
+ "version": "0.0.1-alpha.0",
4
+ "main": "index.js",
5
+ "files": [
6
+ "index.js"
7
+ ]
8
+ }