Testing
Mantle instances use Vitest with two separate configurations: unit tests (fast, mocked dependencies) and integration tests (real LocalStack + PostgreSQL).
Vitest configuration
// 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 LocalStackPlace 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:
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
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.unstubAllEnvsrather than mutatingprocess.envdirectly - Clear mocks in
afterEachto prevent state leaking between tests
Testing entity queries
Entity query tests mock the database client and verify the Drizzle call chain:
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:
pnpm test:integrationIntegration tests seed data in beforeAll, exercise the full Lambda handler chain, and clean up in afterAll:
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
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 emittingNext steps
- Entity Queries — what entity query classes look like
- Observability — what
@mantleframework/observabilityexports and how to use them in production