Skip to content

Commit 129a350

Browse files
authored
update epicshop, refactor <CodeFile /> usage (#7)
1 parent 3e8333f commit 129a350

File tree

31 files changed

+2782
-2825
lines changed

31 files changed

+2782
-2825
lines changed

epicshop/package-lock.json

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

epicshop/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
"author": "Kent C. Dodds <[email protected]> (https://kentcdodds.com/)",
99
"license": "GPL-3.0-only",
1010
"dependencies": {
11-
"@epic-web/workshop-app": "^4.17.1",
12-
"@epic-web/workshop-utils": "^4.17.1",
11+
"@epic-web/workshop-app": "^5.7.1",
12+
"@epic-web/workshop-utils": "^5.7.1",
1313
"execa": "^9.3.1",
1414
"fs-extra": "^11.2.0"
1515
},

exercises/02.functions/01.problem.mock-functions/README.mdx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,46 @@
44

55
We have an `Emitter` implementation that can add listeners to events and call those listeners whenever a matching event is emitted. This behavior is achieved by implementing two core methods on the emitter: `.on()` and `.emit()`:
66

7-
<CodeFile file="emitter.ts" range="7-22" nocopy />
7+
```ts filename=emitter.ts nocopy nonumber
8+
/**
9+
* Add listener for the given event.
10+
*
11+
* @example
12+
* const emitter = new Emitter<{ foo: [number] }>()
13+
* emitter.on('foo', (data) => console.log(data))
14+
*/
15+
public on<Event extends keyof EventMap>(
16+
event: Event,
17+
listener: (...args: EventMap[Event]) => void,
18+
): this {
19+
const prevListeners = this.listeners.get(event) || []
20+
const nextListeners = prevListeners.concat(listener)
21+
this.listeners.set(event, nextListeners)
22+
return this
23+
}
24+
```
825

9-
<CodeFile file="emitter.ts" range="24-44" nocopy />
26+
```ts filename=emitter.ts nocopy nonumber
27+
/**
28+
* Emit an event. This invokes all the listeners
29+
* assigned for that event.
30+
* @return {boolean} True if the emitted event has listeners.
31+
*
32+
* @example
33+
* const emitter = new Emitter<{ foo: [number] }>()
34+
* emitter.emit('foo', 123)
35+
*/
36+
public emit<Event extends keyof EventMap>(
37+
event: Event,
38+
...data: EventMap[Event]
39+
): boolean {
40+
const listeners = this.listeners.get(event) || []
41+
for (const listener of listeners) {
42+
listener.apply(this, data)
43+
}
44+
return listeners.length > 0
45+
}
46+
```
1047

1148
Here's how this emitter is used in our application:
1249

exercises/02.functions/01.solution.mock-functions/README.mdx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
In the `emitter.test.ts`, I will start from creating the `listener` function but it won't be a regular JavaScript function. Instead, I will use the `vi.fn()` API from Vitest, which creates a _special_ kind of function.
66

7-
<CodeFile file="emitter.test.ts" range="5" />
7+
```ts filename=emitter.test.ts nonumber
8+
const listener = vi.fn()
9+
```
810

911
> 📜 Learn more about [`vi.fn()`](https://vitest.dev/api/vi.html#vi-fn) from the Vitest documentation.
1012
@@ -14,11 +16,15 @@ Calling `vi.fn()` returns a function imbued with superpowers, one of which is th
1416
1517
Now that the mock function is ready, I will use it as a listener argument for the `hello` event:
1618

17-
<CodeFile file="emitter.test.ts" range="7" />
19+
```ts filename=emitter.test.ts nonumber
20+
emitter.on('hello', listener)
21+
```
1822

1923
Everything up to this point was the setup for this test. The action here would be calling the `.emit()` method to emit the `hello` event because the listeners are only called when the respective event gets emitted.
2024

21-
<CodeFile file="emitter.test.ts" range="8" />
25+
```ts filename=emitter.test.ts nonumber
26+
emitter.emit('hello', 'John')
27+
```
2228

2329
I expect two things to happen once the `hello` event is emitted:
2430

@@ -29,10 +35,14 @@ The `expect()` function from Vitest comes with handy assertions to describe both
2935

3036
First, I will use the `.toHaveBeenCalledOnce()` assertion:
3137

32-
<CodeFile file="emitter.test.ts" range="10" />
38+
```ts filename=emitter.test.ts nonumber
39+
expect(listener).toHaveBeenCalledOnce()
40+
```
3341

3442
This will only pass if the `listener` function has been called exactly once. If it gets called any other number of times, it's a bug, and the test will fail.
3543

3644
In the same fashion, I will apply the [`.toHaveBeenCalledWith()`](https://vitest.dev/api/expect.html#tohavebeencalledwith) assertion to check that the `listener` function gets called with the right data:
3745

38-
<CodeFile file="emitter.test.ts" range="11" />
46+
```ts filename=emitter.test.ts nonumber
47+
expect(listener).toHaveBeenCalledWith('John')
48+
```

exercises/02.functions/02.problem.spies/README.mdx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,33 @@
44

55
Testing the intentions around side effects is always tricky. Like this `UserService` class that's supposed to log out the `createUser` event whenever a new user is created:
66

7-
<CodeFile file="user-service.ts" highlight="7" nocopy />
7+
```ts filename=user-service.ts nocopy lines=7
8+
import { Logger } from './logger.js'
9+
10+
export class UserService {
11+
constructor(private logger: Logger) {}
12+
13+
public async createUser(initialState: { id: string; name: string }) {
14+
this.logger.log('createUser', { id: initialState.id })
15+
16+
if (!/^\w{3}-\d{3}$/.test(initialState.id)) {
17+
throw new Error(
18+
`Failed to create a user: incorrect ID ("${initialState.id}")`,
19+
)
20+
}
21+
22+
return initialState
23+
}
24+
}
25+
```
826

927
To make things more complicated, logging out the event is not the responsibility of the `UserService` class. Instead, it relies on the `Logger` class and its `.log()` method to do that. So while the actual logging logic is irrelevant in the context of `UserService`, we still have to test the intention of it _calling_ `logger.log()` with the correct arguments.
1028

1129
Luckily, the class accepts a `logger` instance as an argument:
1230

13-
<CodeFile file="user-service.ts" range="4" nocopy />
31+
```ts filename=user-service.ts nocopy nonumber
32+
constructor(private logger: Logger) {}
33+
```
1434

1535
This means we can use a _dependency injection_ to provide it with whichever logger instance we want in test. For example, a logger that we will 🕵️ _spy on_.
1636

exercises/02.functions/02.solution.spies/README.mdx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,31 @@
44

55
Let's start by creating a _spy_ for the `logger.log()` method using the [`vi.spyOn()`](https://vitest.dev/api/vi.html#vi-spyon) function from Vitest:
66

7-
<CodeFile file="user-service.test.ts" range="5-7" highlight="6" />
7+
```ts filename=user-service.test.ts nonumber lines=2
8+
const logger = new Logger()
9+
vi.spyOn(logger, 'log')
10+
const service = new UserService(logger)
11+
```
812

913
The `spyOn()` function has a bit of unusual call signature since it _will not_ accept the `logger.log` method reference directly. Instead, it expects the object that owns the method first, and then the method name as a string:
1014

11-
```ts
15+
```ts nonumber
1216
vi.spyOn(target, method)
1317
```
1418

1519
> :owl: The reason behind this call signature is how `.spyOn()` works under the hood. It redefines the `method` on the `target` object with a spied version of that method! You can think of it as `target[method] = methodSpy`.
1620
1721
Now that I have the spy ready and recording, I can write an assertion on that method being called with the correct arguments using the [`.toHaveBeenCalledWith()`](https://vitest.dev/api/expect.html#tohavebeencalledwith) assertion:
1822

19-
<CodeFile file="user-service.test.ts" range="14" />
23+
```ts filename=user-service.test.ts nonumber
24+
expect(logger.log).toHaveBeenCalledWith('createUser', { id: 'abc-123' })
25+
```
2026

2127
Finally, I will make sure that `logger.log()` has been called exactly once:
2228

23-
<CodeFile file="user-service.test.ts" range="15" />
29+
```ts filename=user-service.test.ts nonumber
30+
expect(logger.log).toHaveBeenCalledOnce()
31+
```
2432

2533
To validate the test, I will run `npm test`:
2634

exercises/02.functions/03.problem.mock-implementation/README.mdx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,44 @@ In this one, we have an `OrderController` class responsible for handling orders
99

1010
The `.createOrder()` method relies on another `.isItemInStock()` method of the same class to check any given cart item's availability.
1111

12-
<CodeFile file="order-controller.ts" range="21-39" highlight="23" />
12+
```ts filename=order-controller.ts nocopy nonumber lines=2-4
13+
public createOrder(args: { cart: Cart }): Order {
14+
const itemsOutOfStock = args.cart.filter(
15+
(item) => !this.isItemInStock(item),
16+
)
17+
18+
if (itemsOutOfStock.length > 0) {
19+
const outOfSocketItemIds = itemsOutOfStock.map((item) => item.id)
20+
throw new Error(
21+
`Failed to create an order: found out of stock items (${outOfSocketItemIds.join(
22+
', ',
23+
)})`,
24+
)
25+
}
26+
27+
return {
28+
cart: args.cart,
29+
}
30+
}
31+
```
1332

1433
The data for the item availability itself comes from the `stock.json` file that 👨‍💼 Peter the Project Manager does a faithful job of updating on a daily basis (think of this JSON file as any source of data—e.g. a database).
1534

16-
<CodeFile file="order-controller.ts" range="1,44-51" highlight="45" />
35+
```ts filename=order-controller.ts nocopy nonumber lines=7
36+
import stockJson from './stock.json'
37+
38+
export class OrderController {
39+
// ...
40+
41+
public isItemInStock(item: CartItem): boolean {
42+
const itemInStock = stockJson.items.find((existingItem) => {
43+
return existingItem.id === item.id
44+
})
45+
46+
return itemInStock && itemInStock.quantity >= item.quantity
47+
}
48+
}
49+
```
1750

1851
That's the responsibility Peter bestowed upon himself.
1952

exercises/02.functions/03.solution.mock-implementation/README.mdx

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,90 @@
44

55
Let's start by going to the first test case for our `OrderController` and spying on the `isItemInStock` method of the created `controller` instance:
66

7-
<CodeFile file="order-controller.test.ts" range="3-6" highlight="6" />
7+
```ts filename=order-controller.test.ts nonumber lines=4
8+
test('creates an order when all items are in stock', () => {
9+
const controller = new OrderController()
10+
11+
vi.spyOn(controller, 'isItemInStock').mockReturnValue(true)
12+
```
813
914
<callout-info>Since the object I'm spying on (`controller`) is scoped to this particular test, there's no need to introduce any test setup to reset the mock.</callout-info>
1015
1116
By using [`.mockReturnValue()`](https://vitest.dev/api/mock.html#mockreturnvalue), I am forcing the `.isItemInStock()` method to always return true when run in this test. With that behavior fixed, I can write the assertions around how the new order should be created:
1217
13-
<CodeFile file="order-controller.test.ts" range="3-26" highlight="17-25" />
18+
```ts filename=order-controller.test.ts nonumber lines=15-23
19+
test('creates an order when all items are in stock', () => {
20+
const controller = new OrderController()
21+
22+
vi.spyOn(controller, 'isItemInStock').mockReturnValue(true)
23+
24+
const cart: Cart = [
25+
{
26+
id: 4,
27+
name: 'Porcelain vase',
28+
quantity: 1,
29+
},
30+
]
31+
const order = controller.createOrder({ cart })
32+
33+
expect(order).toEqual<Order>({
34+
cart: [
35+
{
36+
id: 4,
37+
name: 'Porcelain vase',
38+
quantity: 1,
39+
},
40+
],
41+
})
42+
})
43+
```
1444
1545
> I prefer keeping my assertions explicit so I _repeat_ the entire `cart` object within my `expect()` statement.
1646
1747
In the next test case, I'd like to do things a little differently. I will still spy on the `.isItemInStock()` method, but instead of mocking its return value, I will _mock that method's implementation_.
1848
19-
<CodeFile file="order-controller.test.ts" range="28-33" highlight="31-33" />
49+
```ts filename=order-controller.test.ts nonumber lines=4-6
50+
test('throws an error when one of the items is out of stock', () => {
51+
const controller = new OrderController()
52+
53+
vi.spyOn(controller, 'isItemInStock').mockImplementation((item) => {
54+
return item.id === 4
55+
})
56+
```
2057
2158
By mocking the implementation, I am creating a mock with conditional behavior. It will behave differently based on the cart `item` that's being checked. Only the item with `id` that equals `4` will be considered in stock. This way, I am able to reproduce the error throwing logic as well as assert on the `controller` class including the right item IDs in the error message.
2259
23-
<CodeFile file="order-controller.test.ts" range="28-56" highlight="53-55" />
60+
```ts filename=order-controller.test.ts nonumber lines=26-28
61+
test('throws an error when one of the items is out of stock', () => {
62+
const controller = new OrderController()
63+
64+
vi.spyOn(controller, 'isItemInStock').mockImplementation((item) => {
65+
return item.id === 4
66+
})
67+
68+
const cart: Cart = [
69+
{
70+
id: 4,
71+
name: 'Porcelain vase',
72+
quantity: 1,
73+
},
74+
{
75+
id: 5,
76+
name: 'Sofa',
77+
quantity: 3,
78+
},
79+
{
80+
id: 6,
81+
name: 'Microwave',
82+
quantity: 1,
83+
},
84+
]
85+
86+
expect(() => controller.createOrder({ cart })).toThrowError(
87+
'Failed to create an order: found out of stock items (5, 6)',
88+
)
89+
})
90+
```
2491
2592
## Alternative approach
2693

exercises/03.date-and-time/01.problem.date-time/README.mdx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,42 @@
44

55
In our application, we need to display a relative time label, like "1 minute ago", "3 days ago", etc. We've created a `getRelativeTime()` function to achieve that. It takes any given date and calculates how far ago it was compared to `Date.now()`, returning a formatted string.
66

7-
<CodeFile file="get-relative-time.ts" />
7+
```ts filename=get-relative-time.ts
8+
/**
9+
* Return a relative time string from a given date.
10+
*/
11+
export function getRelativeTime(date: Date): string {
12+
const delta =
13+
Math.floor(Date.now() / 1000) - Math.floor(date.getTime() / 1000)
14+
15+
/**
16+
* Use the standard `Intl.RelativeTimeFormat` API to produce human-friendly relative time strings.
17+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat
18+
*/
19+
const formatter = new Intl.RelativeTimeFormat('en', { style: 'long' })
20+
21+
switch (true) {
22+
case delta < 60: {
23+
return `Just now`
24+
}
25+
case delta < 3600: {
26+
return formatter.format(-Math.floor(delta / 60), 'minute')
27+
}
28+
case delta < 86_400: {
29+
return formatter.format(-Math.floor(delta / 3600), 'hour')
30+
}
31+
case delta < 2_620_800: {
32+
return formatter.format(-Math.floor(delta / 86_400), 'day')
33+
}
34+
case delta < 31_449_600: {
35+
return formatter.format(-Math.floor(delta / 2_620_800), 'month')
36+
}
37+
default: {
38+
return formatter.format(-Math.floor(delta / 31_449_600), 'year')
39+
}
40+
}
41+
}
42+
```
843

944
> We are also using the standard [`Intl.RelativeTimeFormat()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat) API to help us format the time: "1 minute ago" but "5 minute<u>s</u> ago". Give it a read if you're not familiar with it!
1045
@@ -14,7 +49,7 @@ Since mocking time is such a common use case, Vitest gives you a more comfortabl
1449

1550
To reliably test our `getRelativeTime()` function, we have to _freeze the date_ in our test. For example, by setting it to always be the 1st of June 2024:
1651

17-
```ts
52+
```ts nonumber
1853
vi.useFakeTimers()
1954
vi.setSystemTime(new Date('2024-06-01 00:00:00.000Z'))
2055

0 commit comments

Comments
 (0)