Jest: Writing Composable Tests – DEV Community


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(/* .. */)
  })
})
Enter fullscreen mode

Exit fullscreen mode

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 */))
Enter fullscreen mode

Exit fullscreen mode

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(/* .. */)
  })
})
Enter fullscreen mode

Exit fullscreen mode

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({/* .. */}),
    )
  }
}
Enter fullscreen mode

Exit fullscreen mode

Following are the characteristics of a fixture here:

  • A fixture is a class that at the minimum has a use function and is mostly responsible for a a single resource (here the FirstTable).
  • The use function returns an instance of the fixture class or undefined. It can optionally receive some arguments.
  • The use function calls global jest hooks, like beforeEach and afterEach. 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 a class 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")
}
Enter fullscreen mode

Exit fullscreen mode

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
Enter fullscreen mode

Exit fullscreen mode

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 to 1 to prevent parallel tests from conflicting over the same Docker container. Running projects 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
Enter fullscreen mode

Exit fullscreen mode

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 the use 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", () => {})
})
Enter fullscreen mode

Exit fullscreen mode

  • 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.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *