Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

update epicshop, refactor <CodeFile /> usage #7

Merged
merged 2 commits into from
Oct 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,640 changes: 1,951 additions & 2,689 deletions epicshop/package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions epicshop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
"author": "Kent C. Dodds <[email protected]> (https://kentcdodds.com/)",
"license": "GPL-3.0-only",
"dependencies": {
"@epic-web/workshop-app": "^4.17.1",
"@epic-web/workshop-utils": "^4.17.1",
"@epic-web/workshop-app": "^5.7.1",
"@epic-web/workshop-utils": "^5.7.1",
"execa": "^9.3.1",
"fs-extra": "^11.2.0"
},
Expand Down
41 changes: 39 additions & 2 deletions exercises/02.functions/01.problem.mock-functions/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,46 @@

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()`:

<CodeFile file="emitter.ts" range="7-22" nocopy />
```ts filename=emitter.ts nocopy nonumber
/**
* Add listener for the given event.
*
* @example
* const emitter = new Emitter<{ foo: [number] }>()
* emitter.on('foo', (data) => console.log(data))
*/
public on<Event extends keyof EventMap>(
event: Event,
listener: (...args: EventMap[Event]) => void,
): this {
const prevListeners = this.listeners.get(event) || []
const nextListeners = prevListeners.concat(listener)
this.listeners.set(event, nextListeners)
return this
}
```

<CodeFile file="emitter.ts" range="24-44" nocopy />
```ts filename=emitter.ts nocopy nonumber
/**
* Emit an event. This invokes all the listeners
* assigned for that event.
* @return {boolean} True if the emitted event has listeners.
*
* @example
* const emitter = new Emitter<{ foo: [number] }>()
* emitter.emit('foo', 123)
*/
public emit<Event extends keyof EventMap>(
event: Event,
...data: EventMap[Event]
): boolean {
const listeners = this.listeners.get(event) || []
for (const listener of listeners) {
listener.apply(this, data)
}
return listeners.length > 0
}
```

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

Expand Down
20 changes: 15 additions & 5 deletions exercises/02.functions/01.solution.mock-functions/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

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.

<CodeFile file="emitter.test.ts" range="5" />
```ts filename=emitter.test.ts nonumber
const listener = vi.fn()
```

> 📜 Learn more about [`vi.fn()`](https://vitest.dev/api/vi.html#vi-fn) from the Vitest documentation.

Expand All @@ -14,11 +16,15 @@ Calling `vi.fn()` returns a function imbued with superpowers, one of which is th

Now that the mock function is ready, I will use it as a listener argument for the `hello` event:

<CodeFile file="emitter.test.ts" range="7" />
```ts filename=emitter.test.ts nonumber
emitter.on('hello', listener)
```

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.

<CodeFile file="emitter.test.ts" range="8" />
```ts filename=emitter.test.ts nonumber
emitter.emit('hello', 'John')
```

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

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

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

<CodeFile file="emitter.test.ts" range="10" />
```ts filename=emitter.test.ts nonumber
expect(listener).toHaveBeenCalledOnce()
```

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.

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:

<CodeFile file="emitter.test.ts" range="11" />
```ts filename=emitter.test.ts nonumber
expect(listener).toHaveBeenCalledWith('John')
```
24 changes: 22 additions & 2 deletions exercises/02.functions/02.problem.spies/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,33 @@

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:

<CodeFile file="user-service.ts" highlight="7" nocopy />
```ts filename=user-service.ts nocopy lines=7
import { Logger } from './logger.js'

export class UserService {
constructor(private logger: Logger) {}

public async createUser(initialState: { id: string; name: string }) {
this.logger.log('createUser', { id: initialState.id })

if (!/^\w{3}-\d{3}$/.test(initialState.id)) {
throw new Error(
`Failed to create a user: incorrect ID ("${initialState.id}")`,
)
}

return initialState
}
}
```

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.

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

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

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_.

Expand Down
16 changes: 12 additions & 4 deletions exercises/02.functions/02.solution.spies/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,31 @@

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:

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

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:

```ts
```ts nonumber
vi.spyOn(target, method)
```

