Testing Guide
This guide covers testing patterns and best practices for @geekmidas applications.
Testing Philosophy
The project follows an "Integration over Unit" testing philosophy:
- Integration over Unit - Prefer tests that verify complete integration between components
- Behavior over Implementation - Test what the code does, not how it works internally
- Real Dependencies over Mocks - Use actual implementations when possible
- Comprehensive Coverage - Test both happy paths and edge cases
Setup
Vitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
testTimeout: 10000,
coverage: {
provider: 'v8',
thresholds: {
functions: 85,
branches: 85,
},
},
},
});Where the test database comes from
gkm test reconciles the test stage before the suite runs: the same containers gkm dev uses, with the resources named for the stage. So there is no docker compose up in a test script and no DATABASE_URL to set — a declared database is what makes a Postgres exist, and its URL is injected under the key that database publishes.
// test/config.ts
import { it as itVitest } from 'vitest';
import { wrapVitestKyselyTransaction } from '@geekmidas/testkit/kysely';
import { Kysely, PostgresDialect } from 'kysely';
import pg from 'pg';
import type { Database } from '../src/constructs/database.ts';
const connection = new Kysely<Database>({
dialect: new PostgresDialect({
// `ORDERS_URL` for `new KyselyDatabase<Database, 'Orders'>('Orders')`.
pool: new pg.Pool({ connectionString: process.env.ORDERS_URL }),
}),
});
export const it = wrapVitestKyselyTransaction<Database>(itVitest, { connection });Migrations connect as the owner role — ORDERS_OWNER_URL, the one that may create, alter, and drop. A handler is never given it: .dependsOn() takes constructs, and .owner is a Service, so a handler cannot ask for DDL rights at all.
gkm test # reconcile the test stage, then run
gkm test --run --coverage # once, with coverage
GKM_AUTO_SETUP=1 gkm test # CI: generate a fresh stage when none existsRunning Tests
pnpm test # Watch mode
pnpm test:once # Single run with coverage
pnpm test:ui # Visual UI
pnpm test path/to/file # Specific fileFeature Tests
gkm test generates #test: an it that hands each test what it uses, by name, so a test imports nothing but it:
import { it } from '#test';
it('updates my profile', async ({ browser, faker }) => {
const { user } = await browser.signIn();
await browser.api.post('/users', {
body: { name: faker.person.fullName(), email: user.email },
});
// …
});browser.signIn()signs in through the auth server's magic link, the way a person does: the email really sent to Mailpit, the link really followed. With no address it signs in as somebody new, unique to the test, anduser.emailsays who. Pass an address when the test needs a particular one.fakeris testkit's faker, the same one factories are given, seeded from the test's name: a failing test fails again with the same data.An
ExternalApi's fake —test/fakes/<id>.ts— answers at the URL the test stage resolved for it, so a handler that calls Polar calls the fake with nothing for the test to set up. See Fakes.browser.on(slug), in an app with a site on every subdomain, is the same browser on that tenant's host: one cookie jar, its clients, andOrigin: https://<slug>.test.<project>.localhost:<port>on every request. A test signs in once and acts as two tenants:typescriptit('keeps each tenant to its own orders', async ({ browser }) => { await browser.signIn(); await browser.on('acme').api.post('/orders', { body: { sku: 'A1' } }); expect(await browser.on('globex').api.get('/orders')).toEqual([]); });
Fakes
A fake stands in for an API somebody else runs — Polar, a carrier, a payment gateway — wherever the real one can't be called: in every feature test, and in gkm dev --fake. It is a working implementation of the API, not a mock: the client under test talks HTTP to it exactly as it would to the provider, and the test asserts on what the endpoint did and what the fake received, never on which functions were called.
Where a fake lives
One file per ExternalApi, at test/fakes/<id>.ts in the workspace root — the construct's id in kebab case, so Shipping is test/fakes/shipping.ts and PayFast is test/fakes/pay-fast.ts. gkm finds it by that path, the way it finds test/factories/<database>.ts.
The construct never imports its fake. That is what keeps a fake, and the responses it answers from, out of every deployed bundle, however a bundler follows imports.
Writing one
Default-export fake.app(…) with a fetch handler — a Hono app is the usual one — and the credentials the fake accepts:
// test/fakes/shipping.ts
import { fake } from '@geekmidas/constructs/external-api';
import type { shipping } from '../../constructs/shipping';
import { Hono } from 'hono';
const carrier = new Hono().post('/quotes', async (c) => {
// Refuse what the provider refuses, so the client's error path is tested.
if (c.req.header('authorization') !== 'Bearer fake-key') {
return c.json({ error: 'unknown key' }, 401);
}
const { destination, weightKg } = await c.req.json();
return c.json({ destination, amount: 50 + weightKg * 10 });
});
export default fake.app<typeof shipping>(carrier, {
credentials: { apiKey: 'fake-key' },
});- Answer in the provider's shape. Start from a recorded response — the provider's docs, or a sandbox call saved as JSON beside the fake — so the client parses what it will parse in production.
- Cover the paths the client handles: the refusal for bad credentials, a missing resource, a rate limit. A fake that only ever succeeds tests only the happy path.
credentialsare what a test andgkm dev --fakehand the construct as<ID>_CREDENTIALS.fake.app<typeof shipping>checks them against the construct's schema, and the handler checks them the way the provider would.
Asserting on what it received
A fake stays hidden: a test never imports one or reads its state. It asserts through the client a handler gets — services.get('shipping') — so the assertion is about the provider's contract, and holds against the provider's sandbox as much as against the fake.
So a fake implements the provider's read endpoints too, not only the ones the app calls to act. The carrier lists the quotes it was asked for; the fake does the same:
// constructs/shipping.ts — the client gains the carrier's read endpoint
client: ({ url, credentials }) => ({
async quote(destination: string, weightKg: number): Promise<Quote> { /* POST /quotes */ },
async quotes(destination: string): Promise<QuoteRequest[]> { /* GET /quotes */ },
}),// test/fakes/shipping.ts — a more complete carrier, exporting only itself
const asked = new Map<string, QuoteRequest[]>();
const carrier = new Hono()
.post('/quotes', async (c) => { /* record, then quote */ })
.get('/quotes', (c) => c.json(asked.get(c.req.query('destination') ?? '') ?? []));
export default fake.app<typeof shipping>(carrier, {
credentials: { apiKey: 'fake-key' },
});// apps/api/__tests__/shipping.spec.ts
import { it } from '#test';
it('asks the carrier for exactly the parcel it was given', async ({
browser,
faker,
services,
}) => {
const destination = faker.location.city();
await browser.api.post('/shipping/quotes', {
body: { destination, weightKg: 3.5 },
});
const carrier = await services.get('shipping');
expect(await carrier.quotes(destination)).toEqual([
{ destination, weightKg: 3.5 },
]);
});services.get(name) resolves by service name, typed by each construct's client, the way the test's endpoints resolve it: an external API's client aimed at what the test stage resolved, a topic or queue as its recorder, a database as the test's transaction.
A provider with no read endpoint — a push service, an incoming webhook — gives a test nothing to ask it. Assert on what the app itself did instead: the row it wrote, the status it recorded, what it published.
Background work, end to end
What a request publishes is delivered before the test sees the response — in-process, no broker, no Docker:
- A queue's messages go to its one consumer, as a batch.
- A topic's events go to every subscriber that named that event type, and only those — the fan-out the broker does deployed.
- Each payload is checked against the consumer's schema first, so a producer sending the wrong shape fails the test (
MessageRejected). - Consumers run in the test's transaction: they see the rows the endpoint wrote, and what they write rolls back with the test.
- What a consumer publishes is delivered in turn, until nothing is left. A chain that never settles fails with
DeliveryDidNotSettle. - A consumer that throws fails the test (
DeliveryFailed). Deployed, the message would be retried; here that would only hide the bug.
it('writes a notification when a user is created', async ({ browser, db }) => {
const user = await browser.api.post('/users', { body: { name: 'Ada', email } });
// The `user.created` subscriber has already run.
const app = await db.get('database');
expect(
await app.selectFrom('notifications').where('user_id', '=', user.id).execute(),
).toHaveLength(1);
});What is tested is the contract: what a handler is handed — messages for a queue, events ({ type, payload }) for a subscriber. Turning an SNS envelope or an SQS record into that shape is each adaptor's job, and tested there. One thing to know: a transport carries JSON, so a Date in a payload arrives as a string deployed — write payload schemas with z.iso.datetime(), not z.date().
published(topic) still records everything published, and queue(q).invoke(...) / subscriber(s).invoke(...) run a consumer on its own — a redelivery, say — and deliver whatever it publishes.
Pushes to a WebSocket surface
What a handler sends through services.<surface> of a WebSocketApi is recorded, per test, rather than delivered: sockets(realtime) is the recorder every handler in the test was handed. Each send is still checked by the surface's declared messages, so an invalid push fails the request as it would deployed.
it('tells the chat a message arrived', async ({ browser, sockets }) => {
await browser.api.post('/chats/c1/messages', { body: { text: 'hi' } });
expect(sockets(realtime).sent).toEqual([
{
ids: ['a.1'],
action: 'chat.message.created',
data: { chatId: 'c1', text: 'hi' },
frame: { type: 'chat.message.created', chatId: 'c1', text: 'hi' }, // the wire
},
]);
});.broadcasts and .closed record the rest. The surface's own routes are tested with route.request() — see Testing WebSocket routes.
State lives for the test file
A fake is loaded once per test file, not once per test, so what it keeps carries over from one test to the next in the same file. Read state by a key the test chose — a destination, a user id, an email from the test's faker — rather than the whole list, as above. Different test files never share a fake's state.
A provider's own local server
Where the provider publishes one — Stripe's stripe-mock — use its image instead of writing a fake. It runs as a container beside Postgres and Mailpit, on an allocated host port, in tests and in gkm dev --fake:
// test/fakes/stripe.ts
import { fake } from '@geekmidas/constructs/external-api';
import type { stripe } from '../../constructs/stripe';
export default fake.image<typeof stripe>('stripe/stripe-mock', {
port: 12111, // the port the image listens on inside the container
credentials: { secretKey: 'sk_test_fake', webhookSecret: 'whsec_fake' },
});There is nothing to import from an image fake: assert on what the endpoint returned, or on what the provider's server reports through its own API.
A credential's test value
A Credential has no server to fake, only a value — and nobody issues one to a test stage set up fresh, as CI's GKM_AUTO_SETUP does: it stores no third party's values. So a credential a test reaches has a fake in the same folder, default-exporting fake.credential(…) with the value as its schema takes it:
// constructs/reviewer.ts
export const reviewerPassword = new Credential('ReviewerPassword', {
schema: z.string().min(16),
});
// test/fakes/reviewer-password.ts
import { fake } from '@geekmidas/constructs/credential';
import type { reviewerPassword } from '../../constructs/reviewer';
export default fake.credential<typeof reviewerPassword>(
'a-reviewer-password-for-tests',
);gkm test hands it to the suite as REVIEWER_PASSWORD_CREDENTIALS, so services.get('reviewerPassword') in a feature test — and a handler that depends on it — gets 'a-reviewer-password-for-tests', validated against the schema like the real value. gkm dev --fake hands it to the apps it starts.
A fake wins over a value the stage stores, as an external API's does. Without one, a stored value is used — so a credential that only a developer's own stage sets needs no fake until a fresh stage runs the suite. A deployed stage never reads it: gkm deploy still refuses a stage without the real <ID>_CREDENTIALS.
In gkm dev
gkm dev calls the real API — its sandbox — with the local stage's own credentials. gkm dev --fake serves every app fake on a port of its own and runs every image fake, and the apps it starts are pointed at them.
When there is none
gkm test and gkm dev --fake stop before anything runs:
| Error | Means |
|---|---|
NoFake | an ExternalApi has no test/fakes/<id>.ts; the message names the file to create |
NotAFake | the file's default export isn't fake.app(…) or fake.image(…) — or, for a Credential, fake.credential(…) |
CredentialHasNoTestValue | gkm test only: a Credential has no fake and the stage stores no value; the message names the key and the file to create |
Commands that act on a stage — gkm setup, secrets:*, deploy — never read a fake, and don't need one to exist.
Unit Testing
Testing Services
import { describe, it, expect } from 'vitest';
import { InMemoryCache } from '@geekmidas/cache/memory';
import { UserService } from './user-service';
describe('UserService', () => {
it('should cache user after fetch', async () => {
// Use real cache implementation
const cache = new InMemoryCache<User>();
const service = new UserService({ cache });
const user = await service.getUser('123');
// Verify behavior
expect(user.id).toBe('123');
expect(await cache.get('user:123')).toEqual(user);
});
});Testing with Factories
import { describe, it, expect } from 'vitest';
import { faker } from '@geekmidas/testkit/faker';
describe('OrderCalculator', () => {
it('should calculate total with tax', () => {
const items = [
{ name: faker.commerce.productName(), price: 10.00, quantity: 2 },
{ name: faker.commerce.productName(), price: 25.00, quantity: 1 },
];
const result = calculateOrder(items, { taxRate: 0.1 });
expect(result.subtotal).toBe(45.00);
expect(result.tax).toBe(4.50);
expect(result.total).toBe(49.50);
});
});Integration Testing
Database Integration
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { KyselyFactory } from '@geekmidas/testkit/kysely';
import { VitestKyselyTransactionIsolator } from '@geekmidas/testkit/kysely';
import { db, builders, seeds } from './test-setup';
describe('UserRepository', () => {
const isolator = new VitestKyselyTransactionIsolator(db);
const factory = new KyselyFactory(builders, seeds, db);
beforeEach(async () => {
await isolator.begin();
});
afterEach(async () => {
await isolator.rollback();
});
it('should create user with profile', async () => {
// Create test data
const user = await factory.insert('user', {
email: 'test@example.com',
});
// Test the repository
const result = await userRepository.findById(user.id);
expect(result).toMatchObject({
id: user.id,
email: 'test@example.com',
});
});
it('should use seed for complex scenario', async () => {
// Use a seed for complex data setup
const { user, posts } = await factory.seed('userWithPosts');
const result = await userRepository.getUserWithPosts(user.id);
expect(result.posts).toHaveLength(posts.length);
});
});API Endpoint Testing
import { describe, it, expect } from 'vitest';
import { createTestApp } from '@geekmidas/constructs/testing';
import { createUserEndpoint, getUserEndpoint } from './endpoints';
describe('User API', () => {
const app = createTestApp([createUserEndpoint, getUserEndpoint]);
it('should create and retrieve user', async () => {
// Create user
const createRes = await app
.post('/users')
.send({ name: 'John Doe', email: 'john@example.com' })
.expect(201);
expect(createRes.body).toMatchObject({
id: expect.any(String),
name: 'John Doe',
});
// Retrieve user
const getRes = await app
.get(`/users/${createRes.body.id}`)
.expect(200);
expect(getRes.body.email).toBe('john@example.com');
});
it('should validate input', async () => {
const res = await app
.post('/users')
.send({ name: '' }) // Invalid: missing email
.expect(400);
expect(res.body.error).toBeDefined();
});
});Mocking External APIs with MSW
An API declared as an ExternalApi needs none of this in a feature test: its fake is served at its URL for you. For code that calls an API directly, use Mock Service Worker:
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
// Define handlers
const handlers = [
http.get('https://api.stripe.com/v1/customers/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
email: 'customer@example.com',
});
}),
http.post('https://api.stripe.com/v1/charges', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
id: 'ch_test123',
amount: body.amount,
status: 'succeeded',
});
}),
];
const server = setupServer(...handlers);
describe('PaymentService', () => {
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('should process payment', async () => {
const result = await paymentService.charge({
customerId: 'cus_123',
amount: 1000,
});
expect(result.status).toBe('succeeded');
});
it('should handle API errors', async () => {
// Override handler for this test
server.use(
http.post('https://api.stripe.com/v1/charges', () => {
return HttpResponse.json(
{ error: { message: 'Card declined' } },
{ status: 402 }
);
})
);
await expect(
paymentService.charge({ customerId: 'cus_123', amount: 1000 })
).rejects.toThrow('Card declined');
});
});Testing Authentication
import { describe, it, expect } from 'vitest';
import { createTestApp } from '@geekmidas/constructs/testing';
import { JwtVerifier } from '@geekmidas/auth/jwt';
import { protectedEndpoint } from './endpoints';
describe('Protected Endpoints', () => {
const verifier = new JwtVerifier({ secret: 'test-secret' });
const app = createTestApp([protectedEndpoint]);
it('should reject without token', async () => {
await app.get('/profile').expect(401);
});
it('should accept valid token', async () => {
const token = await verifier.sign({ sub: 'user-123', role: 'admin' });
const res = await app
.get('/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(res.body.userId).toBe('user-123');
});
it('should reject expired token', async () => {
const token = await verifier.sign(
{ sub: 'user-123' },
{ expiresIn: '-1h' } // Already expired
);
await app
.get('/profile')
.set('Authorization', `Bearer ${token}`)
.expect(401);
});
});Testing Events
import { describe, it, expect, vi } from 'vitest';
import { BasicPublisher, BasicSubscriber } from '@geekmidas/events/basic';
describe('Event System', () => {
it('should publish and receive events', async () => {
const publisher = new BasicPublisher();
const subscriber = new BasicSubscriber();
const handler = vi.fn();
await subscriber.subscribe(['user.created'], handler);
await publisher.publish([
{ type: 'user.created', payload: { userId: '123' } },
]);
// Allow async processing
await new Promise((r) => setTimeout(r, 10));
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'user.created',
payload: { userId: '123' },
})
);
});
});Enhanced Faker Utilities
The testkit provides enhanced faker utilities:
import { faker } from '@geekmidas/testkit/faker';
// Timestamps for database records
const timestamps = faker.timestamps();
// { createdAt: Date, updatedAt: Date }
// Unique sequences
const email1 = `user${faker.sequence('email')}@example.com`; // user1@example.com
const email2 = `user${faker.sequence('email')}@example.com`; // user2@example.com
// Reset sequences between tests
faker.resetSequence('email');
faker.resetAllSequences();
// Prices (as numbers, not strings)
const price = faker.price(); // 29.99
// A birthdate for someone of a given age today
const adult = faker.age(18); // exactly 18
const student = faker.age(18, 24); // 18 to 24, inclusive
// Coordinates within radius
const location = faker.coordinates.within(
{ lat: 40.7128, lng: -74.0060 }, // NYC
5000 // 5km radius
);
// Coordinates outside radius
const farLocation = faker.coordinates.outside(
{ lat: 40.7128, lng: -74.0060 },
10000, // min 10km
50000 // max 50km
);Performance Testing
import { describe, bench } from 'vitest';
describe('Performance', () => {
bench('should handle 1000 validations', () => {
for (let i = 0; i < 1000; i++) {
schema.parse({ name: 'test', email: 'test@example.com' });
}
});
bench('should serialize large response', () => {
const data = generateLargeDataset(10000);
JSON.stringify(data);
});
});Run benchmarks:
pnpm benchSnapshot Testing
import { describe, it, expect } from 'vitest';
describe('OpenAPI Generation', () => {
it('should generate consistent schema', async () => {
const schema = await Endpoint.buildOpenApiSchema([
usersEndpoint,
ordersEndpoint,
]);
expect(schema).toMatchSnapshot();
});
});Update snapshots:
pnpm test -uCoverage Requirements
The project enforces minimum coverage:
- Functions: 85%
- Branches: 85%
Check coverage:
pnpm test:once --coverageBest Practices
Do
- Use real cache/database implementations when possible
- Test behavior, not implementation details
- Use MSW for external HTTP APIs
- Use transaction isolation for database tests
- Reset state between tests
- Test error scenarios and edge cases
Don't
- Mock internal dependencies heavily
- Test private methods directly
- Skip error scenario tests
- Use shared state between tests
- Hard-code test data (use factories)
Test File Organization
src/
├── users/
│ ├── user-service.ts
│ ├── user-service.spec.ts # Unit tests
│ └── __tests__/
│ └── user-api.integration.spec.ts
├── __fixtures__/
│ └── users.json # Shared test data
└── __helpers__/
└── test-utils.ts # Shared test utilities