When writing tests, you often need to spin up the same resources—databases, tables, services—across multiple test cases. At first, jest
’s lifecycle hooks (beforeEach
, afterEach
, beforeAll
, afterAll
) feel like the right tool for the job. But as soon as your setup and teardown logic gets more complex, these hooks start to show their limits.
The problem? Setup and teardown live in different places, even though they logically belong together. That makes tests harder to read, harder to maintain, and surprisingly easy to get wrong.
This post walks through a example to show the pitfalls of the traditional approach, and then introduces a cleaner pattern using fixtures. Fixtures let you co-locate setup and teardown, build reusable building blocks, and keep your test files concise and self-explanatory.
Issues by Example
Let’s analyse common issues with a sophisticated example, which relies on a docker container running with a AWS DynamoDB database. It doesn’t matter which technology is used, but I believe it’s easier to understand with a close to real world example:
import { DynamoDBClient, CreateTableCommand, DeleteTableCommand, PutItemCommand } from "@aws-sdk/client-dynamodb"
import { LocalstackContainer, StartedLocalStackContainer } from "@testcontainers/localstack"
describe("My Integration Tests", () => {
let localStackContainer: StartedLocalStackContainer;
let dynamoDbClient: DynamoDBClient;
beforeAll(async () => {
localStackContainer = await (new LocalStackContainer().start())
dynamoDBClient = new DynamoDBClient({
endpoint: localStackContainer.getConnectionUri(),
})
})
beforeEach(async () => {
dynamoDbClient.send(new CreateTableCommand(/* First Table */))
dynamoDbClient.send(new CreateTableCommand(/* Second Table */))
})
afterEach(async () => {
dynamoDbClient.send(new DeleteTableCommand(/* First Table */))
dynamoDbClient.send(new DeleteTableCommand(/* Second Table */))
})
afterAll(async () => {
dynamoDBClient.destroy()
localStackContainer.stop()
})
it("gets items from both tables", () => {
dynamoDbClient.send(new PutItemCommand(/* First Table Item */))
dynamoDbClient.send(new PutItemCommand(/* Second Table Item */))
const myService = new Service(dynamoDbClient);
const items = myService.getItemsFromBothTables();
expect(items).toEqual(/* .. */)
})
})
There are few issues when you want to reuse some logic here, which are mainly resulting because of following reason: disconnected setup and teardown.
While beforeEach
and afterEach
give you the option to define common setup and teardown for every test, the code in these hooks is not co-located. For example these two statements belong together, but are located in different places:
// setup (beforeEach)
dynamoDbClient.send(new CreateTableCommand(/* First Table */))
// teardown (afterEach)
dynamoDbClient.send(new DeleteTableCommand(/* First Table */))
This causes several problems:
- Hidden dependency: setup and teardown are logically tied together, but the code is separated, making the relationship hard to see.
- Context switching: readers need to jump between different hooks to fully understand the lifecycle of a resource.
- Error-prone: it’s easy to accidentally mismatch setup/teardown logic (e.g., creating two tables but only deleting one).
- Reduced readability: tests become harder to scan because the lifecycle of resources is fragmented.
The Solution
In the following sections we will find out, how we can solve all of the mentioned issues and more.
Fixtures
Most of these issues can be resolved by using Fixtures. While other test runners like vitest
or playwright
already provide implementations for fixtures, with jest
you have to build a fixtures system yourself.
The main idea of fixtures is to have the setup and teardown logic for that same resource co-located. Fixtures usually have a use function that initializes the fixture, so you can decide in which tests you want to use the fixture.
Let’s see how our test could look like with fixtures:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb"
import { DynamoDBClientFixture, FirstTableFixture, SecondTableFixture } from "../test/fixtures"
describe("My Integration Tests", () => {
const dynamoDBClient = DynamoDBClientFixture.use().getClient()
const firstTable = FirstTableFixture.use(dynamoDBClient)
const secondTable = SecondTableFixture.use(dynamoDBClient)
it("gets items from both tables", () => {
firstTable.putItem(/* .. */)
secondTable.putItem(/* .. */)
const myService = new Service(dynamoDbClient);
const items = myService.getItemsFromBothTables();
expect(items).toEqual(/* .. */)
})
})
Now the test file is much more readable and concise. We can exactly see what the test needs to run by looking at the fixtures it uses. All the setup and tear down logic is now encapsulated in fixtures, which makes them easy to reuse.
But how is a fixture implemented? Let’s zoom into the FirstTableFixture
implementation as an example:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb"
import { FirstEntity } from "../../models"
export class FirstTableFixture {
private constructor(
private dynamoDbClient: DynamoDBClient
) {}
public static use(dynamoDbClient: DynamoDBClient) {
const fixture = new FirstTableFixture(dynamoDbClient)
// put setup and teardown logic directly here
beforeEach(async () => {
await fixture.dynamoDbClient.send(new CreateTableCommand({/* .. */}))
})
afterEach(async () => {
await fixture.dynamoDbClient.send(new DeleteTableCommand({ /* .. */ }))
})
return fixture
}
public putItem(entity: FirstEntity) {
return this.dynamoDbClient.send(
new PutItemCommand({/* .. */}),
)
}
}
Following are the characteristics of a fixture here:
- A fixture is a
class
that at the minimum has ause
function and is mostly responsible for a a single resource (here theFirstTable
). - The
use
function returns an instance of the fixture class orundefined
. It can optionally receive some arguments. - The
use
function calls global jest hooks, likebeforeEach
andafterEach
. Yes, you can call these hooks anywhere!jest
will call these hooks in the same order as they were called. - A fixture optionally provides additional functions which can be getters or allow actions on the resource. For example the
putItem
function allows type-safe inserts to the table. This is also the main reason why I chose to use aclass
instead of a factory function for fixtures.
You can now implement a fixture for each resource and create resuable building blocks for your tests.
Global Setup & Teardown (advanced)
As you might have noticed, I omitted the LocalStack
part in the fixture examples completely. Starting a docker container can actually take quite some time so it makes sense to reuse the same container for multiple test files.
In your jest.config
you can define globalSetup
and globalTeardown
files. Unfortunately, these require a function to be exported from two different files, which is why I usually create a helper file first:
// local-stack.ts
import { LocalstackContainer, StartedLocalStackContainer } from "@testcontainers/localstack"
// You only need this if you want to pass variables
// from your global setup to your test files and fixtures
declare global {
var localStackContainer: StartedLocalStackContainer
}
export const startLocalStack = async () => {
const localStackContainer = new LocalstackContainer("localstack/localstack:3.1")
global.localStackContainer = await localStackContainer.start()
return global.localStackContainer
}
export const stopLocalStack = async () => {
if (global.localStackContainer) {
return global.localStackContainer.stop()
}
return Promise.resolve()
}
// Can be used in test files and fixtures
export const getLocalStackContainer = () => {
if (globalThis.localStackContainer) {
return globalThis.localStackContainer
}
throw new Error("LocalStack container is not started")
}
Now you can create minimal global setup/teardown files:
// localstack-setup.ts
import { startLocalStack } from "./local-stack"
export default startLocalStack
// localstack-teardown.ts
import { stopLocalStack } from "./local-stack"
export default stopLocalStack
This way the main logic still resides in a single file.
There might also be cases where you want to have different global setup/teardown logic for different parts of your tests. Maybe one set of test files requires not just a LocalStack container, but also a PostgreSQL container. Here you can use the projects
option with different global setup/teardown files:
Note:
maxWorkers
is set to1
to prevent parallel tests from conflicting over the same Docker container. Runningprojects
in parallel would be preferable, but it’s not yet supported (see open GitHub issue).
// postgres-and-localstack-setup.ts
import { startPostgres } from "./postgres"
import { startLocalStack } from "./local-stack"
const postgresAndLocalstackSetup = async () => {
await Promise.all([startPostgres(), startLocalStack()])
}
export default postgresAndLocalstackSetup
// postgres-and-localstack-teardown.ts
import { stopPostgres } from "./postgres"
import { stopLocalStack } from "./local-stack"
const postgresAndLocalstackSetup = async () => {
await Promise.all([stopPostgres(), stopLocalStack()])
}
export default postgresAndLocalstackSetup
// jest.config.ts
import { JestConfigWithTsJest } from "ts-jest"
const jestConfig: JestConfigWithTsJest = {
// ..
maxWorkers: 1,
projects: [
{
// ..
displayName: "dynamodb-sync-test",
testMatch: ["/src/dynamodb-sync/**/*.test.ts "],
globalSetup: "/test/global-environments/localstack-setup.ts ",
globalTeardown: "/test/global-environments/localstack-teardown.ts ",
},
{
// ..
displayName: "neo4j-sync-test",
testMatch: ["/src/postgres-sync/**/*.test.ts "],
globalSetup: "/test/global-environments/postgres-and-localstack-setup.ts ",
globalTeardown: "/test/global-environments/postgres-and-localstack-teardown.ts ",
},
],
}
export default jestConfig
Now test files can share the same resource globally, which can save a lot if time when executing tests.
Further Considerations & Ideas
Here are a few additional points that might help you get along with the fixture architecture:
- Use
beforeAll
/afterAll
depending on your needs in theuse
functions as well - You can still use hooks in your test file, to e.g. setup data specific to a describe block:
describe("My Integration Tests", () => {
// ..
const firstTable = FirstTableFixture.use(dynamoDBClient)
beforeEach(() => {
firstTable.putItem(/* .. */)
})
it("first test", () => {})
it("second test", () => {})
})
- If you are starting out a new project, you might just want to use
vitest
as it already comes with a build-in fixtures system.
Conclusion
Writing clean and maintainable tests isn’t just about assertions, it’s also about managing setup and teardown in a way that’s readable, reusable, and composable. While jest
‘s beforeEach
/afterEach
hooks cover the basics, they quickly become messy when resources have disconnected lifecycles. By introducing fixtures, you can co-locate setup and teardown logic, eliminate hidden dependencies, and build reusable building blocks that make your tests easier to reason about.
Combined with global setup and teardown for expensive resources like Docker containers, this approach gives you a structured testing architecture that scales with your codebase. Whether you continue with jest
or switch to a runner like vitest
that provides fixtures out of the box, thinking in terms of fixtures will help you write tests faster and make them clearer and more maintainable.