Skip to content

Commit 7a9c01c

Browse files
committed
feat(mocking): introduce mock$ to verify interactions with component callbacks (experimental)
1 parent df4983b commit 7a9c01c

File tree

8 files changed

+208
-28
lines changed

8 files changed

+208
-28
lines changed

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ src="https://raw.githubusercontent.com/ianlet/qwik-testing-library/main/high-vol
9292
- [Setup](#setup)
9393
- [Examples](#examples)
9494
- [Qwikstart](#qwikstart)
95+
- [Mocking Component Callbacks (experimental)](#mocking-component-callbacks-experimental)
9596
- [Qwik City - `server$` calls](#qwik-city---server-calls)
9697
- [Gotchas](#gotchas)
9798
- [Issues](#issues)
@@ -303,6 +304,64 @@ describe("<Counter />", () => {
303304
})
304305
```
305306

307+
### Mocking Component Callbacks (experimental)
308+
309+
> [!WARNING]
310+
> This feature is under a testing phase and thus experimental.
311+
> Its API may change in the future, so use it at your own risk.
312+
313+
The Qwik Testing Library provides a `mock$` function
314+
that can be used to create a mock of a QRL and verify interactions on your Qwik components.
315+
316+
It is _not_ a replacement of regular mocking functions (such as `vi.fn` and `vi.mock`) as its intended use is only for
317+
testing callbacks of Qwik components.
318+
319+
Here's an example on how to use the `mock$` function:
320+
321+
```tsx title="counter.spec.tsx"
322+
// import qwik-testing methods
323+
import {mock$, clearAllMock, render, screen, waitFor} from "@noma.to/qwik-testing-library";
324+
// import the userEvent methods to interact with the DOM
325+
import userEvent from "@testing-library/user-event";
326+
327+
// import the component to be tested
328+
import {Counter} from "./counter";
329+
330+
// describe the test suite
331+
describe("<Counter />", () => {
332+
// initialize a mock
333+
// note: the empty callback is required but currently unused
334+
const onChangeMock = mock$(() => {
335+
});
336+
337+
// setup beforeEach block to run before each test
338+
beforeEach(() => {
339+
// remember to always clear all mocks before each test
340+
clearAllMocks();
341+
});
342+
343+
// describe the 'on increment' test cases
344+
describe("on increment", () => {
345+
// describe the test case
346+
it("should call onChange$", async () => {
347+
// render the component into the DOM
348+
await render(<Counter value={0} onChange$={onChangeMock}/>);
349+
350+
// retrieve the 'decrement' button
351+
const decrementBtn = screen.getByRole("button", {name: "Decrement"});
352+
// click the button
353+
await userEvent.click(decrementBtn);
354+
355+
// assert that the onChange$ callback was called with the right value
356+
// note: QRLs are async in Qwik, so we need to resolve them to verify interactions
357+
await waitFor(() =>
358+
expect(onChangeMock.resolve()).resolves.toHaveBeenCalledWith(-1),
359+
);
360+
});
361+
});
362+
})
363+
```
364+
306365
### Qwik City - `server$` calls
307366

308367
If one of your Qwik components uses `server$` calls, your tests might fail with a rather cryptic message (e.g. `QWIK
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
clearAllMocks,
3+
mock$,
4+
render,
5+
screen,
6+
waitFor,
7+
} from "@noma.to/qwik-testing-library";
8+
import { Counter } from "./counter";
9+
import userEvent from "@testing-library/user-event";
10+
11+
describe("<Counter />", () => {
12+
const onChangeMock = mock$(() => {});
13+
14+
beforeEach(() => {
15+
clearAllMocks();
16+
});
17+
18+
it("should start at 0 by default", async () => {
19+
await render(<Counter onChange$={onChangeMock} />);
20+
21+
expect(screen.getByText("Counter: 0")).toBeInTheDocument();
22+
});
23+
24+
describe("on increment", () => {
25+
it("should increase by 1", async () => {
26+
await render(<Counter onChange$={onChangeMock} />);
27+
28+
const incrementBtn = screen.getByRole("button", { name: "Increment" });
29+
await userEvent.click(incrementBtn);
30+
31+
await waitFor(() =>
32+
expect(screen.getByText("Counter: 1")).toBeInTheDocument(),
33+
);
34+
});
35+
36+
it("should call onChange$", async () => {
37+
await render(<Counter onChange$={onChangeMock} />);
38+
39+
const incrementBtn = screen.getByRole("button", { name: "Increment" });
40+
await userEvent.click(incrementBtn);
41+
42+
await waitFor(() =>
43+
expect(onChangeMock.resolve()).resolves.toHaveBeenCalledWith(1),
44+
);
45+
});
46+
});
47+
48+
describe("on decrement", () => {
49+
it("should decrease by 1", async () => {
50+
await render(<Counter onChange$={onChangeMock} />);
51+
52+
const decrementBtn = screen.getByRole("button", { name: "Decrement" });
53+
await userEvent.click(decrementBtn);
54+
55+
await waitFor(() =>
56+
expect(screen.getByText("Counter: -1")).toBeInTheDocument(),
57+
);
58+
});
59+
60+
it("should call onChange$", async () => {
61+
await render(<Counter onChange$={onChangeMock} />);
62+
63+
const decrementBtn = screen.getByRole("button", { name: "Decrement" });
64+
await userEvent.click(decrementBtn);
65+
66+
await waitFor(() =>
67+
expect(onChangeMock.resolve()).resolves.toHaveBeenCalledWith(-1),
68+
);
69+
});
70+
});
71+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { $, component$, QRL, useSignal } from "@builder.io/qwik";
2+
3+
interface CounterProps {
4+
onChange$: QRL<(value: number) => void>;
5+
}
6+
7+
export const Counter = component$<CounterProps>(({ onChange$ }) => {
8+
const count = useSignal(0);
9+
10+
const handleIncrement = $(() => {
11+
count.value++;
12+
return onChange$(count.value);
13+
});
14+
15+
const handleDecrement = $(() => {
16+
count.value--;
17+
return onChange$(count.value);
18+
});
19+
20+
return (
21+
<div>
22+
<h1>Counter: {count}</h1>
23+
<button onClick$={handleIncrement}>Increment</button>
24+
<button onClick$={handleDecrement}>Decrement</button>
25+
</div>
26+
);
27+
});

apps/qwik-testing-library-e2e-tests/src/components/qwik-component.spec.tsx

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { QwikComponent } from "./qwik-component";
22
import { render, screen, waitFor } from "@noma.to/qwik-testing-library";
33
import userEvent from "@testing-library/user-event";
4-
import { Mock } from "vitest";
54

65
describe("<QwikComponent />", () => {
76
const aProp = "my-prop";
@@ -209,31 +208,6 @@ describe("<QwikComponent />", () => {
209208
});
210209
});
211210

212-
describe("Events", () => {
213-
let firstEvent: Mock;
214-
let secondEvent: Mock;
215-
216-
beforeEach(() => {
217-
firstEvent = vi.fn();
218-
secondEvent = vi.fn();
219-
});
220-
221-
it("should handle multiple events", async () => {
222-
await render(
223-
// eslint-disable-next-line qwik/valid-lexical-scope
224-
<QwikComponent onFirst$={firstEvent} onSecond$={secondEvent} />,
225-
);
226-
227-
const eventsBtn = screen.getByRole("button", {
228-
name: /fire events/,
229-
});
230-
await userEvent.click(eventsBtn);
231-
232-
expect(firstEvent).toHaveBeenCalledTimes(1);
233-
expect(secondEvent).toHaveBeenCalledTimes(1);
234-
});
235-
});
236-
237211
describe("Tasks", () => {
238212
const trackedTaskValue = "tracked-task-value";
239213

packages/qwik-testing-library/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"typescript": "5.6.2",
4747
"undici": "*",
4848
"vite": "5.4.8",
49-
"vite-tsconfig-paths": "^5.0.1"
49+
"vite-tsconfig-paths": "^5.0.1",
50+
"vitest": "^2.1.1"
5051
}
5152
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { $, implicit$FirstArg } from "@builder.io/qwik";
2+
import { vi } from "vitest";
3+
4+
export const mockQrl = () => {
5+
return $(vi.fn());
6+
};
7+
8+
/**
9+
* @experimental
10+
*
11+
* Create a QRL mock that can be used in tests to verify interactions
12+
*
13+
* As Qwik is an async framework, you need to `resolve()` the mock before making your verifications.
14+
* And remember to clear the mocks before each test to start with a clean slate!
15+
*
16+
* @example
17+
* ```tsx
18+
* describe('<MyButton />', () => {
19+
* const onClickMock = mock$(() => {});
20+
*
21+
* beforeEach(() => {
22+
* clearAllMocks();
23+
* });
24+
*
25+
* it('should call onClick$', async () => {
26+
* await render(<MyButton onClick$={onClickMock} />);
27+
*
28+
* await userEvent.click(screen.getByRole('button'));
29+
*
30+
* await waitFor(() => expect(onClickMock.resolve()).resolves.toHaveBeenCalled());
31+
* });
32+
* });
33+
* ```
34+
*/
35+
export const mock$ = implicit$FirstArg(mockQrl);
36+
37+
/**
38+
* Will call `.mockClear()` on all spies. This will clear mock history, but not reset its implementation to the
39+
* default one.
40+
*/
41+
export function clearAllMocks() {
42+
vi.clearAllMocks();
43+
}

packages/qwik-testing-library/src/lib/qwik-testing-library.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getQueriesForElement, prettyDOM } from "@testing-library/dom";
22
import { JSXOutput } from "@builder.io/qwik";
33
import type { ComponentRef, Options, Result } from "./types";
44
import { qwikLoader } from "./qwikloader";
5+
import { clearAllMocks, mock$, mockQrl } from "./mock";
56

67
const mountedContainers = new Set<ComponentRef>();
78

@@ -74,4 +75,4 @@ function cleanup() {
7475
}
7576

7677
export * from "@testing-library/dom";
77-
export { cleanup, render };
78+
export { cleanup, render, mock$, mockQrl, clearAllMocks };

pnpm-lock.yaml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)