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.
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:
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.
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?
assertOk()
function?The functions I’m working with return errors as values.
const result = await insertWorkspace({ db, workspace })
typeof result // Error | Workspacetypeof 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.
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
).
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:
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 thedb
test context.
- We set a single attribute
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.useCreateValueconst 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.
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:
- A
db
connection - A
workspaceId
value - A
userId
value - 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.
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.useCreateValueconst 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!