Skip to content

Testing

Mantle instances use Vitest with two separate configurations: unit tests (fast, mocked dependencies) and integration tests (real LocalStack + PostgreSQL).

Vitest configuration

typescript
// vitest.config.ts
import { resolve } from 'path'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  resolve: {
    // Ensure shared packages resolve to a single instance
    dedupe: ['@mantleframework/observability', '@mantleframework/errors',
             '@mantleframework/core', '@mantleframework/database', '@mantleframework/env'],
    alias: {
      'drizzle-orm': resolve(__dirname, 'node_modules/@mantleframework/database/node_modules/drizzle-orm'),
      'zod':         resolve(__dirname, 'node_modules/@mantleframework/validation/node_modules/zod'),
    },
  },
  test: {
    exclude: ['test/integration/**', 'node_modules/**'],
    env: {
      ENVIRONMENT:        'test',
      METRICS_NAMESPACE:  'MyAppTest',
    },
  },
})

Integration tests use a separate config (vitest.integration.config.mts) that targets only test/integration/**.

Test directory structure

test/
  lambdas/          # handler unit tests (one file per Lambda)
  entities/         # entity query unit tests
  services/         # service/utility unit tests
  schemas/          # Zod schema validation tests
  constants/        # enum and constant tests
  integration/      # end-to-end tests against LocalStack

Place tests under test/, mirroring the src/ structure. Integration tests are isolated in test/integration/ and excluded from the default Vitest run.

Mocking @mantleframework/observability

Every test file that imports a Lambda handler or entity query must mock @mantleframework/observability. Use vi.mock before the handler import:

typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

vi.mock('@mantleframework/observability', () => ({
  logger: {
    info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(),
    appendKeys: vi.fn(), addContext: vi.fn(),
  },
  metrics: {
    addMetric: vi.fn(), publishStoredMetrics: vi.fn(),
    singleMetric: vi.fn(() => ({ addDimension: vi.fn(), addMetric: vi.fn() })),
  },
  MetricUnit:         { Count: 'Count', Milliseconds: 'Milliseconds', Seconds: 'Seconds' },
  addAnnotation:      vi.fn(),
  addMetadata:        vi.fn(),
  startSpan:          vi.fn(() => ({})),
  endSpan:            vi.fn(),
  logDebug:           vi.fn(),
  logError:           vi.fn(),
  logInfo:            vi.fn(),
  logWarn:            vi.fn(),
  sanitizeData:       vi.fn().mockImplementation((v: unknown) => v),
  resolveLoggingConfig: vi.fn().mockReturnValue({ logLevel: 'INFO', sampleRate: undefined }),
}))

import { handler } from '../../src/lambdas/api/files/index.get.js'

The import must come after vi.mock so that the mock is hoisted before module evaluation.

Testing a Lambda handler

typescript
import type { APIGatewayProxyEvent, Context } from 'aws-lambda'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

// 1. Hoist db mock before any imports
const mocks = vi.hoisted(() => {
  const mockResult = vi.fn().mockResolvedValue([{ fileId: 'abc', title: 'My File', status: 'ready' }])
  const mockWhere  = vi.fn().mockReturnValue({ limit: () => mockResult() })
  const mockFrom   = vi.fn().mockReturnValue({ where: mockWhere })
  const mockSelect = vi.fn().mockReturnValue({ from: mockFrom })
  return { mockSelect, mockResult }
})

vi.mock('../../src/db/client.js', () => ({ getDrizzleClient: vi.fn().mockResolvedValue({ select: mocks.mockSelect }) }))

vi.mock('@mantleframework/observability', () => ({
  logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), appendKeys: vi.fn(), addContext: vi.fn() },
  metrics: { addMetric: vi.fn(), publishStoredMetrics: vi.fn(), singleMetric: vi.fn(() => ({ addDimension: vi.fn(), addMetric: vi.fn() })) },
  MetricUnit: { Count: 'Count', Milliseconds: 'Milliseconds' },
  addAnnotation: vi.fn(), addMetadata: vi.fn(),
  startSpan: vi.fn(() => ({})), endSpan: vi.fn(),
  logDebug: vi.fn(), logError: vi.fn(), logInfo: vi.fn(), logWarn: vi.fn(),
  sanitizeData: vi.fn().mockImplementation((v: unknown) => v),
  resolveLoggingConfig: vi.fn().mockReturnValue({ logLevel: 'INFO', sampleRate: undefined }),
}))

import { handler } from '../../src/lambdas/api/files/[fileId]/index.get.js'

function makeEvent(fileId: string, token = 'test-token'): APIGatewayProxyEvent {
  return {
    body: null,
    headers: { Authorization: `Bearer ${token}` },
    isBase64Encoded: false, httpMethod: 'GET',
    path: `/files/${fileId}`,
    pathParameters: { fileId },
    queryStringParameters: null, multiValueHeaders: {},
    multiValueQueryStringParameters: null, stageVariables: null,
    requestContext: {} as APIGatewayProxyEvent['requestContext'], resource: '',
  }
}

const mockContext: Context = {
  callbackWaitsForEmptyEventLoop: false,
  functionName: 'GetFile', functionVersion: '1',
  invokedFunctionArn: 'arn:aws:lambda:us-east-1:123:function:GetFile',
  memoryLimitInMB: '128', awsRequestId: 'test-id',
  logGroupName: '', logStreamName: '',
  getRemainingTimeInMillis: () => 30000,
  done: vi.fn(), fail: vi.fn(), succeed: vi.fn(),
}

describe('GetFile handler', () => {
  beforeEach(() => {
    vi.stubEnv('API_BEARER_TOKEN', 'test-token')
    vi.stubEnv('DSQL_ENDPOINT', 'test.dsql.us-east-1.on.aws')
    vi.stubEnv('AWS_REGION', 'us-east-1')
  })

  afterEach(() => {
    vi.unstubAllEnvs()
    vi.clearAllMocks()
  })

  it('returns 200 with file data', async () => {
    const result = await handler(makeEvent('abc'), mockContext)
    expect(result.statusCode).toBe(200)
    const body = JSON.parse(result.body)
    expect(body.body.fileId).toBe('abc')
  })

  it('returns 401 for missing token', async () => {
    const result = await handler(makeEvent('abc', ''), mockContext)
    expect(result.statusCode).toBe(401)
  })
})

Key points:

  • Use vi.hoisted() to define mock return values before module evaluation
  • Use vi.stubEnv / vi.unstubAllEnvs rather than mutating process.env directly
  • Clear mocks in afterEach to prevent state leaking between tests

Testing entity queries

Entity query tests mock the database client and verify the Drizzle call chain:

typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mocks = vi.hoisted(() => {
  const mockReturning   = vi.fn().mockResolvedValue([{ fileId: 'abc', title: 'Test', status: 'pending' }])
  const mockWhere       = vi.fn().mockReturnValue({ returning: mockReturning })
  const mockSet         = vi.fn().mockReturnValue({ where: mockWhere })
  const mockUpdate      = vi.fn().mockReturnValue({ set: mockSet })
  return { mockUpdate, mockSet, mockWhere, mockReturning }
})

vi.mock('../../src/db/client.js', () => ({ getDrizzleClient: vi.fn().mockResolvedValue({ update: mocks.mockUpdate }) }))
vi.mock('@mantleframework/observability', () => ({ /* same as above */ }))