> :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`.

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:

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

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

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

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

Expand Down
37 changes: 35 additions & 2 deletions exercises/02.functions/03.problem.mock-implementation/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,44 @@ In this one, we have an `OrderController` class responsible for handling orders

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

<CodeFile file="order-controller.ts" range="21-39" highlight="23" />
```ts filename=order-controller.ts nocopy nonumber lines=2-4
public createOrder(args: { cart: Cart }): Order {
const itemsOutOfStock = args.cart.filter(
(item) => !this.isItemInStock(item),
)

if (itemsOutOfStock.length > 0) {
const outOfSocketItemIds = itemsOutOfStock.map((item) => item.id)
throw new Error(
`Failed to create an order: found out of stock items (${outOfSocketItemIds.join(
', ',
)})`,
)
}

return {
cart: args.cart,
}
}
```

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).

<CodeFile file="order-controller.ts" range="1,44-51" highlight="45" />
```ts filename=order-controller.ts nocopy nonumber lines=7
import stockJson from './stock.json'

export class OrderController {
// ...

public isItemInStock(item: CartItem): boolean {
const itemInStock = stockJson.items.find((existingItem) => {
return existingItem.id === item.id
})

return itemInStock && itemInStock.quantity >= item.quantity
}
}
```

That's the responsibility Peter bestowed upon himself.

Expand Down
75 changes: 71 additions & 4 deletions exercises/02.functions/03.solution.mock-implementation/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,90 @@

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

<CodeFile file="order-controller.test.ts" range="3-6" highlight="6" />
```ts filename=order-controller.test.ts nonumber lines=4
test('creates an order when all items are in stock', () => {
const controller = new OrderController()

vi.spyOn(controller, 'isItemInStock').mockReturnValue(true)
```

<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>

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:

<CodeFile file="order-controller.test.ts" range="3-26" highlight="17-25" />
```ts filename=order-controller.test.ts nonumber lines=15-23
test('creates an order when all items are in stock', () => {
const controller = new OrderController()

vi.spyOn(controller, 'isItemInStock').mockReturnValue(true)

const cart: Cart = [
{
id: 4,
name: 'Porcelain vase',
quantity: 1,
},
]
const order = controller.createOrder({ cart })

expect(order).toEqual<Order>({
cart: [
{
id: 4,
name: 'Porcelain vase',
quantity: 1,
},
],
})
})
```

> I prefer keeping my assertions explicit so I _repeat_ the entire `cart` object within my `expect()` statement.

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_.

<CodeFile file="order-controller.test.ts" range="28-33" highlight="31-33" />
```ts filename=order-controller.test.ts nonumber lines=4-6
test('throws an error when one of the items is out of stock', () => {
const controller = new OrderController()

vi.spyOn(controller, 'isItemInStock').mockImplementation((item) => {
return item.id === 4
})
```

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.

<CodeFile file="order-controller.test.ts" range="28-56" highlight="53-55" />
```ts filename=order-controller.test.ts nonumber lines=26-28
test('throws an error when one of the items is out of stock', () => {
const controller = new OrderController()

vi.spyOn(controller, 'isItemInStock').mockImplementation((item) => {
return item.id === 4
})

const cart: Cart = [
{
id: 4,
name: 'Porcelain vase',
quantity: 1,
},
{
id: 5,
name: 'Sofa',
quantity: 3,
},
{
id: 6,
name: 'Microwave',
quantity: 1,
},
]

expect(() => controller.createOrder({ cart })).toThrowError(
'Failed to create an order: found out of stock items (5, 6)',
)
})
```

## Alternative approach

Expand Down
39 changes: 37 additions & 2 deletions exercises/03.date-and-time/01.problem.date-time/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,42 @@

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.

<CodeFile file="get-relative-time.ts" />
```ts filename=get-relative-time.ts
/**
* Return a relative time string from a given date.
*/
export function getRelativeTime(date: Date): string {
const delta =
Math.floor(Date.now() / 1000) - Math.floor(date.getTime() / 1000)

/**
* Use the standard `Intl.RelativeTimeFormat` API to produce human-friendly relative time strings.
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat
*/
const formatter = new Intl.RelativeTimeFormat('en', { style: 'long' })

switch (true) {
case delta < 60: {
return `Just now`
}
case delta < 3600: {
return formatter.format(-Math.floor(delta / 60), 'minute')
}
case delta < 86_400: {
return formatter.format(-Math.floor(delta / 3600), 'hour')
}
case delta < 2_620_800: {
return formatter.format(-Math.floor(delta / 86_400), 'day')
}
case delta < 31_449_600: {
return formatter.format(-Math.floor(delta / 2_620_800), 'month')
}
default: {
return formatter.format(-Math.floor(delta / 31_449_600), 'year')
}
}
}
```

> 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!

Expand All @@ -14,7 +49,7 @@ Since mocking time is such a common use case, Vitest gives you a more comfortabl

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:

```ts
```ts nonumber
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-06-01 00:00:00.000Z'))

Expand Down
Loading
Loading