Focused tests with Test Fixture Factory

My favourite JavaScript framework for writing tests is Vitest.

One of my favourite features is its ability to extend the Test Context with test.extend. Test Context is a great way to pass in reusable fixtures to each test, which can be executed concurrently. Each fixture can have its own setup/teardown code and is only run when a test requires it.

example.test.ts
3 collapsed lines
import { test as baseTest, expect } from "vitest"
import { completeTodo, createTodo, deleteTodo, type Todo } from "#lib/db/todos.js"
const test = baseTest.extend<{
todo: Todo
}>({
todo: async ({}, use) => {
// setup
const todo = await createTodo()
// pass our value to the test
// and then wait until the test completes
await use(todo)
// teardown
await deleteTodo(todo)
},
})
test("should complete a todo", async ({ todo }) => {
const updatedTodo = await completeTodo(todo)
expect(updatedTodo.completed).toBe(true)
})

Integration Tests

When writing an integration test, you will need to set up the application state in a specific way, ready to be tested. This can often require writing many lines of code for declaring dependencies that aren’t directly related to the test, but rather required by database constraints.

For example, I’m currently working on some code for Rough.app, which involves testing a function called getNextPublicIdForDocument.

Rough uses ULIDs to identify rows in our database. I love ULIDs, but they aren’t very friendly to my human brain. It’s much easier to refer to a Document with a number - I am more likely to recall ID #57, but have no chance of recognising #01K4D2CQMAN0EG015FHMZ87KZ3.

Whenever someone creates a new Document in Rough, we assign it a private ULID to uniquely identify it in the database, and also give it a human-friendly numeric ID, which is how we display it to the user.

Here is the function signature:

getNextPublicIdForDocument(options: {
db: DB,
workspaceId: WorkspaceId
}): Promise<number | Error>

I want to write some tests for this function to ensure it works as expected. The ideal test would call the function and assert the result is the expected value.

Ideally, all I should need to write is:

get-next-public-id-for-document.test.ts
3 collapsed lines
import { expect, test } from "vitest"
import { getNextPublicIdForDocument } from "#lib/db/document/get-next-public-id-for-document.js"
test("initial public ID should be 1", async ({ db, workspace }) => {
const publicId = await getNextPublicIdForDocument({
db,
workspaceId: workspace.id,
})
expect(publicId).toBe(1)
})
test("should return the next public ID", async ({ db, workspace, createDocument }) => {
await createDocument({ publicId: 7 })
const publicId = await getNextPublicIdForDocument({
db,
workspaceId: workspace.id,
})
expect(publicId).toBe(8)
})

Spoiler alert: this is how our test will look at the end of this article!

However, right now, this test doesn’t work - Vitest doesn’t know how to provide the db, workspace, or createDocument fixtures yet! We will need to teach Vitest how to insert these values into the database.

Our workspace table has several columns that I need to define, even though they aren’t at all relevant to this test. I call these properties “noise” - as I could provide any combination of values and it wouldn’t affect the outcome of the test.

To test how the function behaves when there are existing document rows, I need to create a new document in the database. This also involves providing many properties that don’t affect the test.

Also, due to the particular database constraints, all document rows must link to the user who created them. So I first must create a new user row.

As you can see below, getting this test working involves a lot of setup code. Only 5% of the lines of code are actually relevant to the test; all the rest is noise.

