Savvi Studio

Writing Tests Guide

Essential Guide: How to write effective tests in Savvi Studio

Overview

This guide covers:

  1. When to write unit vs integration tests
  2. Choosing the right test context
  3. Test structure and organization
  4. Naming conventions
  5. Common patterns and anti-patterns

Unit vs Integration Tests

Unit Tests

Definition: Test a single unit of code in isolation with mocked dependencies.

When to Use:

  • ✅ Pure functions with no I/O
  • ✅ Complex business logic
  • ✅ Data transformations
  • ✅ Validation logic
  • ✅ Utility functions

Location: src/**/__tests__/ (co-located with code)

Example:

// src/lib/utils/__tests__/sanitize.test.ts
import { describe, test, expect } from 'vitest';
import { sanitizeInput } from '../sanitize';

describe('sanitizeInput', () => {
  test('should remove whitespace', () => {
    expect(sanitizeInput('  hello  ')).toBe('hello');
  });
  
  test('should handle empty string', () => {
    expect(sanitizeInput('')).toBe('');
  });
  
  test('should handle null', () => {
    expect(sanitizeInput(null)).toBe(null);
  });
});

Integration Tests

Definition: Test multiple components working together with real dependencies (database, external services).

When to Use:

  • ✅ Database operations
  • ✅ API endpoints
  • ✅ Auth flows
  • ✅ Graph operations
  • ✅ End-to-end workflows
  • ✅ RLS policy testing

Location: tests/integration/ (organized by domain)

Example:

// tests/integration/auth/secret-management.test.ts
import { test } from '@/tests/fixtures/auth.context';

describe('Secret Management', () => {
  test('should create and decrypt secret', async ({ admin }) => {
    // Real database, real AdminOperations
    const keyId = await admin.secrets.upsertSecret({
      keyName: 'test-key',
      secret: Buffer.from('secret').toString('base64'),
      keyType: 'hmac',
      algorithm: 'aes'
    });
    
    const decrypted = await admin.secrets.decrypt(keyId);
    expect(decrypted.error_message).toBeNull();
  });
});

Decision Matrix

Aspect Unit Test Integration Test
Speed Very fast (<1ms) Fast (10-100ms)
Isolation Complete Partial
Dependencies All mocked Real DB/services
Coverage Logic paths Workflows
Debugging Easy Moderate
Confidence Logic correct System works

Rule of Thumb: If it touches the database or external services, it's an integration test.

Choosing the Right Test Context

Quick Decision Tree

What are you testing?

├─ Pure function (no I/O)?
│  └─ Unit test (no context)
│
├─ Auth operations (secrets, keys)?
│  └─ Integration test with Auth context
│
├─ Graph operations?
│  └─ Integration test with Base or Graph context
│
├─ Database with RLS?
│  └─ Integration test with Base context (asUser, asAdmin)
│
└─ Database without RLS?
   └─ Integration test with Base context (asSystem)

Context Reference

Test Type Context Import
Auth/secrets Auth @/tests/fixtures/auth.context
Graph (clean) Graph @/tests/fixtures/graph.context
Graph (any state) Base @/tests/config/base.context
Database with RLS Base @/tests/config/base.context
Database without RLS Base @/tests/config/base.context
Pure logic None vitest

See Test Contexts Guide for detailed context documentation.

Test Structure

File Organization

tests/
├── integration/
│   ├── auth/                   # Auth domain
│   │   ├── secret-management.test.ts
│   │   ├── key-rotation.test.ts
│   │   └── jwt-validation.test.ts
│   ├── graph/                  # Graph domain
│   │   ├── node-operations.test.ts
│   │   ├── edge-operations.test.ts
│   │   └── traversal.test.ts
│   └── webhooks/               # Webhooks domain
│       ├── auth-handlers.test.ts
│       └── workos-handlers.test.ts
└── unit/
    └── lib/
        └── utils/
            └── sanitize.test.ts

Organization Rules:

  1. Group by domain first (auth, graph, webhooks)
  2. One domain = one directory
  3. Related tests stay together
  4. Max 2 levels of nesting
  5. Descriptive file names

Test File Structure

// 1. Imports
import { test } from '@/tests/fixtures/auth.context';
import { describe, expect } from 'vitest';

// 2. Test suite
describe('Domain - Feature', () => {
  
  // 3. Grouped related tests
  describe('Specific Behavior', () => {
    
    test('should do X when Y', async ({ fixture }) => {
      // 4. Arrange
      const input = setupTestData();
      
      // 5. Act
      const result = await performAction(input);
      
      // 6. Assert
      expect(result).toBe(expected);
    });
    
    test('should throw error when invalid', async ({ fixture }) => {
      // Test error cases
    });
  });
});

Naming Conventions

Test Files

Pattern: {domain}-{feature}.test.ts

Good:

  • auth-secret-management.test.ts
  • graph-node-operations.test.ts
  • webhooks-auth-handlers.test.ts

