From 9a288af6aafa0e3ec9d2c5ebc2aee7a57987f329 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 14 Apr 2026 14:44:14 +0200 Subject: [PATCH] docs: improve learning section (#10140) --- docs/guide/learn/async.md | 57 ++++++++++----- docs/guide/learn/debugging-tests.md | 14 +++- docs/guide/learn/matchers.md | 48 ++++++++++++- docs/guide/learn/mock-functions.md | 8 ++- docs/guide/learn/setup-teardown.md | 29 +++++--- docs/guide/learn/snapshots.md | 35 ++++++++++ docs/guide/learn/testing-in-practice.md | 6 +- docs/guide/learn/writing-tests.md | 92 +++++++++++++++++++++---- 8 files changed, 243 insertions(+), 46 deletions(-) diff --git a/docs/guide/learn/async.md b/docs/guide/learn/async.md index 02d137dcc..9ef3fa01e 100644 --- a/docs/guide/learn/async.md +++ b/docs/guide/learn/async.md @@ -49,6 +49,41 @@ test('rejects with an error', async () => { Don't forget the `await` before `expect`. Vitest will detect unawaited assertions and print a warning at the end of the test, but it's best to always include `await` explicitly. Vitest will also wait for all pending promises in `Promise.all` before starting the next test, but relying on this behavior makes tests harder to understand. ::: +## Assertion Counting + +With async code, there's a subtle risk: an assertion inside a callback or `.then()` chain might never execute, and the test would still pass because no assertion failed. [`expect.hasAssertions()`](/api/expect#hasassertions) guards against this by verifying that at least one assertion ran during the test: + +```js +test('callback is invoked', async () => { + expect.hasAssertions() + + const data = await fetchData() + data.items.forEach((item) => { + expect(item.id).toBeDefined() + }) + // if data.items is empty, the test fails instead of silently passing +}) +``` + +When you know exactly how many assertions should run, [`expect.assertions(n)`](/api/expect#assertions) is more precise: + +```js +test('both callbacks are called', async () => { + expect.assertions(2) + + await Promise.all([ + fetchUser(1).then(user => expect(user.name).toBe('Alice')), + fetchUser(2).then(user => expect(user.name).toBe('Bob')), + ]) +}) +``` + +In most cases, `async`/`await` with direct assertions is clear enough and you don't need assertion counting. It's most useful when assertions are inside callbacks, loops, or conditional branches where you want to guarantee they actually executed. + +::: tip +If you want every test in your project to require at least one assertion, enable [`expect.requireAssertions`](/config/expect#expect-requireassertions) in your config instead of adding `expect.hasAssertions()` to each test manually. +::: + ## Callbacks Some older APIs use callbacks instead of promises. Since Vitest works with promises, the simplest approach is to wrap the callback in a `Promise`: @@ -68,6 +103,10 @@ test('the data is peanut butter', async () => { This pattern works for any callback-based API. Pass `resolve` as the success callback, and the test will wait until the callback is invoked. +::: tip +Most modern Node.js APIs (such as `fs/promises` and `fetch`) support promises natively, so you can use `async`/`await` directly. The callback wrapping pattern above is mainly useful for older libraries that haven't adopted promises yet. +::: + ## Timeouts By default, each test has a 5-second timeout. If a test takes longer than that (perhaps because a promise never resolves, or a network request hangs), it will fail with a timeout error. This prevents your test suite from getting stuck indefinitely. @@ -92,24 +131,6 @@ export default defineConfig({ }) ``` -## Concurrent Tests - -By default, tests within a file run one after another. This is usually what you want, especially when tests share setup code. But if you have many independent async tests that each spend most of their time waiting (on network, disk, timers, etc.), running them concurrently with [`test.concurrent`](/api/test#concurrent) can significantly speed things up: - -```js -test.concurrent('first async test', async () => { - const result = await fetchUser(1) - expect(result.name).toBe('Alice') -}) - -test.concurrent('second async test', async () => { - const result = await fetchUser(2) - expect(result.name).toBe('Bob') -}) -``` - -See the [Parallelism](/guide/parallelism) guide for the full picture of how Vitest runs tests in parallel, both across files and within them. - ## Unhandled Rejections By default, Vitest reports unhandled promise rejections as errors in the test run. If a promise rejects somewhere in your code and nothing catches it, the test run will fail, even if all your assertions passed. This is intentional: unhandled rejections usually indicate real bugs, like a forgotten `await` or a fire-and-forget promise that silently fails. diff --git a/docs/guide/learn/debugging-tests.md b/docs/guide/learn/debugging-tests.md index ccf66a310..c3d461e78 100644 --- a/docs/guide/learn/debugging-tests.md +++ b/docs/guide/learn/debugging-tests.md @@ -53,6 +53,12 @@ test.only('sets the default role', () => { }) ``` +If you have many failures and want to focus on the first one, use [`--bail`](/config/bail) to stop after a set number of failures: + +```bash +vitest --bail 1 +``` + If the test passes when run alone but fails when run with others, you have a test isolation problem (more on that below). If it fails even when run alone, the issue is in the test itself or the code it's testing. ## Common Causes of Failures @@ -188,7 +194,13 @@ This shows every test individually (not just the files), which can help spot pat ### Attaching a Debugger -For more complex issues where you need to step through code line by line, you can attach a debugger. See the [Debugging](/guide/debugging) guide for setup instructions for VS Code, IntelliJ, and Chrome DevTools. +For more complex issues where you need to step through code line by line, you can run Vitest with the `--inspect-brk` flag and attach a debugger. The `--no-file-parallelism` flag ensures tests run in the main thread so breakpoints work reliably: + +```bash +vitest --inspect-brk --no-file-parallelism +``` + +Then attach from VS Code, IntelliJ, or Chrome DevTools (`chrome://inspect`). See the [Debugging](/guide/debugging) guide for detailed setup instructions for each editor. ## Getting Help diff --git a/docs/guide/learn/matchers.md b/docs/guide/learn/matchers.md index d6c31bc92..bb7ab79dc 100644 --- a/docs/guide/learn/matchers.md +++ b/docs/guide/learn/matchers.md @@ -50,8 +50,29 @@ test('toBe vs toEqual', () => { }) ``` +There's also [`toStrictEqual`](/api/expect#tostrictequal), which is stricter than `toEqual` in three ways: it checks `undefined` properties, distinguishes sparse arrays from `undefined` values, and verifies that objects have the same type (not just the same shape): + +```js +test('toEqual vs toStrictEqual', () => { + // toEqual ignores undefined properties + expect({ a: 1 }).toEqual({ a: 1, b: undefined }) + + // toStrictEqual catches them + expect({ a: 1 }).not.toStrictEqual({ a: 1, b: undefined }) + + // toEqual doesn't check object types + class User { + constructor(name) { + this.name = name + } + } + expect(new User('Alice')).toEqual({ name: 'Alice' }) + expect(new User('Alice')).not.toStrictEqual({ name: 'Alice' }) +}) +``` + ::: tip -A good rule of thumb: use `toBe` for primitives (numbers, strings, booleans) and `toEqual` for objects and arrays. +A good rule of thumb: use `toBe` for primitives (numbers, strings, booleans), `toEqual` for comparing structure, and `toStrictEqual` when you also care about types and explicit `undefined` values. ::: You can also negate any matcher by inserting `.not` before it. This is useful when you want to verify that something is *not* the case: @@ -193,6 +214,31 @@ test('object has property', () => { }) ``` +## Asymmetric Matchers + +Sometimes you don't know the exact value, but you know its type or shape. Asymmetric matchers let you describe what a value should *look like* without pinning down the exact content. They work inside any matcher that does deep comparison, like `toEqual` or `toMatchObject`: + +```js +test('user has the right shape', () => { + const user = createUser('Alice') + + expect(user).toEqual({ + id: expect.any(Number), + name: 'Alice', + email: expect.stringContaining('@'), + roles: expect.arrayContaining(['viewer']), + }) +}) +``` + +The most common asymmetric matchers are: + +- [`expect.any(Constructor)`](/api/expect#expect-any) matches any value created with the given constructor (e.g., `Number`, `String`, `Array`) +- [`expect.stringContaining(str)`](/api/expect#expect-stringcontaining) matches a string that includes the given substring +- [`expect.stringMatching(regex)`](/api/expect#expect-stringmatching) matches a string against a regular expression +- [`expect.arrayContaining(arr)`](/api/expect#expect-arraycontaining) matches an array that includes all items in the expected array (order doesn't matter, extra items are allowed) +- [`expect.objectContaining(obj)`](/api/expect#expect-objectcontaining) matches an object that includes at least the specified properties + ## Exceptions To verify that a function throws an error, use [`toThrow`](/api/expect#tothrow). You need to wrap the call in another function so that Vitest can catch the error instead of letting it crash the test: diff --git a/docs/guide/learn/mock-functions.md b/docs/guide/learn/mock-functions.md index 593ae4cef..2892ff7b2 100644 --- a/docs/guide/learn/mock-functions.md +++ b/docs/guide/learn/mock-functions.md @@ -114,6 +114,10 @@ test('inspecting mock calls', () => { expect(greet).toHaveBeenCalledWith('Alice') expect(greet).toHaveBeenCalledWith('Bob', 'Charlie') + // Check the arguments of a specific call by position + expect(greet).toHaveBeenNthCalledWith(1, 'Alice') + expect(greet).toHaveBeenLastCalledWith('Bob', 'Charlie') + // Access the raw call data expect(greet.mock.calls).toEqual([ ['Alice'], @@ -267,8 +271,8 @@ test('mock a module', () => { [`vi.mock`](/api/vi#vi-mock) calls are hoisted to the top of the file. They run before any imports. This means the mocked version is in place by the time your test code runs. ::: -::: tip -Notice that we pass `import('./db.js')` instead of a plain string `'./db.js'`. When you use `import()`, TypeScript can infer the module's types, so the factory function's return value is type-checked and `importOriginal` returns the correctly typed module. As a bonus, if you rename or move the file in your IDE, the import path will be updated automatically. If you use a string, you lose both the type safety and the automatic refactoring. +::: warning +Always pass `import('./db.js')` rather than a plain string `'./db.js'`. When you use `import()`, TypeScript can infer the module's types, so the factory function's return value is type-checked and `importOriginal` returns the correctly typed module. As a bonus, if you rename or move the file in your IDE, the import path will be updated automatically. If you use a string, you lose both the type safety and the automatic refactoring. ::: Vitest has comprehensive guides for specific mocking scenarios: diff --git a/docs/guide/learn/setup-teardown.md b/docs/guide/learn/setup-teardown.md index 49c901b5c..2eacac656 100644 --- a/docs/guide/learn/setup-teardown.md +++ b/docs/guide/learn/setup-teardown.md @@ -121,16 +121,20 @@ When you have hooks at multiple levels, it's helpful to understand the order the import { afterAll, afterEach, beforeAll, beforeEach, describe, test } from 'vitest' beforeAll(() => console.log('1 - beforeAll')) -afterAll(() => console.log('6 - afterAll')) +afterAll(() => console.log('8 - afterAll')) beforeEach(() => console.log('2 - beforeEach')) -afterEach(() => console.log('4 - afterEach')) +afterEach(() => console.log('5 - afterEach')) describe('suite', () => { beforeEach(() => console.log('3 - inner beforeEach')) - afterEach(() => console.log('3.5 - inner afterEach')) + afterEach(() => console.log('4 - inner afterEach')) - test('example', () => { - console.log(' test') + test('first test', () => { + console.log(' first test') + }) + + test('second test', () => { + console.log(' second test') }) }) ``` @@ -141,13 +145,18 @@ This produces the following output: 1 - beforeAll 2 - beforeEach 3 - inner beforeEach - test -3.5 - inner afterEach -4 - afterEach -6 - afterAll + first test +4 - inner afterEach +5 - afterEach +2 - beforeEach +3 - inner beforeEach + second test +4 - inner afterEach +5 - afterEach +8 - afterAll ``` -Notice the pattern: outer `beforeEach` runs first (setting up the broadest context), then inner `beforeEach` runs (narrowing the context). After the test, the order reverses: inner `afterEach` cleans up the narrow context first, then outer `afterEach` handles the broader cleanup. +Notice the pattern: `beforeAll` and `afterAll` run once for the entire suite, while `beforeEach` and `afterEach` repeat for every test. Within each test, outer `beforeEach` runs first (setting up the broadest context), then inner `beforeEach` runs (narrowing the context). After the test, the order reverses: inner `afterEach` cleans up the narrow context first, then outer `afterEach` handles the broader cleanup. ## Cleanup with `onTestFinished` diff --git a/docs/guide/learn/snapshots.md b/docs/guide/learn/snapshots.md index cf7445571..9b0ed9d8e 100644 --- a/docs/guide/learn/snapshots.md +++ b/docs/guide/learn/snapshots.md @@ -89,6 +89,10 @@ Now the expected output lives right next to the code that produces it. You can r Inline snapshots are great for small, focused values. For large outputs (like a full HTML page), external snapshots or file snapshots are a better fit. +::: tip +Unlike external snapshots, inline snapshots don't create separate `.snap` files. The expected value is stored directly in your test file as the argument to `toMatchInlineSnapshot()`, so there's nothing extra to commit. +::: + ## Updating Snapshots When you intentionally change the output of your code, existing snapshots will be outdated and the tests will fail. This is by design; it's the whole point of snapshot testing. But once you've verified that the new output is correct, you need to update the snapshots. @@ -136,6 +140,37 @@ On the other hand, snapshots are not always the best tool. If the output changes The general rule: use snapshots when you want to protect against *any* change in the output, and use targeted assertions when you only care about *specific* properties. +## Handling Dynamic Values + +If your output includes values that change every run (like timestamps or IDs), you can use property matchers to pin the structure while ignoring volatile fields. Pass an object with asymmetric matchers as the first argument to `toMatchSnapshot()` or `toMatchInlineSnapshot()`: + +```js +test('user snapshot with dynamic fields', () => { + const user = createUser('Alice') + + expect(user).toMatchSnapshot({ + id: expect.any(Number), + createdAt: expect.any(Date), + }) +}) +``` + +The `id` and `createdAt` fields are checked against the matchers (any number, any date) instead of being compared to a stored value. All other fields are snapshotted as usual. + +## Error Snapshots + +A common use of inline snapshots is capturing error messages. [`toThrowErrorMatchingInlineSnapshot`](/api/expect#tothrowerrormatchinginlinesnapshot) combines `toThrow` with `toMatchInlineSnapshot` so you can snapshot the error message without a separate `.snap` file: + +```js +test('throws on invalid input', () => { + expect(() => parse('')).toThrowErrorMatchingInlineSnapshot( + `[Error: Unexpected end of input at position 0]` + ) +}) +``` + +This is especially handy for verifying that error messages are clear and don't accidentally change. Like other inline snapshots, Vitest fills in the string on the first run and updates it when you press `u`. + ::: tip For custom snapshot serializers, snapshot matchers, and advanced configuration, see the [Snapshot](/guide/snapshot) guide. ::: diff --git a/docs/guide/learn/testing-in-practice.md b/docs/guide/learn/testing-in-practice.md index 9f2283ae5..d622e6aaf 100644 --- a/docs/guide/learn/testing-in-practice.md +++ b/docs/guide/learn/testing-in-practice.md @@ -38,7 +38,7 @@ test('formats USD prices', () => { }) test('formats EUR prices', () => { - expect(formatPrice(10, 'EUR')).toMatchInlineSnapshot(`"€10.00"`) + expect(formatPrice(10, 'EUR')).toBe('€10.00') }) test('handles zero', () => { @@ -431,6 +431,10 @@ Each `describe` block focuses on one method. Each test verifies one specific beh Notice that we create a fresh `createTodoList()` in every test. This keeps tests independent, which means they can run in any order without affecting each other. If you find yourself repeating the same setup in every test, that's a good candidate for [`beforeEach`](/api/hooks#beforeeach) or a [`test.extend`](/guide/test-context#extend-test-context) fixture. ::: +::: details What about `nextId`? +The `nextId` counter at the top of the module is shared across all calls to `createTodoList()`, including across tests. This means IDs aren't predictable: one test might get IDs 1 and 2, while another gets 3 and 4 depending on execution order. This works fine here because the tests only check *relative* uniqueness (`first.id !== second.id`), not specific ID values. If a test asserted `expect(todo.id).toBe(1)`, it would break depending on which tests ran before it. When you have shared module-level state like this, make sure your tests don't depend on its specific value. +::: + --- If you're building a web application and want to test components in a real browser environment, check out [Component Testing](/guide/browser/component-testing) for testing React, Vue, Svelte, and other UI frameworks. diff --git a/docs/guide/learn/writing-tests.md b/docs/guide/learn/writing-tests.md index 2400da699..a047001bd 100644 --- a/docs/guide/learn/writing-tests.md +++ b/docs/guide/learn/writing-tests.md @@ -86,6 +86,36 @@ src/ If the default patterns don't work for your project, you can customize which files are included with the [`include`](/config/include) and [`exclude`](/config/exclude) config options. +## Testing TypeScript + +Because Vitest runs on top of Vite, TypeScript works out of the box. There's no extra compiler to install, no `ts-jest` to configure, and no separate build step for your tests. Just name your test file `.test.ts` instead of `.test.js` and start writing: + +```ts +import { expect, test } from 'vitest' + +interface User { + name: string + age: number +} + +function createUser(name: string, age: number): User { + return { name, age } +} + +test('creates a user with the correct fields', () => { + const user = createUser('Alice', 30) + + expect(user).toEqual({ name: 'Alice', age: 30 }) + expect(user.name).toBe('Alice') +}) +``` + +You can import your production types, use generics, and write typed test utilities exactly as you would in the rest of your codebase. Vite transforms TypeScript on the fly, so tests start fast even in large projects. + +::: tip +Vitest transforms TypeScript for execution but does **not** type-check your tests during the test run. This is the same trade-off Vite makes for speed: you get fast feedback in the terminal, and run `tsc` or `vitest typecheck` separately when you want full type checking. See the [Testing Types](/guide/testing-types) guide for more details. +::: + ## Reading Test Output When you run `vitest` and only a single test file matches, the output is expanded into a tree structure showing `describe` groups and individual tests along with their duration: @@ -130,6 +160,54 @@ test.todo('implement validation later') These modifiers are great for quick, local changes while developing. For more permanent ways to filter tests (by filename, line number, or tags), see the [Test Filtering](/guide/filtering) guide. +## Parameterized Tests + +When you have several test cases that only differ in their inputs and expected outputs, writing a separate `test` for each one gets repetitive. [`test.for`](/api/test#test-for) lets you define the cases as data and run the same test logic for all of them: + +```js +import { expect, test } from 'vitest' + +test.for([ + [1, 1, 2], + [1, 2, 3], + [2, 1, 3], +])('add(%i, %i) -> %i', ([a, b, expected]) => { + expect(a + b).toBe(expected) +}) +``` + +The placeholders `%i`, `%s`, and `%f` in the test name are replaced with the corresponding values from each row, so the output shows `add(1, 1) -> 2`, `add(1, 2) -> 3`, and so on. + +If your cases have more than two or three values, passing objects is more readable. Use `$property` in the name to interpolate fields: + +```js +test.for([ + { a: 1, b: 1, expected: 2 }, + { a: 1, b: 2, expected: 3 }, + { a: 2, b: 1, expected: 3 }, +])('add($a, $b) -> $expected', ({ a, b, expected }) => { + expect(a + b).toBe(expected) +}) +``` + +The second argument to the test function is the [Test Context](/guide/test-context), which gives you access to fixtures, per-test `expect`, and other utilities. This is especially useful with [`test.concurrent`](/api/test#concurrent), where concurrent tests run in parallel and the global `expect` can't reliably associate a snapshot with the right test. The context-scoped `expect` solves this: + +```js +test.concurrent.for([ + [1, 1], + [1, 2], + [2, 1], +])('add(%i, %i)', ([a, b], { expect }) => { + expect(a + b).toMatchSnapshot() +}) +``` + +[`describe.for`](/api/describe#describe-for) works the same way but creates a suite for each set of parameters, which is useful when multiple tests share the same parameterized setup. + +::: tip +Vitest also provides [`test.each`](/api/test#each), which you may recognize from Jest. It works similarly but spreads array arguments instead of passing them as a single value, and doesn't provide access to the Test Context. It exists mainly for Jest compatibility. Prefer `test.for` in new code. +::: + ## Using Global Imports By default, you import `test`, `expect`, `describe`, and other functions from `vitest` at the top of every test file. If you'd rather use them as globals without importing (similar to how Jest works), you can enable the [`globals`](/config/globals) option in your config: @@ -160,16 +238,4 @@ If you use TypeScript, add `"types": ["vitest/globals"]` to your `tsconfig.json` Vitest runs all test files **in parallel** by default, using [child processes](/config/pool). Each test file runs in its own isolated context, so your test files don't share state with each other. This prevents tests in different files from accidentally interfering. -Tests **within** a single file run sequentially by default, which is usually what you want since tests in the same file often share setup code. If your tests are truly independent, you can opt into running them concurrently with [`test.concurrent`](/api/test#concurrent) to speed things up: - -```js -test.concurrent('first concurrent test', async () => { - // runs in parallel with the next test -}) - -test.concurrent('second concurrent test', async () => { - // runs in parallel with the previous test -}) -``` - -See the [Parallelism](/guide/parallelism) guide for more details on controlling test execution. +Tests **within** a single file run sequentially by default, which is usually what you want since tests in the same file often share setup code. If your tests are truly independent, you can opt into running them concurrently with [`test.concurrent`](/api/test#concurrent) to speed things up. See the [Parallelism](/guide/parallelism) guide for more details on controlling test execution. -- 2.51.2