Testing
Superfunction applications can be thoroughly tested using modern testing tools like Vitest for unit/integration tests and Playwright for E2E tests.
Testing Setup
Install testing dependencies:
Terminal# Install Vitest for unit and integration tests pnpm add -D vitest @vitest/ui # Install testing utilities pnpm add -D @testing-library/react @testing-library/jest-dom happy-dom # Install Playwright for E2E tests pnpm add -D @playwright/test
Vitest Configuration
Create vitest.config.ts in your project root:
typescriptimport { defineConfig } from 'vitest/config'; import path from 'path'; export default defineConfig({ test: { globals: true, environment: 'happy-dom', setupFiles: ['./src/test/setup.ts'], include: ['**/*.{test,spec}.{ts,tsx}'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'src/test/', '**/*.d.ts', '**/*.config.*', '**/dist/', ], }, }, resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, });
Test Setup File
Create src/test/setup.ts:
typescriptimport '@testing-library/jest-dom'; import { beforeAll, afterAll, afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; // Cleanup after each test afterEach(() => { cleanup(); }); // Setup database connection for integration tests beforeAll(async () => { // Initialize test database process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; }); afterAll(async () => { // Cleanup database connections });
Update package.json
json{ "scripts": { "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" } }
Unit Testing
Testing Helper Functions
Test utility functions in isolation:
typescript// src/lib/utils.test.ts import { describe, it, expect } from 'vitest'; import { slugify, formatDate } from '@/lib/utils'; describe('slugify', () => { it('should convert string to slug', () => { expect(slugify('Hello World')).toBe('hello-world'); expect(slugify('TypeScript & Node.js')).toBe('typescript-nodejs'); }); it('should handle special characters', () => { expect(slugify('Hello! @#$% World')).toBe('hello-world'); }); }); describe('formatDate', () => { it('should format date correctly', () => { const date = new Date('2024-01-15'); expect(formatDate(date)).toBe('January 15, 2024'); }); });
Testing Input Schema Validation
Test TypeBox schema validation for route inputs:
typescript// src/server/routes/users.test.ts import { describe, it, expect } from 'vitest'; import { Value } from '@sinclair/typebox/value'; import { Type } from '@sinclair/typebox'; // Define schemas matching your route inputs const createUserSchema = Type.Object({ email: Type.String({ format: 'email' }), name: Type.String({ minLength: 1 }), password: Type.String({ minLength: 8 }) }); describe('createUser input validation', () => { it('should validate correct user data', () => { const validData = { email: 'user@example.com', name: 'John Doe', password: 'secure123', }; const result = Value.Check(createUserSchema, validData); expect(result).toBe(true); }); it('should reject invalid email', () => { const invalidData = { email: 'not-an-email', name: 'John Doe', password: 'secure123', }; const result = Value.Check(createUserSchema, invalidData); expect(result).toBe(false); }); it('should reject short password', () => { const invalidData = { email: 'user@example.com', name: 'John Doe', password: '123', }; const result = Value.Check(createUserSchema, invalidData); expect(result).toBe(false); }); });
Testing Database Helpers
Test database helper functions:
typescript// src/lib/db/helpers.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { findOne, create, updateOne } from '@spfn/core/db'; import { users } from '@/server/entities/users'; import { getDatabaseOrThrow } from '@spfn/core/db'; describe('Database Helpers', () => { beforeEach(async () => { // Clean up test data const db = getDatabaseOrThrow(); await db.delete(users); }); describe('create', () => { it('should create a new user', async () => { const userData = { email: 'test@example.com', name: 'Test User', }; const user = await create(users, userData); expect(user).toMatchObject(userData); expect(user.id).toBeDefined(); expect(user.createdAt).toBeInstanceOf(Date); }); }); describe('findOne', () => { it('should find user by id', async () => { const created = await create(users, { email: 'test@example.com', name: 'Test User', }); const found = await findOne(users, { id: created.id }); expect(found).toMatchObject(created); }); it('should return null when user not found', async () => { const found = await findOne(users, { id: '99999' }); expect(found).toBeNull(); }); }); describe('updateOne', () => { it('should update user', async () => { const created = await create(users, { email: 'test@example.com', name: 'Test User', }); const updated = await updateOne(users, { id: created.id }, { name: 'Updated Name', }); expect(updated.name).toBe('Updated Name'); expect(updated.email).toBe(created.email); }); }); });
Integration Testing
Testing API Endpoints
Test Hono routes with full validation:
typescript// src/server/routes/users.test.ts import { describe, it, expect, beforeEach } from 'vitest'; import { Hono } from 'hono'; import { registerRoutes } from '@spfn/core/route'; import { appRouter } from '@/server/router'; import { getDatabaseOrThrow } from '@spfn/core/db'; import { users } from '@/server/entities/users'; // Create test app const app = new Hono(); registerRoutes(app, appRouter); describe('Users API', () => { beforeEach(async () => { const db = getDatabaseOrThrow(); await db.delete(users); }); describe('POST /users', () => { it('should create a new user', async () => { const res = await app.request('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'test@example.com', name: 'Test User', password: 'secure123', }), }); expect(res.status).toBe(201); const data = await res.json(); expect(data).toMatchObject({ email: 'test@example.com', name: 'Test User', }); expect(data.id).toBeDefined(); }); it('should return 400 for invalid email', async () => { const res = await app.request('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'invalid-email', name: 'Test User', password: 'secure123', }), }); expect(res.status).toBe(400); }); it('should return 409 for duplicate email', async () => { // Create first user await app.request('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'test@example.com', name: 'User 1', password: 'secure123', }), }); // Try to create duplicate const res = await app.request('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'test@example.com', name: 'User 2', password: 'secure123', }), }); expect(res.status).toBe(409); }); }); describe('GET /users/:id', () => { it('should get user by id', async () => { // Create user const createRes = await app.request('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'test@example.com', name: 'Test User', password: 'secure123', }), }); const created = await createRes.json(); // Get user const res = await app.request(`/users/${created.id}`); expect(res.status).toBe(200); const data = await res.json(); expect(data).toMatchObject({ id: created.id, email: 'test@example.com', name: 'Test User', }); }); it('should return 404 for non-existent user', async () => { const res = await app.request('/users/99999'); expect(res.status).toBe(404); }); }); });
Testing with Middleware
Test protected routes with authentication:
typescript// src/server/routes/protected.test.ts import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { registerRoutes } from '@spfn/core/route'; import { appRouter } from '@/server/router'; import { generateToken } from '@/lib/auth'; const app = new Hono(); registerRoutes(app, appRouter); describe('Protected Routes', () => { const validToken = generateToken({ userId: '1', email: 'test@example.com' }); describe('GET /profile', () => { it('should return 401 without token', async () => { const res = await app.request('/profile'); expect(res.status).toBe(401); }); it('should return 401 with invalid token', async () => { const res = await app.request('/profile', { headers: { Authorization: 'Bearer invalid-token', }, }); expect(res.status).toBe(401); }); it('should return profile with valid token', async () => { const res = await app.request('/profile', { headers: { Authorization: `Bearer ${validToken}`, }, }); expect(res.status).toBe(200); const data = await res.json(); expect(data.email).toBe('test@example.com'); }); }); });
E2E Testing with Playwright
Playwright Configuration
Create playwright.config.ts:
typescriptimport { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { baseURL: 'http://localhost:3790', trace: 'on-first-retry', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], webServer: { command: 'pnpm run spfn:start', url: 'http://localhost:3790', reuseExistingServer: !process.env.CI, }, });
E2E Test Example
typescript// e2e/users.spec.ts import { test, expect } from '@playwright/test'; test.describe('User Management', () => { test('should create a new user', async ({ page }) => { await page.goto('/users/new'); // Fill form await page.fill('input[name="email"]', 'test@example.com'); await page.fill('input[name="name"]', 'Test User'); await page.fill('input[name="password"]', 'secure123'); // Submit await page.click('button[type="submit"]'); // Verify success await expect(page).toHaveURL(/\/users\/\d+/); await expect(page.locator('h1')).toContainText('Test User'); }); test('should display validation errors', async ({ page }) => { await page.goto('/users/new'); // Submit without filling form await page.click('button[type="submit"]'); // Verify errors await expect(page.locator('.error')).toContainText('Email is required'); }); test('should list all users', async ({ page, request }) => { // Create test users via API await request.post('http://localhost:8790/users', { data: { email: 'user1@example.com', name: 'User 1', password: 'secure123', }, }); await request.post('http://localhost:8790/users', { data: { email: 'user2@example.com', name: 'User 2', password: 'secure123', }, }); // Visit users page await page.goto('/users'); // Verify users are listed await expect(page.locator('text=User 1')).toBeVisible(); await expect(page.locator('text=User 2')).toBeVisible(); }); });
Test Patterns
Factory Pattern for Test Data
Create factories to generate test data:
typescript// src/test/factories/user.factory.ts import { faker } from '@faker-js/faker'; import { create } from '@spfn/core/db'; import { users } from '@/server/entities/users'; export async function createUser(overrides = {}) { const userData = { email: faker.internet.email(), name: faker.person.fullName(), password: faker.internet.password(), ...overrides, }; return create(users, userData); } export function buildUser(overrides = {}) { return { email: faker.internet.email(), name: faker.person.fullName(), password: faker.internet.password(), ...overrides, }; } // Usage in tests import { createUser, buildUser } from '@/test/factories/user.factory'; // Create in database const user = await createUser({ email: 'specific@example.com' }); // Build without saving const userData = buildUser({ name: 'Specific Name' });
Database Seeding
typescript// src/test/seed.ts import { getDatabaseOrThrow } from '@spfn/core/db'; import { users, teams } from '@/server/entities'; import { createUser } from '@/test/factories/user.factory'; export async function seedDatabase() { // Create test users const user1 = await createUser({ email: 'admin@example.com', name: 'Admin User', }); const user2 = await createUser({ email: 'user@example.com', name: 'Regular User', }); // Create test teams const db = getDatabaseOrThrow(); await db.insert(teams).values([ { name: 'Engineering', ownerId: user1.id }, { name: 'Marketing', ownerId: user2.id }, ]); } export async function cleanDatabase() { const db = getDatabaseOrThrow(); await db.delete(teams); await db.delete(users); } // Usage in test setup import { beforeEach } from 'vitest'; import { seedDatabase, cleanDatabase } from '@/test/seed'; beforeEach(async () => { await cleanDatabase(); await seedDatabase(); });
Mocking External Services
typescript// src/test/mocks/email.ts import { vi } from 'vitest'; export const mockEmailService = { sendEmail: vi.fn().mockResolvedValue({ success: true }), sendPasswordReset: vi.fn().mockResolvedValue({ success: true }), }; // Usage in tests import { describe, it, expect, beforeEach } from 'vitest'; import { mockEmailService } from '@/test/mocks/email'; describe('Password Reset', () => { beforeEach(() => { mockEmailService.sendPasswordReset.mockClear(); }); it('should send password reset email', async () => { await resetPassword('user@example.com'); expect(mockEmailService.sendPasswordReset).toHaveBeenCalledWith({ to: 'user@example.com', token: expect.any(String), }); }); });
CI/CD Integration
GitHub Actions workflow for running tests:
yaml# .github/workflows/test.yml name: Tests on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRES_USER: test POSTGRES_PASSWORD: test POSTGRES_DB: test_db options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install pnpm uses: pnpm/action-setup@v2 with: version: 8 - name: Install dependencies run: pnpm install - name: Run unit tests env: DATABASE_URL: postgresql://test:test@localhost:5432/test_db run: pnpm test - name: Run E2E tests env: DATABASE_URL: postgresql://test:test@localhost:5432/test_db run: pnpm test:e2e - name: Upload coverage uses: codecov/codecov-action@v3 with: files: ./coverage/coverage-final.json - name: Upload Playwright report if: always() uses: actions/upload-artifact@v3 with: name: playwright-report path: playwright-report/
Best Practices
1. Test Organization
- Keep tests close to source code (co-location)
- Use descriptive test names that explain behavior
- Follow Arrange-Act-Assert pattern
- One assertion per test (when possible)
2. Database Testing
- Use a separate test database
- Clean up data between tests
- Use transactions for test isolation
- Seed data consistently
3. API Testing
- Test both success and error cases
- Validate response structure and types
- Test input validation
- Test middleware behavior
4. Performance
- Run tests in parallel when possible
- Mock external services to reduce latency
- Use factories instead of fixtures for flexibility
- Keep E2E tests minimal and focused
Note: Test Coverage Goals
- Unit tests: Aim for 80%+ coverage on business logic
- Integration tests: Cover all API endpoints
- E2E tests: Cover critical user journeys
- Input validation tests: Validate all schema definitions
Next: Deployment
Now that you know how to test your Superfunction application, learn how to deploy it to production.