Test Contexts Guide
Essential Guide: Understanding and using test contexts effectively
Overview
Savvi Studio uses a hierarchical test context system built on Vitest's test.extend(). This guide helps you:
- Understand available test contexts
- Choose the right context for your test
- Create custom test contexts
- Understand the context hierarchy
Quick Reference
| Context | Import Path | Use When | Provides |
|---|---|---|---|
| Base | @/tests/config/base.context |
Graph ops, DB ops, general testing | asUser, asAdmin, asSystem, graph, studio |
| Auth | @/tests/fixtures/auth.context |
Auth, secrets, key operations | admin, helpers + all base fixtures |
| Graph | @/tests/fixtures/graph.context |
Graph-specific with clean state | cleanGraph + all base fixtures |
Context Hierarchy
tests/config/database.context.ts # Database lifecycle (worker-scoped)
↓
tests/config/base.context.ts # Core fixtures (asUser, asAdmin, etc.)
↓
tests/fixtures/auth.context.ts # Auth-specific (admin, helpers)
tests/fixtures/graph.context.ts # Graph-specific (cleanGraph)
tests/fixtures/[custom].context.ts # Your custom contexts
Inheritance: Each level builds on the previous, adding new fixtures while keeping all parent fixtures available.
Base Context
Location: tests/config/base.context.ts
Extends: DatabaseContext (from database.context.ts)
Use For: Most integration tests
Available Fixtures
asUser(userId, orgId, callback)
Execute database operations with user role and RLS policies applied.
import { test } from '@/tests/config/base.context';
test('user can read own data', async ({ asUser }) => {
await asUser('user-123', 'org-456', async (client) => {
const result = await client.query(
'SELECT * FROM graph.nodes WHERE owner_id = current_setting(\'jwt.claims.sub\')::text'
);
expect(result.rows.length).toBeGreaterThan(0);
});
});
Key Points:
- ✅ RLS policies enforced
- ✅ Session context set automatically
- ✅ Uses
studio_userrole - ✅ Transaction automatic
- ✅ Cleanup automatic
asAdmin(userId, orgId, callback)
Execute database operations with admin role and RLS policies applied.
test('admin can access all org data', async ({ asAdmin }) => {
await asAdmin('admin-123', 'org-456', async (client) => {
const result = await client.query(
'SELECT * FROM graph.nodes WHERE org_id = current_setting(\'jwt.claims.org\')::text'
);
expect(result.rows.length).toBeGreaterThan(0);
});
});
Key Points:
- ✅ Admin privileges within org
- ✅ RLS policies enforced
- ✅ Uses
studio_adminrole - ✅ Still limited to org scope
asSystem(callback)
Execute database operations with system privileges, bypassing RLS.
test('system can modify infrastructure', async ({ asSystem }) => {
await asSystem(async (client) => {
// Direct table access, no RLS
await client.query('TRUNCATE TABLE graph.nodes CASCADE');
expect(true).toBe(true); // Setup successful
});
});
Key Points:
- ⚠️ Bypasses all RLS
- ⚠️ Use for infrastructure testing only
- ✅ Full database access
- ✅ Uses
studio_test_role
systemClient
Direct access to database client from worker-scoped pool.
test('direct query access', async ({ systemClient }) => {
const result = await systemClient.query('SELECT version()');
expect(result.rows[0].version).toContain('PostgreSQL');
});
Key Points:
- ⚠️ No RLS enforcement
- ⚠️ No transaction wrapping
- ✅ Direct pool access
- ✅ Good for queries, not mutations
graph(userId, orgId, callback)
Execute graph operations with user context using GraphOperations.
test('create graph node', async ({ graph }) => {
await graph('user-123', 'org-456', async (ops) => {
const node = await ops.nodes.create({
nodeType: 'custom.document',
externalId: 'doc-123',
data: { title: 'Test Doc' }
});
expect(node.owner_id).toBe('user-123');
});
});
Key Points:
- ✅ High-level GraphOperations API
- ✅ User session context
- ✅ RLS enforced
- ✅ Type-safe operations
graphAsRoot(callback)
Execute graph operations with root privileges.
test('root can create system nodes', async ({ graphAsRoot }) => {
await graphAsRoot(async (ops) => {
const systemNode = await ops.nodes.create({
nodeType: 'system.config',
externalId: 'config-123',
data: { setting: 'value' }
});
expect(systemNode).toBeDefined();
});
});
Key Points:
- ⚠️ Root privileges
- ⚠️ Use for system setup only
- ✅ No RLS restrictions
- ✅ Full graph access
studio(userId, orgId, callback) and studioAsRoot(callback)
Similar to graph operations but for StudioOperations API.
test('studio operations', async ({ studio }) => {
await studio('user-123', 'org-456', async (ops) => {
// Studio-specific operations
});
});
assertions
Helper assertions for checking session context.
test('session context', async ({ assertions, asUser }) => {
await asUser('user-123', 'org-456', async (client) => {
await assertions.userIdToBe('user-123');
// Or use promise-based assertion
await expect(assertions.userId()).resolves.toBe('user-123');
});
});
When to Use Base Context
✅ Use base context when:
- Testing graph operations
- Testing database queries with RLS
- Testing user/admin access patterns
- General integration testing
❌ Don't use base context when:
- Testing auth/secrets operations (use auth context)
- Need pre-configured AdminOperations (use auth context)
Auth Context
Location: tests/fixtures/auth.context.ts
Extends: BaseContext
Use For: Auth, secrets, JWT, key management tests
Additional Fixtures
admin
Pre-configured AdminOperations instance with MEK initialized.
import { test } from '@/tests/fixtures/auth.context';
test('create secret', async ({ admin }) => {
const keyId = await admin.secrets.upsertSecret({
keyName: 'test-key',
secret: Buffer.from('secret').toString('base64'),
keyType: 'hmac',
algorithm: 'aes'
});
expect(keyId).toBeDefined();
});
Key Points:
- ✅ AdminOperations ready to use
- ✅ MEK automatically initialized
- ✅ No manual setup needed
- ✅ Cleanup automatic
helpers
Auth-specific test helpers.
test('create test key', async ({ helpers }) => {
const key = await helpers.createTestKey({
keyPrefix: 'test',
keyType: 'hmac',
markAsSigning: true
});
expect(key.is_signing_key).toBe(true);
});
test('create key hierarchy', async ({ helpers }) => {
const { parent, child } = await helpers.createKeyHierarchy();
expect(child.parent_key_name).toBe(parent.key_name);
});
Available Helpers:
createTestKey(options)- Create test key with common configcreateKeyHierarchy(options)- Create parent/child keysverifySecretHash(decryptedKey, dbSecret)- Verify key hashgetCurrentVersion(keyName)- Get current key version
When to Use Auth Context
✅ Use auth context when:
- Testing secret management
- Testing key rotation
- Testing JWT operations
- Testing AdminOperations
❌ Don't use auth context when:
- Only need database operations (use base context)
- Don't need AdminOperations
Graph Context
Location: tests/fixtures/graph.context.ts
Extends: DatabaseContext
Use For: Graph tests that need clean state
Additional Fixtures
cleanGraph
Database client with graph tables truncated.
import { test } from '@/tests/fixtures/graph.context';
test('create node in clean graph', async ({ cleanGraph }) => {
// Tables already truncated
const result = await cleanGraph.query(
'SELECT COUNT(*) FROM graph.nodes'
);
expect(parseInt(result.rows[0].count)).toBe(0);
// Now create nodes...
});
Key Points:
- ✅ Graph tables truncated before each test
- ✅ Clean slate for graph operations
- ✅ Prevents test interference
- ⚠️ Use for tests that need isolation
When to Use Graph Context
✅ Use graph context when:
- Need clean graph state
- Testing graph-specific functionality
- Want to avoid interference from other tests
❌ Don't use graph context when:
- Graph state doesn't matter
- Want to test with existing data
Creating Custom Contexts
Pattern: Extend Existing Context
// tests/fixtures/custom.context.ts
import { test as baseTest } from '../config/base.context';
export interface CustomContext {
customSetup: { /* your type */ };
}
export const test = baseTest.extend<CustomContext>({
customSetup: async ({ asSystem }, use) => {
// Setup
const setup = await asSystem(async (client) => {
// Your setup logic
return { /* your data */ };
});
await use(setup);
// Cleanup (if needed)
}
});
Pattern: Compose Multiple Contexts
// tests/fixtures/full-stack.context.ts
import { test as authTest } from './auth.context';
import { test as graphTest } from './graph.context';
// Combine auth and graph contexts
export const test = authTest.extend({
// Add graph's cleanGraph to auth context
cleanGraph: graphTest._extendTest.cleanGraph
});
// Now has: admin, helpers, cleanGraph, and all base fixtures
Pattern: Parameterized Context Factory
// tests/fixtures/permission-context.ts
export function createPermissionTest(level: 'read' | 'write' | 'admin') {
return baseTest.extend({
userWithPermission: async ({ asSystem }, use) => {
const user = await asSystem(async (client) => {
const { user } = await createUserWithOrgSetup(client);
await grantPermission(client, user.nodeId, level);
return user;
});
await use(user);
}
});
}
// Usage
const readTest = createPermissionTest('read');
const adminTest = createPermissionTest('admin');
readTest('read-only access', async ({ userWithPermission }) => { /* test */ });
adminTest('admin access', async ({ userWithPermission }) => { /* test */ });
Decision Tree: Which Context?
Need to test...
├─ Auth operations (secrets, keys, JWT)?
│ └─ Use: Auth Context (admin, helpers)
│
├─ Graph operations with clean state?
│ └─ Use: Graph Context (cleanGraph)
│
├─ Graph operations (state doesn't matter)?
│ └─ Use: Base Context (graph, graphAsRoot)
│
├─ Database operations with RLS?
│ └─ Use: Base Context (asUser, asAdmin)
│
├─ Database operations without RLS?
│ └─ Use: Base Context (asSystem)
│
├─ Pure function (no I/O)?
│ └─ Use: No context (unit test)
│
└─ Custom scenario (3+ tests)?
└─ Create: Custom Context
Common Patterns
Pattern 1: Using Multiple Contexts
// Can use fixtures from base + auth
import { test } from '@/tests/fixtures/auth.context';
test('full auth flow', async ({ admin, graph, asUser }) => {
// admin from auth context
// graph from base context
// asUser from base context
const key = await admin.secrets.upsertSecret({ /* ... */ });
await graph('user-123', 'org-456', async (ops) => {
// Use graph operations
});
await asUser('user-123', 'org-456', async (client) => {
// Use database client
});
});
Pattern 2: Conditional Setup
const test = baseTest.extend({
setupData: async ({ asSystem }, use, testInfo) => {
// Can access test metadata
const needsPermissions = testInfo.title.includes('permission');
const data = await asSystem(async (client) => {
const user = await createUser(client);
if (needsPermissions) {
await grantPermissions(client, user);
}
return user;
});
await use(data);
}
});
Pattern 3: Fixture Dependencies
const test = baseTest.extend({
// First level
user: async ({ asSystem }, use) => {
const user = await createUser();
await use(user);
},
// Second level depends on user
workspace: async ({ user, asSystem }, use) => {
const workspace = await createWorkspace(user.id);
await use(workspace);
},
// Third level depends on workspace
document: async ({ workspace, asSystem }, use) => {
const doc = await createDocument(workspace.id);
await use(doc);
}
});
// Request only what you need - dependencies auto-resolved
test('test 1', async ({ user }) => { /* just user */ });
test('test 2', async ({ workspace }) => { /* user + workspace */ });
test('test 3', async ({ document }) => { /* user + workspace + document */ });
Best Practices
✅ DO
-
Use the most specific context
// Good - specific context import { test } from '@/tests/fixtures/auth.context'; test('secret ops', async ({ admin }) => { /* ... */ }); -
Extend contexts for shared setup
const test = authTest.extend({ testKey: async ({ admin }, use) => { /* shared key setup */ } }); -
Request only needed fixtures
// Good - only what's needed test('simple test', async ({ user }) => { /* ... */ }); // Not: async ({ user, workspace, document, admin }) => { /* only need user */ } -
Use worker scope for expensive setup
const test = baseTest.extend({ expensiveSetup: [async ({}, use) => { const resource = await expensiveOperation(); await use(resource); await cleanup(resource); }, { scope: 'worker' }] // Shared across file });
❌ DON'T
-
Don't create AdminOperations manually
// Bad test('secret ops', async ({ asSystem }) => { await asSystem(async (client) => { const admin = new AdminOperations(client); // ... }); }); // Good import { test } from '@/tests/fixtures/auth.context'; test('secret ops', async ({ admin }) => { // admin already configured }); -
Don't use asSystem for user operations
// Bad - bypasses RLS test('user access', async ({ asSystem }) => { await asSystem(async (client) => { // RLS not enforced! }); }); // Good - enforces RLS test('user access', async ({ asUser }) => { await asUser('user-123', 'org-456', async (client) => { // RLS enforced }); }); -
Don't duplicate fixture logic
// Bad - duplicated across tests test('test 1', async ({ admin }) => { const key = await admin.secrets.upsertSecret({ /* ... */ }); // ... }); test('test 2', async ({ admin }) => { const key = await admin.secrets.upsertSecret({ /* ... */ }); // Duplicate! // ... }); // Good - extract to fixture const test = authTest.extend({ testKey: async ({ admin }, use) => { const key = await admin.secrets.upsertSecret({ /* ... */ }); await use(key); } });
Summary
- Base Context: Use for most tests (graph, DB, RLS testing)
- Auth Context: Use for auth, secrets, keys, AdminOperations
- Graph Context: Use when you need clean graph state
- Custom Context: Create when you have 3+ tests with shared setup
Key Principle: Choose the most specific context that provides what you need, and extend it when you have shared setup across multiple tests.
Related Documentation
- Vitest Patterns - How to use test.extend() effectively
- Writing Tests - General test writing guide
- Fixtures Reference - Complete API reference
- Common Scenarios - Practical examples
Questions? See real examples in tests/integration/auth/ and tests/integration/graph/.