Testing
Putnami uses Bun's native test runner for fast, integrated testing. Run tests with the CLI to automatically detect impacted projects and optimize test execution.
Running tests
Basic commands
# Run tests for impacted projects only
bunx putnami test --impacted
# Run all tests
bunx putnami test --all
# Run tests for specific project
bunx putnami test my-project
# Watch mode
bunx putnami test . --watchTest file patterns
Tests are discovered by these patterns:
**/*.test.ts**/*.spec.tstest/**/*.ts
Unit testing
Testing services with app.fork()
The recommended approach is to fork your production application and override specific providers with test doubles:
// src/services/user.service.test.ts
import { describe, test, expect, mock } from 'bun:test';
import { app } from '../app'; // your production application
import { UserService } from './user.service';
import { UserRepository } from '../repositories/user.repository';
describe('UserService', () => {
test('getUser returns user by id', async () => {
await using testApp = app.fork()
.override(UserRepository, () => ({
findOne: mock(() => Promise.resolve({ id: '1', name: 'Test' })),
save: mock((data) => Promise.resolve({ id: '1', ...data })),
delete: mock(() => Promise.resolve()),
}));
await testApp.start();
const service = testApp.get(UserService);
const user = await service.getUser('1');
expect(user).toEqual({ id: '1', name: 'Test' });
});
test('createUser saves and returns user', async () => {
await using testApp = app.fork()
.override(UserRepository, () => ({
findOne: mock(() => Promise.resolve(null)),
save: mock((data) => Promise.resolve({ id: '1', ...data })),
}));
await testApp.start();
const service = testApp.get(UserService);
const user = await service.createUser({
name: 'New User',
email: 'new@example.com',
});
expect(user.name).toBe('New User');
expect(user.email).toBe('new@example.com');
});
});Testing services with a standalone context
You can also create a self-contained ContainerContext for tests:
import { describe, test, expect, mock } from 'bun:test';
import { ContainerContext, provide } from '@putnami/runtime';
import { UserService } from './user.service';
import { UserRepository } from '../repositories/user.repository';
describe('UserService', () => {
test('getUser returns user by id', async () => {
const ctx = new ContainerContext('test');
ctx.register(provide(UserRepository, () => ({
findOne: mock(() => Promise.resolve({ id: '1', name: 'Test' })),
save: mock((data) => Promise.resolve({ id: '1', ...data })),
delete: mock(() => Promise.resolve()),
})));
ctx.register(provide(UserService, { deps: [UserRepository] }));
await ctx.start();
const service = ctx.get(UserService);
const user = await service.getUser('1');
expect(user).toEqual({ id: '1', name: 'Test' });
await ctx.close();
});
});Testing utilities
// src/lib/validators.test.ts
import { describe, test, expect } from 'bun:test';
import { isValidEmail, formatCurrency } from './validators';
describe('isValidEmail', () => {
test('accepts valid emails', () => {
expect(isValidEmail('user@example.com')).toBe(true);
expect(isValidEmail('user.name@domain.co.uk')).toBe(true);
});
test('rejects invalid emails', () => {
expect(isValidEmail('invalid')).toBe(false);
expect(isValidEmail('missing@')).toBe(false);
expect(isValidEmail('@nodomain.com')).toBe(false);
});
});
describe('formatCurrency', () => {
test('formats USD correctly', () => {
expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56');
});
test('handles zero', () => {
expect(formatCurrency(0, 'USD')).toBe('$0.00');
});
});Testing API routes
Route handler tests
Test API routes by creating a test context with mock providers:
// src/app/api/users/get.test.ts
import { describe, test, expect, mock } from 'bun:test';
import { ContainerContext, provide } from '@putnami/runtime';
import { UserRepository } from '../../../repositories/user.repository';
describe('GET /api/users', () => {
test('returns list of users', async () => {
const ctx = new ContainerContext('test');
ctx.register(provide(UserRepository, () => ({
find: mock(() => Promise.resolve([
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' },
])),
})));
await ctx.start();
const repo = ctx.get(UserRepository);
const users = await repo.find();
expect(users).toHaveLength(2);
expect(users[0].name).toBe('User 1');
await ctx.close();
});
});Testing with database
Test database setup
// test/setup.ts
import { database, closeAllDatabases } from '@putnami/sql';
export async function setupTestDb() {
const db = database('test');
// Run migrations
await db`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
name TEXT
)`;
return db;
}
export async function cleanupTestDb() {
const db = database('test');
await db`TRUNCATE users CASCADE`;
}
export async function teardownTestDb() {
await closeAllDatabases();
}Integration tests
// src/services/user.service.integration.test.ts
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { setupTestDb, cleanupTestDb, teardownTestDb } from '../../test/setup';
import { app } from '../app';
import { UserService } from './user.service';
describe('UserService (integration)', () => {
let testApp: Awaited<ReturnType<typeof app.fork>>;
beforeAll(async () => {
await setupTestDb();
testApp = app.fork();
await testApp.start();
});
afterAll(async () => {
await testApp.close();
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
});
test('creates and retrieves user', async () => {
const service = testApp.get(UserService);
const created = await service.createUser({
name: 'Test User',
email: 'test@example.com',
});
const retrieved = await service.getUser(created.id);
expect(retrieved.name).toBe('Test User');
expect(retrieved.email).toBe('test@example.com');
});
test('prevents duplicate emails', async () => {
const service = testApp.get(UserService);
await service.createUser({
name: 'User 1',
email: 'same@example.com',
});
await expect(
service.createUser({
name: 'User 2',
email: 'same@example.com',
})
).rejects.toThrow();
});
});Testing React components
Loader tests
// src/app/users/loader.test.ts
import { describe, test, expect, mock } from 'bun:test';
import { application } from '@putnami/application';
import { UserService } from '../../services/user.service';
import { loader } from './loader';
describe('users loader', () => {
test('returns users from service', async () => {
const app = application()
.provide(UserService, () => ({
listUsers: async () => [
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' },
],
}));
await app.start();
const mockContext = {
queryParams: () => new URLSearchParams(),
};
const result = await loader(mockContext as any);
expect(result.users).toHaveLength(2);
await app.stop();
});
});Mocking
Using Bun's mock
import { mock, spyOn } from 'bun:test';
// Create a mock function
const mockFn = mock(() => 'mocked value');
// Call it
mockFn('arg1', 'arg2');
// Assertions
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(1);
// Mock implementation
const mockAsync = mock(async () => ({ data: 'value' }));
// Spy on existing method
const obj = { method: () => 'original' };
const spy = spyOn(obj, 'method').mockReturnValue('mocked');Mocking modules
import { mock } from 'bun:test';
// Mock at module level
mock.module('./email-client', () => ({
sendEmail: mock(async () => ({ success: true })),
}));
// Now imports will use the mock
import { sendEmail } from './email-client';Mocking fetch
import { mock } from 'bun:test';
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = mock(async (url: string) => {
if (url.includes('/api/users')) {
return new Response(JSON.stringify([{ id: '1', name: 'User' }]));
}
return new Response('Not found', { status: 404 });
});
});
afterEach(() => {
global.fetch = originalFetch;
});Test patterns
Arrange-Act-Assert
test('user can be created', async () => {
// Arrange
const service = get(UserService);
const input = { name: 'Test', email: 'test@example.com' };
// Act
const result = await service.createUser(input);
// Assert
expect(result.name).toBe('Test');
expect(result.email).toBe('test@example.com');
expect(result.id).toBeDefined();
});Test fixtures
// test/fixtures/users.ts
export const testUsers = {
admin: {
id: 'admin-1',
name: 'Admin User',
email: 'admin@example.com',
role: 'admin',
},
regular: {
id: 'user-1',
name: 'Regular User',
email: 'user@example.com',
role: 'user',
},
};
// In tests
import { testUsers } from '../../test/fixtures/users';
test('admin can access dashboard', async () => {
// Use fixture
const result = await checkAccess(testUsers.admin, '/dashboard');
expect(result.allowed).toBe(true);
});Factory functions
// test/factories/user.factory.ts
let counter = 0;
export function createTestUser(overrides: Partial<User> = {}): User {
counter++;
return {
id: `test-user-${counter}`,
name: `Test User ${counter}`,
email: `test${counter}@example.com`,
createdAt: new Date(),
...overrides,
};
}
// In tests
test('lists users', async () => {
const users = [createTestUser(), createTestUser(), createTestUser()];
// ...
});Coverage
Enabling coverage
Coverage is enabled by default in Putnami. View coverage reports after running tests:
bunx putnami test --all
# Coverage report is generated in ./coverage/Coverage configuration
// package.json
{
"putnami": {
"test": {
"coverage": true,
"coverageThreshold": {
"lines": 80,
"functions": 80,
"branches": 70
}
}
}
}Best practices
Test isolation
Each test should be independent. Use await using or fresh ContainerContext instances:
import { ContainerContext, provide } from '@putnami/runtime';
test('isolated test', async () => {
await using ctx = new ContainerContext('test');
ctx.register(provide(MyService));
await ctx.start();
// test with ctx.get(MyService)
});
// ctx is automatically cleaned up via Symbol.asyncDisposeDescriptive test names
// Good
test('createUser throws ConflictException when email exists', async () => {});
test('getUser returns null when user not found', async () => {});
// Bad
test('test1', async () => {});
test('should work', async () => {});Test behavior, not implementation
// Good - tests behavior
test('user can log in with valid credentials', async () => {
const result = await authService.login('user@example.com', 'password');
expect(result.success).toBe(true);
});
// Bad - tests implementation details
test('login calls bcrypt.compare', async () => {
// ...
});Keep tests fast
// Use mocks for external services via provide()
provide(EmailService, () => ({ send: async () => {} }))
// Use in-memory database for unit tests
// Use real database only for integration tests