import { updateFile } from '../../src/entities/queries/fileQueries.js'
import { files } from '../../src/db/schema.js'

describe('FileQueries.updateFile', () => {
  beforeEach(() => vi.clearAllMocks())

  it('calls update with correct table and data', async () => {
    await updateFile('abc', { status: 'ready' })
    expect(mocks.mockUpdate).toHaveBeenCalledWith(files)
    expect(mocks.mockSet).toHaveBeenCalledWith({ status: 'ready' })
  })

  it('propagates database errors', async () => {
    mocks.mockReturning.mockRejectedValueOnce(new Error('connection refused'))
    await expect(updateFile('abc', { status: 'ready' })).rejects.toThrow()
  })
})

Integration tests

Integration tests run against real LocalStack (S3, EventBridge) and a local PostgreSQL instance. They are excluded from vitest.config.ts and run separately:

bash
pnpm test:integration

Integration tests seed data in beforeAll, exercise the full Lambda handler chain, and clean up in afterAll:

typescript
import { beforeAll, afterAll, describe, it, expect } from 'vitest'
import { getDb } from '../../src/db/client.js'
import { books } from '../../src/db/schema.js'

describe('API → EventBridge → S3', () => {
  beforeAll(async () => {
    const db = await getDb()
    await db.insert(books).values({ asin: 'B08XYZ', title: 'Test Book', ... })
      .onConflictDoUpdate({ target: books.asin, set: { title: 'Test Book' } })
  })

  afterAll(async () => {
    const db = await getDb()
    await db.delete(books).where(eq(books.asin, 'B08XYZ'))
  })

  it('handler returns 200 and S3 object is written', async () => {
    const { handler } = await import('../../src/lambdas/api/books/add.post.js')
    const result = await handler(makeApiEvent({ asin: 'B08XYZ' }), makeLambdaContext('AddBook'))
    expect(result.statusCode).toBe(200)
    // verify S3 via readLocalS3Object helper ...
  })
})

Use dynamic import() inside test bodies to ensure mocks are hoisted before module load when using vi.mock at the top of the file.

Running tests

bash
pnpm test                # unit tests (watch mode)
pnpm test --run          # unit tests (CI / single pass)
pnpm test:integration    # integration tests (requires LocalStack)
pnpm typecheck           # TypeScript type-check without emitting

Next steps

  • Entity Queries — what entity query classes look like
  • Observability — what @mantleframework/observability exports and how to use them in production