Skip to content

Commit 5d80eb5

Browse files
committed
03/03: add problem and solution texts
1 parent 3f1dbce commit 5d80eb5

23 files changed

+842
-9
lines changed
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
# Network mocking
2+
3+
We've got quite far testing the `<DiscountForm />` component but there's just one problem. The component has been a bit naive. It simply took the entered discount code and put it in internal state. That's not how things work in real life. In practice, it would likely send that code to some server to validate and apply. It would make a _network call_.
4+
5+
In fact, I went and refactored the discount code component to do just that! Now, submitting the form dispatches a `POST https://api.example.com/discount/code` request with the provided code. Only once the server sends back the confirmation will the component display the applied discount in the UI.
6+
7+
This poses a new challenge: that `POST` request _will actually happen during the test run_. That may be acceptable in end-to-end testing but never on the integration level.
8+
9+
<callout-success>Always mock HTTP requests in integration tests.</callout-success>
10+
11+
Leaving that request to fire during tests means that we are allowing the server's operability and behavior to influence the outcome of the test. This is a no-go as it violates :scroll: [The Golden Rule of Assertions](https://www.epicweb.dev/the-golden-rule-of-assertions):
12+
13+
<callout-info>_A test must fail if, and only if, the intention behind the system is not met._</callout-info>
14+
15+
In other words, our component can be functioning perfectly but the test may still fail if the server is down or having trouble processing the request. The responsibility of the integration test is to assert the client-side code we've written, and never the server-side one.
16+
17+
You have to draw the line. You have to draw a :scroll: [Test boundary](https://www.epicweb.dev/what-is-a-test-boundary).
18+
19+
When it comes to the network, you employ API mocking tools to establish that boundary and help your tests focus on the right thing, turning the dynamic and unpredictable thing such as network to fixed and given.
20+
21+
In this exercise, we would have toto intercept and mock the `POST https://api.example.com/discount/code` that our component makes during tests. We will use the library called [Mock Service Worker](https://mswjs.io/) (MSW) to do that.
22+
23+
## Using MSW with Vitest Browser Mode
24+
25+
Integrating MSW with Vitest Browser Mode is a little different from the usual browser integration so let's go throuigh it together. Follow the instructions below.
26+
27+
### Installation and setup
28+
29+
🐨 Start by installing the `msw` package as a dependency:
30+
31+
```sh nonumber
32+
npm install -D msw
33+
```
34+
35+
🐨 Generate the Service Worker script that MSW will use to intercept requests in the browser by running this command:
36+
37+
```sh nonumber
38+
npx msw init ./public
39+
```
40+
41+
🐨 Create a browser integration file at <InlineFile file="./src/mocks/browser.ts">`src/mocks/browser.ts`</InlineFile>. Feel free to paste the following content there:
42+
43+
```ts filename=src/mocks/browser.ts
44+
import { setupWorker } from 'msw/browser'
45+
import { handlers } from './handlers.js'
46+
47+
export const worker = setupWorker(...handlers)
48+
49+
export async function startWorker() {
50+
await worker.start({
51+
quiet: true,
52+
onUnhandledRequest(request, print) {
53+
if (/(\.(css|tsx?|woff2?))/.test(request.url)) {
54+
return
55+
}
56+
57+
print.error()
58+
},
59+
})
60+
}
61+
```
62+
63+
Let's briefly go over what's happening here:
64+
65+
- The `setupWorker()` function prepares a Service Worker instance configured with the given request `handlers`. Note that it _doesn't start it_ just yet!
66+
- The `startWorker()` function abstracts starting the worker so we can potentially reuse it in multiple places.
67+
- `quiet: true` disables MSW logging to keep our consoles clean.
68+
- `onUnhandledRequest` decides how to handle requests that you don't have mocks for. In this case, we are ignoring any asset requests while reacting to any other unhandled requests with an error.
69+
70+
After this step, you would normally introduce the `enableMocking()` function and wrap it around your application's initialization. With Vitest Browser Mode, the root of your app (`main.tsx`) doesn't run in tests so you need to start MSW differently.
71+
72+
For that, you will use a _custom test context_.
73+
74+
### Vitest Browser Mode integration
75+
76+
🐨 Create a new file called <InlineFile file="./test-extend.ts">`test-extend.ts`</InlineFile>.
77+
78+
🐨 In `test-extend.ts`, import `test as testBase` from `vitest`:
79+
80+
```ts filename=test-extend.ts add=1
81+
import { test as testBase } from 'vitest'
82+
```
83+
84+
🐨 Create a new variable called `test` (this will be your custom test context) and assing it to the `testBase.extend()` function call:
85+
86+
```ts filename=test-extend.ts add=3
87+
import { test as testBase } from 'vitest'
88+
89+
export const test = testBase.extend()
90+
```
91+
92+
> :owl: Calling `.extend()` allows you to put _custom properties_ you can then access on the `test()` function.
93+
94+
🐨 Provide an object as an argument to the `testBase.extend()` call and declare a new property on that object called `worker`:
95+
96+
```ts filename=test-extend.ts add=4
97+
import { test as testBase } from 'vitest'
98+
99+
export const test = testBase.extend({
100+
worker: [fixture, options],
101+
})
102+
```
103+
104+
Right now, the `worker` property has an array of two elements: `fixture` and `options`. You will use the `fixture` to describe what the worker should do when it's accessed. You will use the `options` to provide additional options associated with this fixture.
105+
106+
🐨 Provide the following fixture for the `worker`:
107+
108+
```ts filename=test-extend.ts add=2,6-10
109+
import { test as testBase } from 'vitest'
110+
import { startWorker, worker } from './src/mocks/browser.js'
111+
112+
export const test = testBase.extend({
113+
worker: [
114+
async ({}, use) => {
115+
await startWorker()
116+
await use(worker)
117+
worker.stop()
118+
},
119+
options,
120+
],
121+
})
122+
```
123+
124+
In this fixture, you are telling Vitest to start the worker (`await startWorker()` from `src/mocks/browser.js` you've prepared earlier), then expose it to the test (`await use(worker)`), and finally call `worker.stop()` after the test is done.
125+
126+
There's just a slight issue with this. By default, fixtures in Vitest are _lazy_, which means they will not be initialized unless they are referenced in a test. Here's what that means:
127+
128+
```ts filename=some.test.ts highlight=3,7
129+
// MSW will not start here because this test does not
130+
// reference the `worker` fixture you've created.
131+
test('first scenario', () => {})
132+
133+
// This test does reference the `worker` fixture,
134+
// which will correctly start MSW for this test.
135+
test('second scenario', ({ worker }) => {})
136+
```
137+
138+
You want MSW up and running _in every test_ so you can take advantage of its request handler layering even if you don't reference the `worker` explicitly.
139+
140+
🐨 To do that, configure the `worker` fixture to have the `auto: true` option:
141+
142+
```ts filename=test-extend.ts add=11
143+
import { test as testBase } from 'vitest'
144+
import { startWorker, worker } from './src/mocks/browser.js'
145+
146+
export const test = testBase.extend({
147+
worker: [
148+
async ({}, use) => {
149+
await startWorker()
150+
await use(worker)
151+
worker.stop()
152+
},
153+
{ auto: true },
154+
],
155+
})
156+
```
157+
158+
> :owl: Passing `auto: true` to a fixture will make Vitest initialize it _automatically_, even if it's not explicitly referenced in the test.
159+
160+
With the setup finally done, let's move on to describing the network.
161+
162+
### Describing the network
163+
164+
🐨 Next, create a new file called <InlineFile file="./src/mocks/handlers.ts">`src/mocks/handlers.ts`</InlineFile>. You will describe the network behaviors you want here. In that file, import `http` and `HttpResponse` from `msw`:
165+
166+
```ts filename=src/mocks/handlers.ts add=1
167+
import { http, HttpResponse } from 'msw'
168+
```
169+
170+
Now is the turn to _describe the network_ you want. In MSW, you do that using functions called [Request handlers](https://mswjs.io/docs/concepts/request-handler) that are responsible for two things: intercepting requests and deciding how to handle them.
171+
172+
🐨 In `src/mocks/handlers.ts`, export an array called `handlers` and declare your first request handler for the `POST https://api.example.com/discount/code` request in it:
173+
174+
```ts filename=src/mocks/handlers.ts add=3-5
175+
import { http, HttpResponse } from 'msw'
176+
177+
export const handlers = [
178+
http.post('https://api.example.com/discount/code', () => {}),
179+
]
180+
```
181+
182+
This request handler will intercept any matching requests and execute the callback function you've provided as the second argument. That function—also called a _response resolver_—doesn't do anything thought! Let's fix that.
183+
184+
Our component expects a response from the server to have the following shape:
185+
186+
```ts
187+
{
188+
code: string
189+
amount: number
190+
}
191+
```
192+
193+
- `code` is the discount code sent by the client;
194+
- `amount` is the discount amount associated with the received discount code.
195+
196+
Since the `code` data comes from the client, let's read the intercepted request's body to retrieve the sent discount code.
197+
198+
🐨 In the response resolver function, access the `request` property on the argument object to the response resolver and read the reqest's body ...
199+
200+
```ts filename=src/mocks/handlers.ts add=7-8
201+
import { http, HttpResponse } from 'msw'
202+
203+
export const handlers = [
204+
http.post(
205+
'https://api.example.com/discount/code',
206+
// `request` is the Fetch API representation of the intercepted request.
207+
async ({ request }) => {
208+
const code = await request.text()
209+
},
210+
),
211+
]
212+
```
213+
214+
> :owl: MSW uses the Fetch API to handle requests and responses, which means you can read the intercepted request's body differently as well (e.g. `await request.json()` or `await request.formData()`). Read it appropriately to the nature of data sent from the client.
215+
216+
🐨 Now, respond with a mocked response to this request by returning `HttpResponse.json()` with the response body you want from the response resolver:
217+
218+
```ts filename=src/mocks/handlers.ts add=10-13
219+
import { http, HttpResponse } from 'msw'
220+
221+
export const handlers = [
222+
http.post(
223+
'https://api.example.com/discount/code',
224+
// `request` is the Fetch API representation of the intercepted request.
225+
async ({ request }) => {
226+
const code = await request.text()
227+
228+
return HttpResponse.json({
229+
code,
230+
amount: 20,
231+
})
232+
},
233+
),
234+
]
235+
```
236+
237+
> :owl: [`HttpResponse`](https://mswjs.io/docs/api/http-response) is a 1-1 compatible abstraction on top of the Fetch API `Response` that supports shorthand response declaration methods otherwise unavailable in the specification, like `HttpResponse.blob()` or `HttpResponse.formData()`. You use it purely for convenience and can always substitute it with a plain `Response` instance.
238+
239+
With MSW in place, it will act as the actual network for our automated test.
240+
241+
## Your task
242+
243+
What, you thought you were done? Not yet. You've just got to the good part, after all!
244+
245+
👨‍💼 Now that you are in charge of the network, your task is to complete the test cases for different network-related scenarios in <InlineFile filename="./src/discount-code-form.browser.test.tsx">`discount-code-form.browser.test.tsx`</InlineFile>. That includes the warning scenario for legacy discount codes as well as the error scenario when fetching the discount fails.
246+
247+
Good luck!
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Vite App</title>
8+
<link rel="preconnect" href="https://fonts.googleapis.com" />
9+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10+
</head>
11+
<body>
12+
<div id="root"></div>
13+
<script type="module" src="/src/main.tsx"></script>
14+
</body>
15+
</html>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"type": "module",
3+
"name": "exercises_03.best-practices_03.problem.network-mocking",
4+
"scripts": {
5+
"dev": "vite",
6+
"test": "vitest"
7+
},
8+
"dependencies": {
9+
"react": "^19.0.0",
10+
"react-dom": "^19.0.0"
11+
},
12+
"devDependencies": {
13+
"@testing-library/dom": "^10.4.0",
14+
"@testing-library/react": "^16.1.0",
15+
"@types/node": "^22.10.6",
16+
"@types/react": "^19.0.6",
17+
"@types/react-dom": "^19.0.3",
18+
"@vitejs/plugin-react": "^4.3.4",
19+
"@vitest/browser": "^3.0.3",
20+
"autoprefixer": "^10.4.20",
21+
"playwright": "^1.49.1",
22+
"postcss": "^8.4.49",
23+
"tailwindcss": "^3.4.17",
24+
"vite": "^6.0.7",
25+
"vitest": "^3.0.3"
26+
}
27+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default {
2+
plugins: {
3+
tailwindcss: {},
4+
autoprefixer: {},
5+
},
6+
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { DiscountCodeForm } from './discount-code-form.js'
2+
3+
export function App() {
4+
return <DiscountCodeForm />
5+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { page } from '@vitest/browser/context'
2+
import { render } from 'vitest-browser-react'
3+
import { http, HttpResponse } from 'msw'
4+
// 🐨 Import the `test` function from `test-extend.js`.
5+
// This custom `test` function exposes the `worker` object
6+
// you will use to access and use MSW in tests.
7+
// 💰 import { test } from '../test-extend.js'
8+
import { DiscountCodeForm } from './discount-code-form.js'
9+
10+
test('applies a discount code', async () => {
11+
render(<DiscountCodeForm />)
12+
13+
const discountInput = page.getByLabelText('Discount code')
14+
await discountInput.fill('EPIC2025')
15+
16+
const applyDiscountButton = page.getByRole('button', {
17+
name: 'Apply discount',
18+
})
19+
await applyDiscountButton.click()
20+
21+
await expect
22+
.element(page.getByText('Discount: EPIC2025 (-20%)'))
23+
.toBeVisible()
24+
})
25+
26+
test('displays a warning for legacy discount codes', async ({
27+
// 🐨 Access the custom `worker` fixture you've prepared earlier.
28+
}) => {
29+
// 🐨 Call `worker.use()` and provide it a new request handler
30+
// for the same POST https://api.example.com/discount/code request.
31+
// In this handler, respond with a different mocked response that
32+
// returns `isLegacy: true` in its JSON payload.
33+
//
34+
// Use the existing happy-path request handler from `src/mocks/handlers.ts`
35+
// as a reference!
36+
//
37+
// 💰 worker.use()
38+
// 💰 http.post(predicate, resolver)
39+
// 💰 HttpResponse.json({ code, amount, isLegacy })
40+
41+
render(<DiscountCodeForm />)
42+
43+
const discountInput = page.getByLabelText('Discount code')
44+
await discountInput.fill('LEGA2000')
45+
46+
const applyDiscountButton = page.getByRole('button', {
47+
name: 'Apply discount',
48+
})
49+
await applyDiscountButton.click()
50+
51+
// 🐨 Write an assertion that expects the text element
52+
// with the applied discount code to be visible on the page.
53+
//
54+
// 🐨 Write another assertion that expected the warning
55+
// to appear, notifying the user about the legacy discount code.
56+
// 💰 await expect.element(locator).toHaveTextContent(content)
57+
// 💰 page.getByRole('alert')
58+
// 💰 '"LEGA2000" is a legacy code. Discount amount halfed.'
59+
})
60+
61+
test('displays an error when fetching the discount fails', async ({
62+
// 🐨 Access the `worker` fixture here.
63+
}) => {
64+
// 🐨 Call `worker.use()` and describe another request handler.
65+
// This time, respond with a mocked 500 response to simulate
66+
// server error.
67+
// 💰 worker.use(handler)
68+
// 💰 http.post(predicate, resolver)
69+
// 💰 new HttpResponse(null, { status: 500 })
70+
71+
render(<DiscountCodeForm />)
72+
73+
const discountInput = page.getByLabelText('Discount code')
74+
await discountInput.fill('CODE1234')
75+
76+
const applyDiscountButton = page.getByRole('button', {
77+
name: 'Apply discount',
78+
})
79+
await applyDiscountButton.click()
80+
81+
// 🐨 Write an assertion that a notification is displayed,
82+
// saying that applying the discount code has failed.
83+
// 💰 await expect.element(locator).toHaveTextContent(content)
84+
// 💰 page.getByRole('alert')
85+
// 💰 'Failed to apply the discount code'
86+
})

0 commit comments

Comments
 (0)