Lawhive Framework
Testing

Test Harness

Composable fixture builder for integration tests

The test harness provides a fluent API for composing test fixtures with automatic lifecycle management.

Basic Usage

import { buildHarness } from "@lawhive/framework-testing"

const ctx = buildHarness()
  .use(() => ({
    config: { apiUrl: "http://localhost:3000" },
  }))
  .use(({ config }) => ({
    client: createApiClient(config.apiUrl),
  }))
  .setup()

describe("API", () => {
  it("works", async () => {
    const result = await ctx.client.get("/health")
    expect(result.status).toBe("ok")
  })
})

The .use() Method

Each .use() call adds fixtures to the context:

const ctx = buildHarness()
  // First layer: no dependencies
  .use(() => ({
    database: postgresContainer(),
    redis: redisContainer(),
  }))
  // Second layer: can access first layer
  .use(({ database }) => ({
    prisma: bootstrapPrisma({
      client: PrismaClient,
      schemaPath: "./prisma/schema.prisma",
      connectionString: database.connectionString,
    }),
  }))
  // Third layer: can access all previous
  .use(({ prisma, redis }) => ({
    cache: createCache({ prisma, redis }),
  }))
  .setup()

Fixture Factory Interface

Each fixture implements FixtureFactory<T>:

type FixtureFactory<TValue> = {
  create: (deps: Record<string, unknown>) => Promise<TValue> | TValue
  cleanup?: (value: TValue) => Promise<void> | void
}

Simple Fixture

.use(() => ({
  config: {
    create: () => ({ port: 3000, host: "localhost" }),
    // No cleanup needed
  },
}))

Async Fixture with Cleanup

.use(() => ({
  server: {
    create: async () => {
      const server = createServer()
      await server.listen(3000)
      return server
    },
    cleanup: async (server) => {
      await server.close()
    },
  },
}))

The .setup() Method

Call .setup() to register beforeAll/afterAll hooks:

// Module level - shared across all tests in file
const ctx = buildHarness()
  .use(() => ({ /* ... */ }))
  .setup()

describe("tests", () => {
  it("can access ctx", () => {
    expect(ctx.prisma).toBeDefined()
  })
})

Scoped Setup

Call .setup() inside a describe block for scoped fixtures:

describe("isolated tests", () => {
  const ctx = buildHarness()
    .use(() => ({ /* ... */ }))
    .setup() // Fixtures scoped to this describe

  it("works", () => {
    expect(ctx.database).toBeDefined()
  })
})

describe("other tests", () => {
  // Different fixtures, different database
  const ctx = buildHarness()
    .use(() => ({ /* ... */ }))
    .setup()
})

Guarded Context

The context proxy throws helpful errors if accessed too early:

const ctx = buildHarness()
  .use(() => ({ value: { create: () => 42 } }))
  .setup()

// ❌ Error: Cannot access fixture "value" before test setup completes
const x = ctx.value // At describe registration time

describe("tests", () => {
  // ✅ Works - inside test callback
  it("works", () => {
    expect(ctx.value).toBe(42)
  })
})

Cleanup Order

Fixtures are cleaned up in reverse order (last created, first cleaned):

const ctx = buildHarness()
  .use(() => ({
    a: { create: () => "a", cleanup: () => console.log("cleanup a") },
  }))
  .use(() => ({
    b: { create: () => "b", cleanup: () => console.log("cleanup b") },
  }))
  .use(() => ({
    c: { create: () => "c", cleanup: () => console.log("cleanup c") },
  }))
  .setup()

// After tests:
// cleanup c
// cleanup b
// cleanup a

Type Inference

The context is fully typed:

const ctx = buildHarness()
  .use(() => ({
    pg: postgresContainer(),
  }))
  .use(({ pg }) => ({
    prisma: bootstrapPrisma({ /* ... */ }),
  }))
  .setup()

// ctx is typed as:
// {
//   pg: PostgresContainer
//   prisma: PrismaClient
// }

ctx.pg.connectionString // ✅ string
ctx.prisma.task.findMany() // ✅ typed
ctx.unknown // ❌ Property 'unknown' does not exist

Common Patterns

Reset Between Tests

const ctx = buildHarness()
  .use(() => ({ pg: postgresContainer() }))
  .use(({ pg }) => ({
    prisma: bootstrapPrisma({ /* ... */ }),
  }))
  .setup()

describe("tests", () => {
  beforeEach(async () => {
    // Clean tables between tests
    await ctx.prisma.task.deleteMany()
    await ctx.prisma.user.deleteMany()
  })

  it("test 1", async () => { /* ... */ })
  it("test 2", async () => { /* ... */ })
})

Shared Test Data

const ctx = buildHarness()
  .use(() => ({ pg: postgresContainer() }))
  .use(({ pg }) => ({
    prisma: bootstrapPrisma({ /* ... */ }),
  }))
  .use(({ prisma }) => ({
    testUser: {
      create: async () => {
        return prisma.user.create({
          data: { email: "test@example.com" },
        })
      },
      cleanup: async (user) => {
        await prisma.user.delete({ where: { id: user.id } })
      },
    },
  }))
  .setup()

it("uses test user", async () => {
  const tasks = await ctx.prisma.task.findMany({
    where: { userId: ctx.testUser.id },
  })
})

Multiple Databases

const ctx = buildHarness()
  .use(() => ({
    mainDb: postgresContainer({ database: "main" }),
    analyticsDb: postgresContainer({ database: "analytics" }),
  }))
  .use(({ mainDb, analyticsDb }) => ({
    mainPrisma: bootstrapPrisma({
      client: MainPrismaClient,
      schemaPath: "./prisma/main.prisma",
      connectionString: mainDb.connectionString,
    }),
    analyticsPrisma: bootstrapPrisma({
      client: AnalyticsPrismaClient,
      schemaPath: "./prisma/analytics.prisma",
      connectionString: analyticsDb.connectionString,
    }),
  }))
  .setup()