Writing Tests Guide
Essential Guide: How to write effective tests in Savvi Studio
Overview
This guide covers:
- When to write unit vs integration tests
- Choosing the right test context
- Test structure and organization
- Naming conventions
- 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:
- Group by domain first (auth, graph, webhooks)
- One domain = one directory
- Related tests stay together
- Max 2 levels of nesting
- 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.tsgraph-node-operations.test.tswebhooks-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
-
Write descriptive test names
test('should grant read permission when user is member', async () => {}); -
Test one thing per test
test('should create node', async () => { /* just creation */ }); test('should update node', async () => { /* just updates */ }); -
Use appropriate context
// Auth operations → Auth context import { test } from '@/tests/fixtures/auth.context'; -
Follow AAA pattern
// Arrange const input = setupData(); // Act const result = performAction(input); // Assert expect(result).toBe(expected); -
Test error cases
test('should throw on invalid input', async () => { await expect(fn(invalid)).rejects.toThrow(); }); -
Use factories for test data
import { createUserWithOrgSetup } from '@/tests/utils/factories'; const { user, org } = await createUserWithOrgSetup(client); -
Keep tests independent
// Each test creates its own data test('test 1', async () => { const data = createTestData(); // ... }); -
Use parameterized tests for variations
test.each([ { input: 'a', expected: 'A' }, { input: 'b', expected: 'B' }, ])('should transform $input', async ({ input, expected }) => {});
✅ DON'T
-
Don't test external libraries
// Don't test that express works // Trust the library, test your code -
Don't make tests depend on execution order
// Each test should work independently -
Don't test private functions
// Test public API, not implementation -
Don't use real external services in tests
// Use mocks or test servers -
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
- Test behavior, not implementation
- Keep tests independent
- Use appropriate test type (unit vs integration)
- Choose the right context
- Write descriptive names
- Test error cases
- Use fixtures to avoid duplication
- Follow AAA pattern
Related Documentation
- Vitest Patterns - Advanced Vitest features
- Test Contexts - Available contexts
- Common Scenarios - Practical examples
- Troubleshooting - Debug test failures
Ready to write tests? See Common Scenarios for practical examples!