get-next-public-id-for-document.test.ts
14 collapsed lines
import { assertOk } from "@stayradiated/error-boundary"
import { expect, test } from "vitest"
import { DocumentStatus, WorkspaceStatus } from "#lib/enums.js"
import { randomULID } from "#lib/utils/ulid.js"
import { getDb } from "#lib/db/get-db.js"
import { deleteDocument } from "#lib/db/document/delete-document.js"
import { getNextPublicIdForDocument } from "#lib/db/document/get-next-public-id-for-document.js"
import { insertDocument } from "#lib/db/document/insert-document.js"
import { deleteUser } from "#lib/db/user/delete-user.js"
import { insertUser } from "#lib/db/user/insert-user.js"
import { deleteWorkspace } from "#lib/db/workspace/delete-workspace.js"
import { insertWorkspace } from "#lib/db/workspace/insert-workspace.js"
test("initial public ID should be 1", async () => {
// SETUP
const db = getDb()
const workspace = await insertWorkspace({
db,
workspace: {
id: randomULID(),
icon: "😀",
name: "Worky McWorkspace",
status: WorkspaceStatus.ACTIVE,
publicId: `test:${randomULID()}`,
version: 1,
icp: "",
strategy: "",
vision: "",
deletedAt: null,
},
})
assertOk(workspace)
// TEST
const publicId = await getNextPublicIdForDocument({
db,
workspaceId: workspace.id,
})
expect(publicId).toBe(1)
// TEARDOWN
await deleteWorkspace({ db, workspaceId: workspace.id })
})
test("should return the next public ID", async () => {
// SETUP
const db = getDb()
const workspace = await insertWorkspace({
db,
workspace: {
id: randomULID(),
icon: "😀",
name: "Worky McWorkspace",
status: WorkspaceStatus.ACTIVE,
publicId: `test:${randomULID()}`,
version: 1,
icp: "",
strategy: "",
vision: "",
deletedAt: null,
},
})
assertOk(workspace)
const user = await insertUser({
db,
user: {
id: randomULID(),
name: "Test User",
image: null,
},
})
assertOk(user)
const document = await insertDocument({
db,
document: {
id: randomULID(),
workspaceId: workspace.id,
version: 1,
status: DocumentStatus.ACTIVE,
createdByUserId: user.id,
lastModifiedAt: Date.now(),
title: "Test Document",
ownedByTeamId: null,
vcsTagList: [],
archivedAt: null,
archivedByUserId: null,
releasedAt: null,
ownedByPersonId: null,
// NOTE: the value of this publicId impacts the test result
publicId: 7,
},
})
assertOk(document)
// TEST
const publicId = await getNextPublicIdForDocument({
db,
workspaceId: workspace.id,
})
expect(publicId).toBe(8)
// TEARDOWN
await deleteDocument({ db, documentId: document.id })
await deleteUser({ db, userId: user.id })
await deleteWorkspace({ db, workspaceId: workspace.id })
})
What is that assertOk() function?

The functions I’m working with return errors as values.

const result = await insertWorkspace({ db, workspace })
typeof result // Error | Workspace
typeof result.id // Property "id" does not exist on type "Error"

Most of the time I will manually check if the value is an error and then handle it appropriately.

const result = await insertWorkspace({ db, workspace })
if (result instanceof Error) {
return new Error("Failed to insert workspace", { cause: result })
}
typeof result.id // WorkspaceId

However, in the test, I am happy to let the error throw if it occurs.

if (result instanceof Error) {
throw result
}

To save manually typing, and to satisfy the TypeScript compiler, I use the assertOk function to do the above.

const result = await insertWorkspace({ db, workspace })
assertOk(result)
typeof result.id // WorkspaceId

With Test Context

We can refactor the above tests to use Test Context to handle the setup and teardown. With this change, we use test.extend to declare our fixtures outside of the test.

Immediately, we see an improvement - we can reuse shared fixtures between tests, and each test only contains lines of code that are directly relevant.

get-next-public-id-for-document.test.ts
16 collapsed lines
import { assertOk } from "@stayradiated/error-boundary"
import { test as baseTest, expect } from "vitest"
import type { DB, Document, User, Workspace } from "#lib/types.js"
import { DocumentStatus, WorkspaceStatus } from "#lib/enums.js"
import { randomULID } from "#lib/utils/ulid.js"
import { getDb } from "#lib/db/get-db.js"
import { deleteDocument } from "#lib/db/document/delete-document.js"
import { getNextPublicIdForDocument } from "#lib/db/document/get-next-public-id-for-document.js"
import { insertDocument } from "#lib/db/document/insert-document.js"
import { deleteUser } from "#lib/db/user/delete-user.js"
import { insertUser } from "#lib/db/user/insert-user.js"
import { deleteWorkspace } from "#lib/db/workspace/delete-workspace.js"
import { insertWorkspace } from "#lib/db/workspace/insert-workspace.js"
const test = baseTest.extend<{
db: DB
workspace: Workspace
user: User
createDocument: (attrs: { publicId: number }) => Promise<Document>
}>({
db: async ({}, use) => {
const db = getDb()
await use(db)
},
workspace: async ({ db }, use) => {
const workspace = await insertWorkspace({
db,
workspace: {
id: randomULID(),
icon: "😀",
name: "Worky McWorkspace",
status: WorkspaceStatus.ACTIVE,
publicId: `test:${randomULID()}`,
version: 1,
icp: "",
strategy: "",
vision: "",
deletedAt: null,
},
})
assertOk(workspace)
await use(workspace)
await deleteWorkspace({ db, workspaceId: workspace.id })
},
user: async ({ db }, use) => {
const user = await insertUser({
db,
user: {
id: randomULID(),
name: "Test User",
image: null,
},
})
assertOk(user)
await use(user)
await deleteUser({ db, userId: user.id })
},
createDocument: async ({ db, user, workspace }, use) => {
const documentList: Document[] = []
await use(async (attrs) => {
const document = await insertDocument({
db,
document: {
id: randomULID(),
workspaceId: workspace.id,
createdByUserId: user.id,
status: DocumentStatus.ACTIVE,
lastModifiedAt: Date.now(),
version: 1,
title: "Test Document",
ownedByTeamId: null,
vcsTagList: [],
archivedAt: null,
archivedByUserId: null,
releasedAt: null,
ownedByPersonId: null,
// pass through attrs
publicId: attrs.publicId,
},
})
assertOk(document)
documentList.push(document)
return document
})
for (const document of documentList) {
await deleteDocument({ db, documentId: document.id })
}
},
})
test("initial public ID should be 1", async ({ db, workspace }) => {
const publicId = await getNextPublicIdForDocument({
db,
workspaceId: workspace.id,
})
expect(publicId).toBe(1)
})
test("should return the next public ID", async ({ db, workspace, createDocument }) => {
await createDocument({ publicId: 7 })
const publicId = await getNextPublicIdForDocument({
db,
workspaceId: workspace.id,
})
expect(publicId).toBe(8)
})

