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.ts beside foo.ts
  • Complex suites: __tests__/ directory with multiple files
  • Integration tests: *.integration.test.ts suffix

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 TypeTargetRationale
packages/shared80%Core business logic
packages/ai70%Prompt logic testable, API calls mocked
packages/db60%Query logic, integration focus
packages/queue60%Dispatch logic
apps/worker-*60%Integration test focus
apps/web40%E2E and integration focus
apps/admin40%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
})