Putnami
DocsGitHub

Licensed under FSL-1.1-MIT

Getting Started
Concepts
How To
Build A Web App
Build An Api Service
Share Code Between Projects
Configure Your App
Add Persistence
Add Authentication
Add Background Jobs
Principles
Tooling & Workspace
Workspace Overview
Cli
Jobs & Commands
SDK
Error Handling
Extensions
Typescript
Go
Python
Docker
Ci
Frameworks
Typescript
OverviewWebReact RoutingForms And ActionsStatic FilesApiErrors And ResponsesConfigurationLoggingHttp And MiddlewareDependency InjectionPlugins And LifecycleSessionsAuthPersistenceEventsStorageCachingWebsocketsTestingHealth ChecksTelemetryProto GrpcSmart Client
Go
OverviewHttpDependency InjectionPlugins And LifecycleConfigurationSecurityPersistenceErrorsEventsStorageCachingLoggingTelemetryGrpcService ClientsValidationOpenapiTesting
Platform
  1. DocsSeparator
  2. FrameworksSeparator
  3. TypescriptSeparator
  4. Testing

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 . --watch

Test file patterns

Tests are discovered by these patterns:

  • **/*.test.ts
  • **/*.spec.ts
  • test/**/*.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.asyncDispose

Descriptive 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

Related guides

  • Dependency Injection
  • Configuration

On this page

  • Testing
  • Running tests
  • Basic commands
  • Test file patterns
  • Unit testing
  • Testing services with app.fork()
  • Testing services with a standalone context
  • Testing utilities
  • Testing API routes
  • Route handler tests
  • Testing with database
  • Test database setup
  • Integration tests
  • Testing React components
  • Loader tests
  • Mocking
  • Using Bun's mock
  • Mocking modules
  • Mocking fetch
  • Test patterns
  • Arrange-Act-Assert
  • Test fixtures
  • Factory functions
  • Coverage
  • Enabling coverage
  • Coverage configuration
  • Best practices
  • Test isolation
  • Descriptive test names
  • Test behavior, not implementation
  • Keep tests fast
  • Related guides