Bad:

  • test-auth.ts (not descriptive)
  • AuthTest.ts (wrong case)
  • auth.test.ts (too generic)

Test Names

Pattern: should {behavior} when {condition}

Good:

test('should create node when valid input provided', async ({ graph }) => {
  // Clear what's being tested
});

test('should throw error when key not found', async ({ admin }) => {
  // Clear expected behavior
});

test('should enforce RLS when user context set', async ({ asUser }) => {
  // Clear security expectation
});

Bad:

test('create node', async ({ graph }) => {
  // What scenario? What's expected?
});

test('it works', async () => {
  // What works? When?
});

test('test1', async () => {
  // Meaningless name
});

Test Suites

Pattern: {Domain} - {Feature}

describe('Auth - Secret Management', () => {
  describe('Encryption', () => {
    test('should encrypt with parent key', async () => {});
    test('should decrypt with correct key', async () => {});
  });
  
  describe('Key Rotation', () => {
    test('should create new version', async () => {});
    test('should maintain grace period', async () => {});
  });
});

Common Patterns

Pattern 1: Arrange-Act-Assert

test('should calculate total correctly', async ({ asSystem }) => {
  // Arrange: Set up test data
  const items = [
    { price: 10, quantity: 2 },
    { price: 5, quantity: 3 }
  ];
  
  // Act: Perform the operation
  const total = calculateTotal(items);
  
  // Assert: Verify the result
  expect(total).toBe(35); // (10*2) + (5*3)
});

Pattern 2: Test Error Cases

describe('Error Handling', () => {
  test('should throw when key not found', async ({ admin }) => {
    await expect(
      admin.secrets.decrypt('nonexistent-key')
    ).rejects.toThrow('Key not found');
  });
  
  test('should return null when invalid input', async () => {
    const result = sanitizeInput(undefined);
    expect(result).toBeNull();
  });
});

Pattern 3: Parameterized Testing

test.each([
  { input: 'hello', expected: 'hello' },
  { input: '  hello  ', expected: 'hello' },
  { input: '', expected: '' },
  { input: null, expected: null },
])('should sanitize $input', async ({ input, expected }) => {
  const result = sanitizeInput(input);
  expect(result).toBe(expected);
});

See Vitest Patterns for advanced patterns.

Pattern 4: Setup Once, Test Many

// Bad: Duplicate setup
test('test 1', async ({ admin }) => {
  const key = await setupKey(admin);
  // test
});

test('test 2', async ({ admin }) => {
  const key = await setupKey(admin); // Duplicated!
  // test
});

// Good: Use fixture
const test = authTest.extend({
  testKey: async ({ admin }, use) => {
    const key = await setupKey(admin);
    await use(key);
  }
});

test('test 1', async ({ testKey }) => { /* use it */ });
test('test 2', async ({ testKey }) => { /* use it */ });

Pattern 5: Test RLS Policies

test('should enforce RLS for user access', async ({ asUser, asSystem }) => {
  // Setup: Create data as system
  await asSystem(async (client) => {
    await createTestData(client);
  });
  
  // Test: Verify user can only see their data
  await asUser('user-123', 'org-456', async (client) => {
    const result = await client.query(
      'SELECT * FROM graph.nodes WHERE owner_id = $1',
      ['user-456'] // Different user
    );
    
    expect(result.rows).toHaveLength(0); // No access!
  });
});

Anti-Patterns

❌ DON'T: Test Implementation Details

// Bad: Tests internal implementation
test('should call helper function', () => {
  const spy = vi.spyOn(internalHelper, 'process');
  myFunction();
  expect(spy).toHaveBeenCalled();
});

// Good: Test behavior
test('should return correct result', () => {
  const result = myFunction();
  expect(result).toBe(expected);
});

❌ DON'T: Make Tests Dependent

// Bad: Tests depend on order
test('create user', async () => {
  // Creates user-123
});

test('delete user', async () => {
  // Expects user-123 to exist from previous test!
});

// Good: Independent tests
test('create user', async ({ asSystem }) => {
  const user = await createUser();
  // ...
});

test('delete user', async ({ asSystem }) => {
  const user = await createUser(); // Create own data
  await deleteUser(user.id);
  // ...
});

❌ DON'T: Test Multiple Things

// Bad: Tests too much
test('user lifecycle', async () => {
  const user = await createUser();
  expect(user).toBeDefined();
  
  await updateUser(user.id, { name: 'New' });
  expect(user.name).toBe('New');
  
  await deleteUser(user.id);
  expect(await getUser(user.id)).toBeNull();
});

// Good: Separate tests
test('should create user', async () => {
  const user = await createUser();
  expect(user).toBeDefined();
});

test('should update user', async () => {
  const user = await createUser();
  await updateUser(user.id, { name: 'New' });
  expect(user.name).toBe('New');
});

