Testing Standards
Standards for writing consistent, reliable, and maintainable tests across the Eko codebase.
Core Rules
TST-001: Test File Colocation
Test files should be colocated with their source files.
# Good: Colocated tests
packages/shared/src/
utils.ts
utils.test.ts # Test beside source
url-normalization.ts
url-normalization.test.ts
# Also Good: __tests__ folder for complex suites
packages/db/src/
queries.ts
__tests__/
queries.test.ts
queries.integration.test.ts
__fixtures__/
sample-data.json
# Bad: Separate test directory mirroring src
packages/shared/
src/
utils.ts
tests/ # Don't mirror src structure
utils.test.ts
Guidelines:
- Simple tests:
foo.test.tsbesidefoo.ts - Complex suites:
__tests__/directory with multiple files - Integration tests:
*.integration.test.tssuffix
TST-002: Test Naming Convention
Use descriptive test names following describe/it pattern.
// Good: Clear, descriptive names
describe('UrlNormalizer', () => {
describe('normalize', () => {
it('should remove trailing slashes from paths', () => {
expect(normalize('https://example.com/')).toBe('https://example.com')
})
it('should lowercase the hostname', () => {
expect(normalize('https://EXAMPLE.COM')).toBe('https://example.com')
})
it('should preserve query parameters in sorted order', () => {
expect(normalize('https://example.com?b=2&a=1')).toBe('https://example.com?a=1&b=2')
})
})
describe('isValid', () => {
it('should return false for non-HTTP protocols', () => {
expect(isValid('ftp://example.com')).toBe(false)
})
})
})
// Bad: Vague or implementation-focused names
describe('UrlNormalizer', () => {
it('works', () => { /* ... */ })
it('test case 1', () => { /* ... */ })
it('handles the edge case', () => { /* ... */ })
})
Naming pattern: it('should [expected behavior] when [condition]')
TST-003: Fixture Organization
Store test fixtures in __fixtures__/ directories.
packages/ai/src/__tests__/
summarize.test.ts
__fixtures__/
pricing-page.html
pricing-page-with-discount.html
blog-post.html
product-page.json
Fixture guidelines:
- Use descriptive file names:
pricing-page-with-discount.html - Keep fixtures minimal (only what's needed for the test)
- Prefer inline data for simple cases
- Use fixtures for complex HTML, JSON, or binary data
// Good: Load fixture for complex HTML
const html = await readFile(join(__dirname, '__fixtures__/pricing-page.html'), 'utf-8')
// Good: Inline for simple data
const simpleUrl = 'https://example.com/page'
// Bad: Large inline string
const html = `<!DOCTYPE html><html>...500 lines...</html>`
TST-004: Mocking Rules
Mock external services, not internal business logic.
// Good: Mock external service
vi.mock('@eko/db', () => ({
supabase: {
from: vi.fn().mockReturnValue({
select: vi.fn().mockResolvedValue({ data: mockData }),
}),
},
}))
// Good: Mock external API
vi.mock('openai', () => ({
OpenAI: vi.fn().mockImplementation(() => ({
chat: { completions: { create: vi.fn().mockResolvedValue(mockResponse) } },
})),
}))
// Bad: Mock internal business logic
vi.mock('./url-normalizer', () => ({
normalize: vi.fn().mockReturnValue('mocked'), // Test the real implementation!
}))
What to mock:
- External APIs (OpenAI, Anthropic, external webhooks)
- Database (Supabase client)
- Queue (Upstash)
- File system (for integration tests)
- Network (fetch calls)
What NOT to mock:
- Internal business logic
- Utility functions
- Type transformations
- Validation logic
TST-005: Coverage Expectations
Minimum coverage targets by package type:
| Package Type | Target | Rationale |
|---|---|---|
packages/shared | 80% | Core business logic |
packages/ai | 70% | Prompt logic testable, API calls mocked |
packages/db | 60% | Query logic, integration focus |
packages/queue | 60% | Dispatch logic |
apps/worker-* | 60% | Integration test focus |
apps/web | 40% | E2E and integration focus |
apps/admin | 40% | E2E focus |
Critical paths (require 90%+):
- URL normalization
- Fact validation pipeline
- Subscription plan logic
- Authentication flows
Test Types
Unit Tests
Test isolated functions and classes.
// packages/shared/src/url-normalization.test.ts
import { describe, expect, it } from 'vitest'
import { normalizeUrl } from './url-normalization'
describe('normalizeUrl', () => {
it('should handle basic URL normalization', () => {
expect(normalizeUrl('https://example.com/')).toBe('https://example.com')
})
})
Integration Tests
Test interactions between components.
// packages/db/src/__tests__/queries.integration.test.ts
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestClient, seedTestData, cleanupTestData } from './helpers'
describe('Query Integration', () => {
beforeAll(async () => {
await seedTestData()
})
afterAll(async () => {
await cleanupTestData()
})
it('should fetch user card interactions with related facts', async () => {
const result = await getUserCardInteractions(testUserId)
expect(result).toHaveLength(2)
expect(result[0].factRecord).toBeDefined()
})
})
E2E Smoke Tests
Test critical user flows (in apps/web/e2e/).
// Smoke test: Core user flow works
describe('Fact Card Flow', () => {
it('should allow user to view and interact with a fact card', async () => {
// Navigate to feed
// Open card detail
// Complete a challenge
// Verify interaction recorded
})
})
Async Testing
Handle async operations correctly.
// Good: Await async assertions
it('should fetch data', async () => {
const result = await fetchData()
expect(result).toBeDefined()
})
// Good: Test rejection
it('should throw on invalid input', async () => {
await expect(fetchData(null)).rejects.toThrow('Invalid input')
})
// Bad: Missing await (test passes incorrectly)
it('should fetch data', () => {
expect(fetchData()).resolves.toBeDefined() // Missing await!
})
Test Data Management
// Good: Factory functions for test data
function createTestFact(overrides?: Partial<FactRecord>): FactRecord {
return {
id: crypto.randomUUID(),
title: 'Test Fact',
topicCategoryId: 'science',
status: 'validated',
createdAt: new Date(),
...overrides,
}
}
// Usage
const fact = createTestFact({ status: 'pending_validation' })
// Bad: Copy-paste test data
const fact1 = { id: '1', title: 'Fact A', topicCategoryId: 'science', status: 'validated', createdAt: new Date() }
const fact2 = { id: '2', title: 'Fact B', topicCategoryId: 'science', status: 'validated', createdAt: new Date() }
Running Tests
# Run all tests
bun run test
# Run specific package tests
bun run test --filter=@eko/shared
# Run with coverage
bun run test --coverage
# Run single file
bun test packages/shared/src/utils.test.ts
# Watch mode
bun run test --watch
Anti-Patterns
1. Testing Implementation Details
// Bad: Testing internal state
it('should set internal flag', () => {
const normalizer = new UrlNormalizer()
normalizer.normalize('https://example.com')
expect(normalizer._hasProcessed).toBe(true) // Implementation detail!
})
// Good: Testing behavior
it('should return normalized URL', () => {
expect(normalize('https://example.com/')).toBe('https://example.com')
})
2. Flaky Time-Based Tests
// Bad: Depends on timing
it('should timeout', async () => {
const start = Date.now()
await operation()
expect(Date.now() - start).toBeGreaterThan(1000)
})
// Good: Mock timers
it('should timeout after 1 second', async () => {
vi.useFakeTimers()
const promise = operation()
vi.advanceTimersByTime(1000)
await expect(promise).rejects.toThrow('Timeout')
vi.useRealTimers()
})
3. Shared Mutable State
// Bad: Tests affect each other
let counter = 0
it('test 1', () => {
counter++
expect(counter).toBe(1)
})
it('test 2', () => {
counter++
expect(counter).toBe(1) // Fails! counter is 2
})
// Good: Reset in beforeEach
let counter: number
beforeEach(() => {
counter = 0
})
Related
- E2E Testing Guide — E2E verification procedures
- Rules Index — All rules