There is still a lot of setup/teardown code in this file, though. Code that will likely need to be repeated in other test files.

With Test Fixture Factory

We can refactor our test fixtures into standalone files, so they can be used in any test. This also keeps our test files shorter, more focused, and easier to scan.

Extracting these functions into separate files can be challenging, though, especially when using TypeScript.

I’ve created a small library to help make this easier: test-fixture-factory

Here is how the test looks after being refactored to use shared fixture factory functions.

Vitest can even automatically infer the types of each fixture - I don’t need to manually specify the type of the Test Context.

8 collapsed lines
import { test as baseTest, expect } from "vitest"
import { getNextPublicIdForDocument } from "#lib/db/document/get-next-public-id-for-document.js"
import { useDb } from "#lib/test/use-db.js"
import { useCreateDocument } from "#lib/test/use-document.js"
import { useUser } from "#lib/test/use-user.js"
import { useWorkspace } from "#lib/test/use-workspace.js"
const test = baseTest.extend({
db: useDb(),
workspace: useWorkspace(),
user: useUser(),
createDocument: useCreateDocument(),
})
test("initial public ID should be 1", async ({ db, workspace }) => {
const publicId = await getNextPublicIdForDocument({
db,
workspaceId: workspace.id,
})
expect(publicId).toBe(1)
})
test("should return the next public ID", async ({ db, workspace, createDocument }) => {
await createDocument({ publicId: 7 })
const publicId = await getNextPublicIdForDocument({
db,
workspaceId: workspace.id,
})
expect(publicId).toBe(8)
})

Now let’s have a look at how these fixture factories are defined.

useDb

The simplest is useDb. This fixture is providing a database connection. It doesn’t have any dependencies.

It simply gets a database client and returns it as the value. How you connect to your database is up to you. I’m using kysely here - with the setup encapsulated within the getDb function (which is also used by the rest of the app to use the db).

use-db.ts
3 collapsed lines
import { createFactory } from "test-fixture-factory"
import { getDb } from "#lib/db/get-db.js"
const dbFactory = createFactory("DB").withValue(() => {
const db = getDb()
return {
value: db,
}
})
const useDb = dbFactory.useValue
export { useDb }

Here is an example of how the useDb() provides a db value to the test:

use-db.ts
3 collapsed lines
import { test as baseTest, expect } from "vitest"
import { useDb } from "#lib/test/use-db.js"
const test = baseTest.extend({
db: useDb(),
})
test("should have a db instance", async ({ db }) => {
// we can use the db instance directly
const users = await db.selectFrom("users").selectAll().execute()
expect(users.length).toBe(0)
})

useWorkspace

Things get more interesting as we look at how to provide a workspace fixture.

For useWorkspace to work, it requires a database connection. However, instead of getting our db instance, we can declare it as a dependency.

  • The .withContext() method is used to set the expected type of the Test Context - this is used to ensure we have type safety when accessing values in the context.
  • The .withSchema() method declares which attributes will be provided to the factory when it’s constructing a new value.
    • We set a single attribute db. Using the field method .from("db") means that this value will come from the db test context.
use-workspace.ts
10 collapsed lines
import { assertOk } from "@stayradiated/error-boundary"
import { createFactory } from "test-fixture-factory"
import type { DB } from "#lib/types.js"
import { WorkspaceStatus } from "#lib/enums.js"
import { randomULID } from "#lib/utils/ulid.js"
import { deleteWorkspace } from "#lib/db/workspace/delete-workspace.js"
import { insertWorkspace } from "#lib/db/workspace/insert-workspace.js"
const workspaceFactory = createFactory("Workspace")
.withContext<{
db: DB
}>()
.withSchema((field) => ({
db: field.type<DB>().from("db"),
}))
.withValue(async ({ db }) => {
const workspace = await insertWorkspace({
db,
workspace: {
id: randomULID(),
icon: "😃",
name: "Worky McWorkspace",
status: WorkspaceStatus.ACTIVE,
publicId: `test:${randomULID()}`,
version: 1,
icp: "",
strategy: "",
vision: "",
deletedAt: null,
},
})
assertOk(workspace)
return {
value: workspace,
destroy: async () => {
await deleteWorkspace({ db, workspaceId: workspace.id })
},
}
})
const useCreateWorkspace = workspaceFactory.useCreateValue
const useWorkspace = workspaceFactory.useValue
export { useCreateWorkspace, useWorkspace }

