Skip to content

Commit 70524b6

Browse files
committed
02/02: add exercise texts
1 parent 98fdbd5 commit 70524b6

File tree

15 files changed

+408
-3
lines changed

15 files changed

+408
-3
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Automatic fixtures
2+
3+
Your custom fixtures can introduce side effects that run before and after the fixture (i.e. after the test case is done). For example, let's take a look at this `createMockFile()` fixture:
4+
5+
```ts filename=test-extend.ts
6+
import fs from 'node:fs'
7+
import os from 'node:os'
8+
import path from 'node:path'
9+
10+
interface Fixtures {
11+
createMockFile: (content: string, filename: string) => Promise<void>
12+
}
13+
14+
test.extend({
15+
async createMockFile({}, use) {
16+
// 1. Prepare.
17+
const temporaryDirectory = await fs.promises.mkdtemp(
18+
path.join(os.tmpdir(), 'test-'),
19+
)
20+
21+
// 2. Use.
22+
await use(async (content, filename) => {
23+
const filePath = path.join(temporaryDirectory, filename)
24+
await fs.promises.writeFile(content, filePath)
25+
return filePath
26+
})
27+
28+
// 3. Clean up.
29+
await fs.promises.rmdir(temporaryDirectory, { recursive: true })
30+
},
31+
})
32+
```
33+
34+
You can spot three distinct phases in this fixture:
35+
36+
1. **Prepare**, where the temporary directory is created on disk;
37+
1. **Use**, where the `content` gets written at the `filename` provided by the test;
38+
1. **Clean up**, where the temporary directory gets deleted.
39+
40+
Then, if you wish to create a mock file in your test, you call the `createMockFile()` fixture:
41+
42+
```ts
43+
test('...', ({ createMockFile }) => {
44+
const filePath = await createMockFile('hello world', 'greeting.txt')
45+
})
46+
```
47+
48+
Once you access that fixture from the test context object, Vitest will know that you intend to use it. So it will run the "prepare" and "clean up" phases before the test starts and after it's done, respectively.
49+
50+
But what about the tests that _don't_ use that fixture?
51+
52+
Since they never reference it, _Vitest will skip its initalization_. That makes sense. If you don't need a temporary file for this test, there's no need to create and delete the temporary directory. Nothing is going to use it.
53+
54+
<callout-info>In other words, all fixtures are _lazy_ by default. Their implementation won't be called unless you reference that fixture in your test.</callout-info>
55+
56+
## Opt out from lazy initialization
57+
58+
That being said, not all fixtures are meant to be explicitly referenced.
59+
60+
For example, think of API mocking. Whether you're referencing it in your test or not, the network must still be mocked consistently for all test cases. The same is true for, say, a mocked database.
61+
62+
```ts
63+
test('throws if the user is not found', async () => {
64+
await expect(queryUser('abc-123')).resolves.toBeUndefined()
65+
})
66+
```
67+
68+
> Above, the `queryUser()` function looks up a user by ID in a database. This test case wants assert the behavior when the user by the given ID does not exist. For that, it's nice to utilize a default, empty state of the mock database.
69+
70+
If that database mock is implemented like a custom fixture, _it will never run for this test_ because this test never referenced it. The test case will fail and that's rather unfortunate.
71+
72+
```
73+
FAIL src/query-user.test.ts > throws if the user is not found
74+
AssertionError: promise rejected "Error: SQLITE_ERROR: no such table: users { …(2) }" instead of resolving
75+
```
76+
77+
This is where you can opt out from the lazy fixture initialization. You can tell Vitest that a certain fixture, like `createMockDatabase()`, should be instantiated regardless if any tests are referencing it.
78+
79+
## Your task
80+
81+
👨‍💼 In this exercise, your task is to modify the existing `createMockDatabase()` fixture to be initialized no matter if it's referenced in the tests.
82+
83+
🐨 First, go to the <InlineFile file="text-extend.js" /> file, find the declaration for that fixture, and follow the instructions to turn off the lazy initialization.
84+
85+
🐨 Next, find your next assignment in the <InlineFile file="src/query-user.test.ts" /> test file, where you will add a new test case for the `queryUser()` function. You will use the `createMockDatabase()` fixture to seed the mock database with a mock user before asserting that it can be found using the right ID.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"type": "module",
3+
"name": "exercises_02.context_02.problem.automatic-fixtures",
4+
"scripts": {
5+
"dev": "vite",
6+
"test": "vitest",
7+
"build": "vite build"
8+
},
9+
"devDependencies": {
10+
"vite": "^6.2.6",
11+
"vitest": "^3.1.1"
12+
},
13+
"dependencies": {
14+
"sqlite3": "^5.1.7"
15+
}
16+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import sqlite3, { type Database } from 'sqlite3'
2+
3+
export class DatabaseClient {
4+
constructor(private readonly db: Database) {}
5+
6+
public async query(query: string, params: Array<unknown>): Promise<any> {
7+
return new Promise((resolve, reject) => {
8+
this.db.get(query, params, function (error, row) {
9+
if (error) {
10+
return reject(error)
11+
}
12+
13+
resolve(row)
14+
})
15+
})
16+
}
17+
}
18+
19+
export const client = new DatabaseClient(new sqlite3.Database(':memory:'))
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { test } from '../test-extend'
2+
import { queryUser } from './query-user'
3+
4+
test('throws if the user is not found', async () => {
5+
await expect(queryUser('abc-123')).resolves.toBeUndefined()
6+
})
7+
8+
// 🐨 Add a new test case called "returns the user by id".
9+
// In this test, use the `createMockDatabase()` fixture to seed the mock
10+
// database with a mock user. Make it have the id "abc-123" and the name "John Doe".
11+
// 💰 test('...', () => {})
12+
// 💰 await createMockDatabase((db, done) => {})
13+
14+
// 🐨 Then, write an assertion that querying for the given user ID
15+
// indeed returns the mock user you've created.
16+
// 💰 await expect(fn(args)).resolves.toEqual({})
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { client } from './database'
2+
3+
export interface User {
4+
id: string
5+
name: string
6+
}
7+
8+
export async function queryUser(id: string): Promise<User> {
9+
return await client.query(`SELECT * FROM users WHERE id = ?`, [id])
10+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import * as fs from 'node:fs'
2+
import * as path from 'node:path'
3+
import { test as testBase } from 'vitest'
4+
import sqlite3, { type Database } from 'sqlite3'
5+
import * as databaseModule from './src/database'
6+
7+
interface Fixtures {
8+
createMockDatabase: (
9+
seed: (database: Database, handle: CallbackHandle) => void,
10+
) => Promise<void>
11+
}
12+
13+
type CallbackHandle = (error?: Error | null, ...args: Array<any>) => void
14+
15+
function toPromise<T>(init: (handle: CallbackHandle) => T): Promise<T> {
16+
return new Promise<T>((resolve, reject) => {
17+
const result = init((error) => {
18+
if (error) {
19+
return reject(error)
20+
}
21+
resolve(result)
22+
})
23+
})
24+
}
25+
26+
export const test = testBase.extend<Fixtures>({
27+
createMockDatabase: [
28+
async ({ task, onTestFinished }, use) => {
29+
const databasePath = `${task.file.filepath}-${task.id}.sqlite`
30+
31+
if (fs.existsSync(databasePath)) {
32+
await fs.promises.rm(databasePath)
33+
}
34+
35+
const mockDatabase = await toPromise((handle) => {
36+
return new sqlite3.Database(databasePath, handle)
37+
})
38+
39+
onTestFinished(async ({ task }) => {
40+
await toPromise((handle) => mockDatabase.close(handle))
41+
42+
if (task.type !== 'test') {
43+
return
44+
}
45+
46+
if (task.result?.state === 'pass') {
47+
await fs.promises.rm(databasePath)
48+
} else {
49+
task.result?.errors?.push({
50+
name: 'Mock database',
51+
message: 'See the database state:',
52+
codeFrame: path.relative(process.cwd(), databasePath),
53+
})
54+
}
55+
})
56+
57+
const clientSpy = vi
58+
.spyOn(databaseModule, 'client', 'get')
59+
.mockReturnValue(new databaseModule.DatabaseClient(mockDatabase))
60+
61+
await toPromise((handle) => {
62+
mockDatabase.run('CREATE TABLE users (id TEXT, name TEXT)', handle)
63+
})
64+
65+
await use((seed) => {
66+
return toPromise((handle) => {
67+
seed(mockDatabase, handle)
68+
})
69+
})
70+
71+
clientSpy.mockRestore()
72+
},
73+
// 🐨 Add an object here that will stand for this fixture's options.
74+
// Inside that object, set the `auto` property to `true.`
75+
// This will make the `createMockDatabase` fixture initialize even
76+
// if no tests are referencing it explicitly.
77+
// 💰 { key: value }
78+
],
79+
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "./tsconfig.base.json",
3+
"include": ["src/**/*"],
4+
"exclude": ["src/**/*.test.ts*"],
5+
"compilerOptions": {
6+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
7+
"jsx": "react-jsx"
8+
}
9+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"module": "ESNext",
5+
"useDefineForClassFields": true,
6+
"skipLibCheck": true,
7+
8+
/* Bundler mode */
9+
"moduleResolution": "bundler",
10+
"allowImportingTsExtensions": true,
11+
"isolatedModules": true,
12+
"moduleDetection": "force",
13+
"noEmit": true,
14+
"verbatimModuleSyntax": true,
15+
16+
/* Linting */
17+
"strict": true,
18+
"noUnusedLocals": false,
19+
"noUnusedParameters": false,
20+
"noFallthroughCasesInSwitch": true,
21+
"noUncheckedSideEffectImports": true
22+
}
23+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"files": [],
3+
"references": [
4+
{ "path": "./tsconfig.app.json" },
5+
{ "path": "./tsconfig.node.json" },
6+
{ "path": "./tsconfig.test.json" }
7+
]
8+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "./tsconfig.base.json",
3+
"compilerOptions": {
4+
"lib": ["ES2023"]
5+
},
6+
"include": ["vitest.config.ts"]
7+
}

0 commit comments

Comments
 (0)