Custom fixtures

Once I've got @faker-js/faker installed, I will head to a newly created test-extend.ts module and implement my custom fixture.
When it comes to any API design, I like approaching it usage-first. I create the usage experience I want and then deal with the implementation details to bring that vision to life. This custom fixture is no exception.
In test-extend.ts, I will define a new type called Fixtures and use it to describe my custom fixture's name and its call signature:
interface Fixtures {
	createMockCart: (items: Array<Partial<CartItem>>) => Cart
}
Above, I've declared a single custom fixture called createMockCart, which is a function that accepts an array of cart items and returns a cart object. I plan on using that fixture to help me mock different states of the cart in tests.
Now it's time to implement it.
Custom fixtures in Vitest work by extending the default test() function using the test.extend() method:
import { test as testBase } from 'vitest'

interface Fixtures {
	createMockCart: (items: Array<Partial<CartItem>>) => Cart
}

export const test = testBase.extend<Fixtures>({})
I am importing the test function under the testBase alias to prevent name collision with my custom test function.
The test.extend() method accepts an object that represents my custom fixtures. By providing the Fixtures type as the type argument to that method, I am letting Vitest know about my fixtures' type definitions.
Let's add the implementation for the createMockCart() fixture.
Despite the call signature of my fixture making it a simple function, the actual implementation for it will be a bit different. You can imagine the fixture implementation as a higher-order function that allow you to hook into Vitest's existing context and internals, as well as control any side effects required to run before and after your fixture:
async exampleFixture({}, use) {
	await somethingYouNeedBefore()
	await use(fixtureValueItself)
	await somethingYouNeedAfter()
}
Think of the fixture implementation as a single function encompassing three steps: before fixture, use fixture (its value), and after fixture (e.g. cleanup).
With this structure in mind, here's how my createMockCart fixture implementation will look like:
import { test as testBase } from 'vitest'
import type { Cart, CartItem } from './src/cart-utils'

interface Fixtures {
	createMockCart: (items: Array<Partial<CartItem>>) => Cart
}

export const test = testBase.extend<Fixtures>({
	async createMockCart({}, use) {
		await use((items) => {
			/* ... */
		})
	},
})
It doesn't need anything before or after itself, so the only thing I'll do is provide the createMockCart implementation inline to the use() call.
The use() function is always asynchronous and you must await it!
Calling use() in your fixture implementation exposes any given argument as the value of that fixture. This is where Vitest infers the expected type of your fixture from the Fixtures interface!
This is also where TypeScript will start screaming at me because my fixture doesn't do what I described in types 😬 Let's fix that.
import { test as testBase } from 'vitest'
import { faker } from '@faker-js/faker'
import type { Cart, CartItem } from './src/cart-utils'

interface Fixtures {
	createMockCart: (items: Array<Partial<CartItem>>) => Cart
}

export const test = testBase.extend<Fixtures>({
	async createMockCart({}, use) {
		await use((items) => {
			return items.map((item) => ({
				id: faker.string.ulid(),
				name: faker.commerce.productName(),
				price: faker.number.int({ min: 1, max: 25 }),
				quantity: faker.number.int({ min: 1, max: 10 }),
				...item,
			}))
		})
	},
})
Here, I am maping over the given items and making sure that each cart item has complete values. I am using the faker object to generate random values so I don't have to describe the entire cart item if my test case is interested only in some of its properties, like price and quantity, for example.
Finally, to use this custom test context and my fixture, I'll go to the src/cart-utils.test.ts test file and import the custom test function I've created:
import { test } from '../test-extend'
import { getTotalPrice } from './cart-utils'
With this function at hand, I can write the first test for the getTotalPrice() behavior:
import { test } from '../test-extend'
import { getTotalPrice } from './cart-utils'

test('returns the total price for the cart', ({ createMockCart }) => {
	const cart = createMockCart([
		{ price: 5, quantity: 10 },
		{ price: 8, quantity: 4 },
	])

	expect(getTotalPrice(cart)).toBe(82)
})
I am grabbing the createMockCart() fixture right from the test context, which is super neat! I provide it with two cart items of fixed price and quantity to model the exact test scenario while leaving the rest of the properties to faker. With this setup, I can write the expectation around the total price to be 82.
Custom fixtures must be accessed via destructuring the test context (i.e. ({ one }) => {} and not (ctx) => ctx.one). Vitest, just like Playwright, uses a getter Proxy that lets it know whether your test is using certain fixtures or not. This allows it to skip the initialization and cleanup of those fixtures that aren't used.

Best practices of custom fixtures

Your fixtures can be anything, from static values to helper functions like our createMockCart(). This makes them extremely powerful. Unfortunately, that very power can poison your test setup, turning your fixtures into something that damages the quality of your tests.
Below, I've prepared a few rules you can follow to ensure awesome custom fixtures.

Static values

As a rule of thumb, DO NOT use fixtures to abstract reused values.
test('returns the total price for the cart', ({ cart }) => {
	expect(getTotalPrice(cart)).toBe(82) // ❌
})
test('returns the total price for the cart', ({ createMockCart }) => {
	const cart = createMockCart([...]) // ✅
	expect(getTotalPrice(cart)).toBe(82)
})
It's tempting to put a single cart object into a fixture since many tests may need the same cart. But what is cart in this particular test? Why its total price is 82 and not, say, 4 or 987?
The cart fixture effectively becomes a shared state. If you change its value, it will affect multiple tests. By nature, cart is not a static value and it doesn't belong in a fixture.
Use fixtures to help create values but always make the values known in the context of the test. Everything the test needs has to be known and controlled within that test.
Once exception to this rule is resused value that is never going to change within the same test run. For example, if you're testing against different locales of your application, you might want to set the locale before the test run and expose its value as a fixture:
test('...', ({ locale }) => {})
The value of locale is fixed to the test run and will never change. Different tests do not need different locale. It's a completely static fixture and as such it doesn't act as a shared state.

Dynamic values

DO use fixtures to generate dynamic values.
test('returns the total price for the cart', () => {
	const cart = [
		{
			id: 1,
			name: 'This is irrelevant',
			price: 5,
			quantity: 10
		},
		{
			id: 2,
			...
		}
	] // ❌
	expect(getTotalPrice(cart)).toBe(82)
})
test('returns the total price for the cart', ({ createMockCart }) => {
	const cart = createMockCart([
		{ price: 5, quantity: 10 },
		{ price: 8, quantity: 4 }
	]) // ✅
	expect(getTotalPrice(cart)).toBe(82)
})
In fact, custom fixtures is a great way to trim down the input of your test only to the things it actually needs. Like in our getTotalPrice test case, the only required input is the price and quantity of cart items. The test doesn't care what are items' name or id and so it doesn't declare them. Instead, it outsources those irrelevant values to faker so the cart items remain realistic but don't pollute the test case.
Since you can perform side effects before and after your fixture, you can implement all sorts of functionality in them: preparing a mock file on the disk, configuring your API mocking, spinning up a database instance with pre-defined tables.

Please set the playground first

Loading "Custom fixtures"
Loading "Custom fixtures"