Because we declare db as a required dependency, the TypeScript compiler will warn us when it’s missing:

const test = baseTest.extend({
workspace: useWorkspace(), // ⚠️ Property 'db' is missing in type …
})

The TypeScript compiler is satisfied when we provide the db fixture (using our useDb() factory). Now our test can receive its own unique workspace row - that is automatically cleaned up when the test finishes.

The factory has two key methods: useValue and useCreateValue. These methods can be passed to the Vitest Test Context. A pattern I often use is to rename and export these methods so they can be used in the test.

use-workspace.ts
4 collapsed lines
import { test as baseTest } from "vitest"
import { useDb } from "#lib/test/use-db.js"
import { useWorkspace } from "#lib/test/use-workspace.js"
const test = baseTest.extend({
db: useDb(),
workspace: useWorkspace(),
})
test("should have a workspace instance", async ({ workspace }) => {
console.log(workspace.id) // "…A5361"
})
test("should have a different workspace instance", async ({ workspace }) => {
console.log(workspace.id) // "…B4215"
})

useDocument

Defining the useDocument fixture factory highlights the test-fixture-factory library.

To create a document, we need:

  1. A db connection
  2. A workspaceId value
  3. A userId value
  4. We also need to allow the test to specify the publicId, so it can control the test result.

Ideally, the test shouldn’t need to manually pass in the workspaceId and userId values. If there is a workspace and a user available in the context, then the factory should use those.

Notice how .withContext() requires workspace and user objects, while the .withSchema() requires workspaceId and userId. This is so I can easily specify a different ID in the test if I want.

For example, I can use createWorkspace to create a new workspace, and then pass that to createDocument to create a new document in that workspace. I could also do the same thing for userId.

test("creates a new document", async ({ createWorkspace, create, createDocument }) => {
const customWorkspace = await createWorkspace()
const document = await createDocument({
workspaceId: customWorkspace.id,
})
})

To extract the IDs from the objects in the context, we use the .from() method again, but this time pass a function as a second argument, which receives the test context and returns the value, so we can get the ID of the workspace.

use-document.ts
11 collapsed lines
import { assertOk } from "@stayradiated/error-boundary"
import { createFactory } from "test-fixture-factory"
import type { UserId, WorkspaceId } from "#lib/ids.js"
import type { DB, User, Workspace } from "#lib/types.js"
import { DocumentStatus } from "#lib/enums.js"
import { randomULID } from "#lib/utils/ulid.js"
import { deleteDocument } from "#lib/db/document/delete-document.js"
import { insertDocument } from "#lib/db/document/insert-document.js"
const documentFactory = createFactory("Document")
.withContext<{
db: DB
workspace: Workspace
user: User
}>()
.withSchema((field) => ({
db: field.type<DB>().from("db"),
workspaceId: field.type<WorkspaceId>().from("workspace", (ctx) => ctx.workspace.id),
userId: field.type<UserId>().from("user", (ctx) => ctx.user.id),
publicId: field.type<number>().default(1),
}))
.withValue(async ({ db, workspaceId, userId, publicId }) => {
const document = await insertDocument({
db,
document: {
id: randomULID(),
workspaceId,
createdByUserId: userId,
status: DocumentStatus.ACTIVE,
lastModifiedAt: Date.now(),
version: 1,
title: "Test Document",
ownedByTeamId: null,
vcsTagList: [],
archivedAt: null,
archivedByUserId: null,
releasedAt: null,
ownedByPersonId: null,
// pass through attrs
publicId,
},
})
assertOk(document)
return {
value: document,
destroy: async () => {
await deleteDocument({ db, documentId: document.id })
},
}
})
const useCreateDocument = documentFactory.useCreateValue
const useDocument = documentFactory.useValue
export { useCreateDocument, useDocument }

Wrapping Up

With the test-fixture-factory library, you can write clean, focused tests, now clearly expressing the intent of your tests without being drowned in implementation details.

The key benefits of this pattern are:

  • Reusable fixtures across test files
  • Automatic dependency resolution through the Test Context
  • Type-safe composition with TypeScript inference
  • Clear separation between test logic and setup code
  • Automatic cleanup

If you want to try this approach in your own projects, you can find the test-fixture-factory library on npm and on GitHub.

Happy testing!