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 thetest
function under thetestBase
alias to prevent name collision with my customtest
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), andafter
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.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
.({ 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.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 oflocale
is fixed to the test run and will never change. Different tests do not need differentlocale
. 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.