Skip to content

Commit c98d1f2

Browse files
committed
03/05: add problem and solution texts
1 parent 576135e commit c98d1f2

File tree

8 files changed

+245
-52
lines changed

8 files changed

+245
-52
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
# Page navigation
22

3-
- **[ ] TODO: Make the discount code form access something from the router.**
3+
Components in your application don't exist in a vacuum. They are likely a part of a larger component tree that describes a page that lives at a certain _route_. Whether you are building a Single Page Application, or a Multi-Page Application, or even a Statically Generated Site, you are unlikely to escape routing.
44

5-
Components in your application don't exist in isolation. They are likely a part of a larger page that lives at a certain _route_. Whether you are building a Single Page Application, or a Multi-Page Application, or even a Statically Generated Site, you are unlikely to escape routing.
5+
What interests us from the testing perspective here is that components can _access router data_ and _navigate to other routes_. We have to account for both of those for reliable and resilient tests.
66

7-
What interest us from the testing perspective here is that components can _access router data_ and _navigate to other routes_. Both of those have to be accounted for during automated tests.
7+
While you were gone, I've added some routing to our application with React Router. The `<DiscountCodeForm />` now belongs to a certain route and contains a link to the cart page.
88

9-
While you were gone, I've added some routing to our application with React Router. The `<DiscountCodeForm />` now belongs to a certain route and contains a link to the Cart page. As usual, the best way for you to learn how to test this is through writing some tests!
9+
If you try running the tests now via `npm test`, you will be faced with the following error:
1010

11-
👨‍💼 Head to the <InlineFile file="./src/discount-code-form.browser.test.tsx">`discount-code-form.browser.test.tsx`</InlineFile> where you will have two tasks:
11+
```
12+
Cannot destructure property 'basename' of 'React10.useContext(...)' as it is null.
13+
❯ LinkWithRef /node_modules/.vite/deps/react-router.js:8171:11
14+
```
1215

13-
1. Refactor the existing tests by introducing a custom `wrapper` that makes it possible for our router-dependent component to render in isolation;
14-
1. Finish the newly added test case for the "Back to cart" link.
16+
This is a bit cryptic, but the stack trace here strongly suggests that the `<Link />` component is trying to read some context that is not available. In actuality, links cannot be rendered outside of the `<Router />` component, and we don't have that in our tests.
17+
18+
We've covered it in the previous exercises that component-level testing doesn't involve rendering the entire component tree so the `<Router>` part introduced in `<App />` is missing.
19+
20+
👨‍💼 In this exercise, you will solve this problem and restore order to the automated tests by creating a _custom wrapper_ for rendering our `<DiscountCodeForm />` component in tests. Follow the instructions in <InlineFile file="./src/discount-code-form.browser.test.tsx">`discount-code-form.browser.test.tsx`</InlineFile> to adjust the test setup and see the tests passing again.
21+
22+
👨‍💼 Once that's done, complete the newly added test case for the "Back to cart" link.
1523

1624
See you once you're done!
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
1+
import { BrowserRouter, Routes, Route } from 'react-router'
12
import { DiscountCodeForm } from './discount-code-form.js'
23

34
export function App() {
4-
return <DiscountCodeForm />
5+
return (
6+
<BrowserRouter>
7+
<Routes>
8+
<Route path="/" element={<DiscountCodeForm />} />
9+
<Route
10+
path="/cart"
11+
element={
12+
<div className="text-center">
13+
<h1 className="mb-2 text-4xl font-bold">Cart</h1>
14+
<p className="text-slate-600">This is a cart page.</p>
15+
</div>
16+
}
17+
/>
18+
<Route path="*" element={<p>Page not found</p>} />
19+
</Routes>
20+
</BrowserRouter>
21+
)
522
}

Diff for: exercises/03.best-practices/05.problem.page-navigation/src/discount-code-form.tsx

+17-29
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import { useReducer, useState, type FormEventHandler } from 'react'
2-
import { ... } from 'react-router'
3-
4-
interface Notification {
5-
type: 'error' | 'warning'
6-
text: string
7-
}
1+
import { useReducer, type FormEventHandler } from 'react'
2+
import { Link } from 'react-router'
3+
import { toast } from 'sonner'
84

95
export interface Discount {
106
code: string
@@ -70,32 +66,24 @@ export function DiscountCodeForm() {
7066
const [state, dispatch] = useReducer(discountFormReducer, {
7167
submitting: false,
7268
})
73-
const [notification, setNotification] = useState<Notification>()
74-
75-
const notify = (text: string, type: Notification['type'] = 'warning') => {
76-
setNotification({ type, text })
77-
setTimeout(() => setNotification(undefined), 5000)
78-
}
7969

8070
const handleApplyDiscount: FormEventHandler<HTMLFormElement> = async (
8171
event,
8272
) => {
8373
event.preventDefault()
84-
setNotification(undefined)
8574
dispatch({ type: 'submitting' })
8675

8776
const data = new FormData(event.currentTarget)
8877
const code = data.get('discountCode')
8978

9079
if (!code) {
91-
notify('Missing discount code', 'error')
80+
toast.error('Missing discount code')
9281
return
9382
}
9483

9584
if (typeof code !== 'string') {
96-
notify(
85+
toast.error(
9786
`Expected discount code to be a string but got ${typeof code}`,
98-
'error',
9987
)
10088
return
10189
}
@@ -105,11 +93,11 @@ export function DiscountCodeForm() {
10593
dispatch({ type: 'success', discount })
10694

10795
if (discount.isLegacy) {
108-
notify(`"${code}" is a legacy code. Discount amount halfed.`)
96+
toast.warning(`"${code}" is a legacy code. Discount amount halfed.`)
10997
}
11098
})
11199
.catch(() => {
112-
notify('Failed to apply the discount code', 'error')
100+
toast.error('Failed to apply the discount code')
113101
dispatch({ type: 'idle' })
114102
})
115103
}
@@ -125,7 +113,7 @@ export function DiscountCodeForm() {
125113
await removeDiscount(code)
126114
.catch((error) => {
127115
console.error(error)
128-
notify('Failed to remove the discount code', 'error')
116+
toast.error('Failed to remote the discount code')
129117
})
130118
.finally(() => {
131119
dispatch({ type: 'idle' })
@@ -184,17 +172,17 @@ export function DiscountCodeForm() {
184172
>
185173
Apply discount
186174
</button>
175+
176+
<p className="text-center">
177+
<Link
178+
to="/cart"
179+
className="text-sm font-medium text-slate-500 hover:underline"
180+
>
181+
Back to cart
182+
</Link>
183+
</p>
187184
</form>
188185
)}
189-
190-
{notification ? (
191-
<p
192-
role="alert"
193-
className={`animation-slide animate-slide-in fixed bottom-5 right-5 rounded-lg border px-5 py-2.5 font-medium ${notification.type === 'error' ? 'border-red-800/20 bg-red-200' : 'border-yellow-800/20 bg-yellow-200'}`}
194-
>
195-
{notification.text}
196-
</p>
197-
) : null}
198186
</section>
199187
)
200188
}

Diff for: exercises/03.best-practices/05.problem.page-navigation/src/main.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { StrictMode } from 'react'
22
import { createRoot } from 'react-dom/client'
3+
import { Toaster } from 'sonner'
34
import './index.css'
45
import { App } from './app.jsx'
56

@@ -13,6 +14,7 @@ async function enableMocking() {
1314
enableMocking().then(() => {
1415
createRoot(document.getElementById('root')!).render(
1516
<StrictMode>
17+
<Toaster richColors />
1618
<App />
1719
</StrictMode>,
1820
)
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,99 @@
11
# Page navigation
22

3-
...
3+
## Custom wrapper
44

5-
## Why not test the navigation?
5+
Okay, let's start by making sure that router-dependent components can render in isolation. For that, I am going to create a _custom wrapper_ function:
66

7-
- Not reliable on the integration level anywhere. There may be other elements rendered in the router tree that can affect how the pages render or even whether some elements on the page are interactive (imagine a random component rendering an `opacity:0` overlay over the "Back to cart" button. It's broken now!).
8-
- Clear responsibilities. Clear boundaries. The Golden Rule of Assertion.
7+
```tsx filename=discount-code-form.browser.test.tsx add=1,3-7
8+
import { MemoryRouter } from 'react-router'
99

10-
## When to test the navigation
10+
const wrapper: React.JSXElementConstructor<{
11+
children: React.ReactNode
12+
}> = ({ children }) => {
13+
return <MemoryRouter>{children}</MemoryRouter>
14+
}
15+
```
1116

12-
- E2E tests as a part of some user flows. Navigation would act as the implied assertion there, which is fantastic.
17+
The `wrapper` here is a regular React component that wraps any provided children in a `<MemoryRouter />` from `react-router`.
18+
19+
> 🦉 While our app uses the `<BrowserRouter />` component, our tests will rely on the `<MemoryRouter />` instead. It provides the same behavior as the browser router but keeps the routing state in-memory instead of the page URL.
20+
21+
Now, I will provide this component as the `wrapper` property on individual `render()` calls in my tests:
22+
23+
```tsx filename=discount-code-form.browser.test.tsx add=2
24+
render(<DiscountCodeForm />, {
25+
wrapper,
26+
})
27+
```
28+
29+
This will result in the following component tree being rendered:
30+
31+
```tsx nonumber nocopy
32+
<MemoryRouter>
33+
<DiscountCodeForm />
34+
</MemoryRouter>
35+
```
36+
37+
Now that my tested component has a parent router, it can access all the router-related information, like the `useNavigate()` hook without issues.
38+
39+
<callout-success>Don't be hesitant to create wrappers to suit the needs of individual tests. Custom wrappers is not something you usually reuse.</callout-success>
40+
41+
## Testing links
42+
43+
All that remains now is to finish the new test case for the "Back to cart" link.
44+
45+
As usual, I will render our component and provide it with the custom `wrapper`:
46+
47+
```tsx filename=discount-code-form.browser.test.tsx add=2
48+
test('displays the "Back to cart" link', async () => {
49+
render(<DiscountCodeForm />, { wrapper })
50+
})
51+
```
52+
53+
Next, let's find the link element on the page by its role and accessible name:
54+
55+
```tsx filename=discount-code-form.browser.test.tsx add=4
56+
test('displays the "Back to cart" link', async () => {
57+
render(<DiscountCodeForm />, { wrapper })
58+
59+
const backToCartLink = page.getByRole('link', { name: 'Back to cart' })
60+
})
61+
```
62+
63+
This is the moment where the user would click on the link and land on the cart page. **But our test is not going to do that**. Instead, all I will do is make sure that the link got rendered with the correct attributes:
64+
65+
```tsx filename=discount-code-form.browser.test.tsx add=5
66+
test('displays the "Back to cart" link', async () => {
67+
render(<DiscountCodeForm />, { wrapper })
68+
69+
const backToCartLink = page.getByRole('link', { name: 'Back to cart' })
70+
await expect.element(backToCartLink).toHaveAttribute('href', '/cart')
71+
})
72+
```
73+
74+
Let me explain.
75+
76+
Any navigation on the web consists of the source and the destination. In the case of our `<DiscountCodeForm />`, the source is the "Back to cart" link while the destination is the cart page. In other words, _there are two pages involved_ (thus the navigation).
77+
78+
**But we aren't testing pages here**. We are testing components. It is crucial we keep in mind what's within our component's power and what is not.
79+
80+
So, what is in our component's power?
81+
82+
- Render the link;
83+
- Make sure it leads to the right page.
84+
85+
What gets rendered on that page or whether the page exists at all isn't the responsibility of `<DiscountCodeForm />`. It cannot be responsible for the other end of this navigation, and so it cannot include it in its tests.
86+
87+
Ask yourself this: Do I want the `<DiscountCodeForm />` tests to fail if something is off on the cart page? You really don't. You would want the cart page tests to fail in that case, wouldn't you?
88+
89+
> This once again brings me to 📜 [The Golden Rule of Assertions](https://www.epicweb.dev/the-golden-rule-of-assertions), proving how invaluable it is when making decisions around your tests.
90+
91+
## Testing navigation
92+
93+
But where should you test the actual navigation then?
94+
95+
<callout-success>Test the actual navigation in end-to-end tests, and test is _implicitly_.</callout-success>
96+
97+
Getting from one page to another is a part of the user's journey. Naturally, both counterparts of the navigation would have to exist for that journey to complete. This is where you act as the user, click on links or buttons, and assert what page they land on.
98+
99+
**Testing navigation on the integration level is unreliable**. There may be other components in the router tree that affect the navigation. For example, some other route may render a `position:fixed` element with incorrect styles that would obstruct the link you are trying to click, making it inaccessible. You won't catch that rendering components in isolation. You will catch that in an end-to-end test.

Diff for: exercises/03.best-practices/05.solution.page-navigation/src/discount-code-form.browser.test.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,4 @@ test('displays the "Back to cart" link', async () => {
112112

113113
const backToCartLink = page.getByRole('link', { name: 'Back to cart' })
114114
await expect.element(backToCartLink).toHaveAttribute('href', '/cart')
115-
await expect.element(backToCartLink).toBeEnabled()
116115
})

Diff for: exercises/03.best-practices/05.solution.page-navigation/src/discount-code-form.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ export function DiscountCodeForm() {
8181
event,
8282
) => {
8383
event.preventDefault()
84-
setNotification(undefined)
8584
dispatch({ type: 'submitting' })
8685

8786
const data = new FormData(event.currentTarget)
@@ -110,6 +109,7 @@ export function DiscountCodeForm() {
110109
})
111110
.catch(() => {
112111
notify('Failed to apply the discount code', 'error')
112+
dispatch({ type: 'idle' })
113113
})
114114
}
115115

@@ -122,13 +122,13 @@ export function DiscountCodeForm() {
122122
const code = data.get('discountCode') as string
123123

124124
await removeDiscount(code)
125-
.then(() => {
126-
dispatch({ type: 'idle' })
127-
})
128125
.catch((error) => {
129126
console.error(error)
130127
notify('Failed to remove the discount code', 'error')
131128
})
129+
.finally(() => {
130+
dispatch({ type: 'idle' })
131+
})
132132
}
133133

134134
return (

0 commit comments

Comments
 (0)