test('should delete user', async () => {
  const user = await createUser();
  await deleteUser(user.id);
  expect(await getUser(user.id)).toBeNull();
});

❌ DON'T: Use Magic Values

// Bad: Unclear what values mean
test('should calculate tax', () => {
  expect(calculateTax(100)).toBe(8); // Where does 8 come from?
});

// Good: Clear, named values
test('should calculate 8% tax', () => {
  const amount = 100;
  const taxRate = 0.08;
  const expectedTax = 8;
  
  expect(calculateTax(amount, taxRate)).toBe(expectedTax);
});

❌ DON'T: Skip Cleanup

// Bad: Manual cleanup that might fail
test('my test', async ({ asSystem }) => {
  const resource = await createResource();
  try {
    // test logic
  } finally {
    await cleanupResource(resource); // Might not run
  }
});

// Good: Use fixture for automatic cleanup
const test = baseTest.extend({
  resource: async ({ asSystem }, use) => {
    const resource = await createResource();
    await use(resource);
    await cleanupResource(resource); // Always runs
  }
});

test('my test', async ({ resource }) => {
  // Use resource, cleanup automatic
});

Best Practices

✅ DO

  1. Write descriptive test names

    test('should grant read permission when user is member', async () => {});
    
  2. Test one thing per test

    test('should create node', async () => { /* just creation */ });
    test('should update node', async () => { /* just updates */ });
    
  3. Use appropriate context

    // Auth operations → Auth context
    import { test } from '@/tests/fixtures/auth.context';
    
  4. Follow AAA pattern

    // Arrange
    const input = setupData();
    // Act
    const result = performAction(input);
    // Assert
    expect(result).toBe(expected);
    
  5. Test error cases

    test('should throw on invalid input', async () => {
      await expect(fn(invalid)).rejects.toThrow();
    });
    
  6. Use factories for test data

    import { createUserWithOrgSetup } from '@/tests/utils/factories';
    const { user, org } = await createUserWithOrgSetup(client);
    
  7. Keep tests independent

    // Each test creates its own data
    test('test 1', async () => {
      const data = createTestData();
      // ...
    });
    
  8. Use parameterized tests for variations

    test.each([
      { input: 'a', expected: 'A' },
      { input: 'b', expected: 'B' },
    ])('should transform $input', async ({ input, expected }) => {});
    

✅ DON'T

  1. Don't test external libraries

    // Don't test that express works
    // Trust the library, test your code
    
  2. Don't make tests depend on execution order

    // Each test should work independently
    
  3. Don't test private functions

    // Test public API, not implementation
    
  4. Don't use real external services in tests

    // Use mocks or test servers
    
  5. Don't skip flaky tests

    // Fix the flakiness, don't skip
    

Test Coverage

What to Cover

High Priority:

  • Critical business logic
  • Auth and permissions
  • Data validation
  • Error handling
  • Security-critical paths
  • RLS policies

Medium Priority:

  • API endpoints
  • Database operations
  • Common workflows
  • Edge cases

Low Priority:

  • Simple getters/setters
  • Configuration code
  • Generated code
  • Trivial functions

Coverage Goals

  • Critical paths: 100%
  • Business logic: >90%
  • Overall: >80%
  • New code: >80%

Don't chase 100% coverage - focus on meaningful tests that provide value.

Running Tests

# All tests
pnpm test

# Integration tests only
pnpm test tests/integration

# Specific domain
pnpm test tests/integration/auth

# Specific file
pnpm test tests/integration/auth/secret-management.test.ts

# Watch mode
pnpm test --watch

# Coverage
pnpm test --coverage

# UI mode (visual debugging)
pnpm test --ui

Debugging Tests

Using console.log

test('debug test', async ({ asSystem }) => {
  await asSystem(async (client) => {
    const result = await client.query('SELECT * FROM users');
    console.log('Result:', result.rows); // Visible in output
  });
});

Using test.only

test.only('focus on this test', async () => {
  // Only this test runs
});

Using debugger

test('debug with breakpoint', async () => {
  const data = setupData();
  debugger; // Breakpoint here
  const result = processData(data);
  expect(result).toBe(expected);
});

Run with: node --inspect-brk node_modules/.bin/vitest

Summary

Quick Checklist

Before writing a test, check:

  • Is this a unit or integration test?
  • Which context do I need?
  • Is my test name descriptive?
  • Am I testing one thing?
  • Am I testing behavior, not implementation?
  • Is my test independent?
  • Have I tested error cases?
  • Am I using appropriate fixtures?

Key Principles

  1. Test behavior, not implementation
  2. Keep tests independent
  3. Use appropriate test type (unit vs integration)
  4. Choose the right context
  5. Write descriptive names
  6. Test error cases
  7. Use fixtures to avoid duplication
  8. Follow AAA pattern

Ready to write tests? See Common Scenarios for practical examples!