Vitest Testing Patterns
Essential Guide: Best practices for using Vitest's powerful features to write maintainable tests
Overview
This guide focuses on Vitest-specific patterns that make tests more maintainable, reduce duplication, and improve readability. We emphasize:
test.extend()- Creating custom test contexts with shared setup- Parameterized tests - Testing multiple scenarios efficiently
- Fixture composition - Building complex test contexts from simpler ones
- Lifecycle management - Automatic setup and cleanup
Table of Contents
- Custom Test Contexts with test.extend()
- Parameterized Tests with test.each()
- Fixture Composition
- Lifecycle and Cleanup
- Avoiding Duplication
- Real-World Examples
Custom Test Contexts with test.extend()
The Problem: Duplication
Anti-Pattern - Duplicated setup in every test:
describe('Key Operations', () => {
test('should decrypt key', async ({ admin }) => {
// Same setup repeated
const key = await admin.secrets.upsertSecret({
keyName: 'test-key',
secret: Buffer.from('secret').toString('base64'),
keyType: 'hmac',
algorithm: 'aes'
});
// Test logic
const result = await admin.secrets.decrypt(key.id);
expect(result.error_message).toBeNull();
});
test('should rotate key', async ({ admin }) => {
// Same setup duplicated!
const key = await admin.secrets.upsertSecret({
keyName: 'test-key',
secret: Buffer.from('secret').toString('base64'),
keyType: 'hmac',
algorithm: 'aes'
});
// Test logic
const result = await admin.keyRotation.rotateKey({
keyName: key.key_name,
gracePeriodHours: 24
});
expect(result.newVersion).toBe(2);
});
});
The Solution: test.extend()
Best Practice - Create a fixture once, use everywhere:
import { test as baseTest } from '@/tests/fixtures/auth.context';
// Extend context with custom fixture
const test = baseTest.extend({
testKey: async ({ admin }, use) => {
// Setup runs once per test
const key = await admin.secrets.upsertSecret({
keyName: `test-key-${Date.now()}`,
secret: Buffer.from('secret').toString('base64'),
keyType: 'hmac',
algorithm: 'aes'
});
// Provide to test
await use(key);
// Cleanup happens automatically after test
// (No explicit cleanup needed in this case)
}
});
describe('Key Operations', () => {
test('should decrypt key', async ({ testKey, admin }) => {
// testKey already set up - no duplication!
const result = await admin.secrets.decrypt(testKey.id);
expect(result.error_message).toBeNull();
});
test('should rotate key', async ({ testKey, admin }) => {
// testKey ready to use - no duplication!
const result = await admin.keyRotation.rotateKey({
keyName: testKey.key_name,
gracePeriodHours: 24
});
expect(result.newVersion).toBe(2);
});
});
Benefits:
- ✅ Setup logic written once
- ✅ Consistent across all tests
- ✅ Automatic cleanup
- ✅ Type-safe fixtures
- ✅ Easy to modify setup for all tests
Creating Reusable Fixtures
// tests/fixtures/shared-fixtures.ts
import { test as baseTest } from '@/tests/config/base.context';
export const test = baseTest.extend({
// User with organization setup
testUser: async ({ asSystem }, use) => {
const user = await asSystem(async (client) => {
return await createUserWithOrgSetup(client, {
userId: `test-user-${Date.now()}`,
userData: { email: 'test@example.com' }
});
});
await use(user);
// Cleanup handled by transaction rollback
},
// Resource with permissions
testResource: async ({ testUser, asSystem }, use) => {
// Note: Can depend on other fixtures!
const resource = await asSystem(async (client) => {
return await createResourceWithPermission(client, {
userNodeId: testUser.user.nodeId,
permissionLevel: 'write'
});
});
await use(resource);
}
});
Parameterized Tests with test.each()
The Problem: Repetitive Tests
Anti-Pattern - Copy-paste tests for different inputs:
test('should handle HMAC key type', async ({ admin }) => {
const key = await admin.secrets.upsertSecret({
keyName: 'test-hmac',
keyType: 'hmac',
algorithm: 'aes',
secret: generateSecret()
});
expect(key).toBeDefined();
});
test('should handle symmetric key type', async ({ admin }) => {
const key = await admin.secrets.upsertSecret({
keyName: 'test-symmetric',
keyType: 'symmetric',
algorithm: 'aes',
secret: generateSecret()
});
expect(key).toBeDefined();
});
test('should handle asymmetric key type', async ({ admin }) => {
const key = await admin.secrets.upsertSecret({
keyName: 'test-asymmetric',
keyType: 'asymmetric',
algorithm: 'rsa',
secret: generateSecret()
});
expect(key).toBeDefined();
});
The Solution: test.each()
Best Practice - One test, multiple scenarios:
test.each([
{ keyType: 'hmac', algorithm: 'aes', description: 'HMAC keys' },
{ keyType: 'symmetric', algorithm: 'aes', description: 'symmetric keys' },
{ keyType: 'asymmetric', algorithm: 'rsa', description: 'asymmetric keys' },
])('should handle $description', async ({ keyType, algorithm }, { admin }) => {
const key = await admin.secrets.upsertSecret({
keyName: `test-${keyType}`,
keyType,
algorithm,
secret: generateSecret()
});
expect(key).toBeDefined();
expect(key.key_type).toBe(keyType);
});
Benefits:
- ✅ Less code duplication
- ✅ Easy to add new cases
- ✅ Clear test names with
$variableinterpolation - ✅ Shared test logic
- ✅ Better coverage with less code
Advanced Parameterization
Testing combinations:
const permissions = ['read', 'write', 'admin'];
const resourceTypes = ['document', 'folder', 'workspace'];
test.each(
permissions.flatMap(perm =>
resourceTypes.map(type => ({ permission: perm, resourceType: type }))
)
)('should grant $permission on $resourceType', async ({ permission, resourceType }, { asSystem }) => {
await asSystem(async (client) => {
const result = await grantPermission(client, {
permission,
resourceType,
userId: 'user-123'
});
expect(result.granted).toBe(true);
expect(result.permission).toBe(permission);
});
});
Testing edge cases:
test.each([
{ input: '', expected: null, description: 'empty string' },
{ input: ' ', expected: null, description: 'whitespace' },
{ input: 'valid', expected: 'valid', description: 'valid input' },
{ input: null, expected: null, description: 'null value' },
{ input: undefined, expected: null, description: 'undefined value' },
])('should handle $description', ({ input, expected }) => {
const result = sanitizeInput(input);
expect(result).toBe(expected);
});
Fixture Composition
Building Complex Contexts
Fixtures can depend on other fixtures, enabling composition:
import { test as baseTest } from '@/tests/config/base.context';
export const test = baseTest.extend({
// Level 1: Basic user
user: async ({ asSystem }, use) => {
const user = await asSystem(async (client) => {
return await createUser(client);
});
await use(user);
},
// Level 2: User with organization (depends on user)
userWithOrg: async ({ user, asSystem }, use) => {
const org = await asSystem(async (client) => {
return await createOrg(client, user.id);
});
await use({ user, org });
},
// Level 3: Full workspace setup (depends on userWithOrg)
workspace: async ({ userWithOrg, asSystem }, use) => {
const workspace = await asSystem(async (client) => {
return await createWorkspace(client, {
ownerId: userWithOrg.user.id,
orgId: userWithOrg.org.id
});
});
await use({ ...userWithOrg, workspace });
}
});
// Usage - just request what you need!
test('simple test', async ({ user }) => {
// Just user, no org or workspace
});
test('organization test', async ({ userWithOrg }) => {
// User + org, no workspace
});
test('full workspace test', async ({ workspace }) => {
// User + org + workspace, all set up automatically
});
Fixture Scopes
Control when fixtures are created and destroyed:
export const test = baseTest.extend({
// Test-scoped: New for each test
testData: async ({}, use) => {
const data = generateTestData();
await use(data);
},
// Worker-scoped: Shared across all tests in file
sharedResource: [async ({}, use) => {
const resource = await expensiveSetup();
await use(resource);
await cleanup(resource);
}, { scope: 'worker' }]
});
Lifecycle and Cleanup
Automatic Cleanup Pattern
export const test = baseTest.extend({
tempFile: async ({}, use) => {
// Setup: Create temp file
const filePath = `/tmp/test-${Date.now()}.txt`;
await fs.writeFile(filePath, 'test data');
// Provide to test
await use(filePath);
// Cleanup: Happens automatically, even if test fails
await fs.unlink(filePath);
},
dbConnection: async ({}, use) => {
// Setup: Create connection
const connection = await createConnection();
// Provide to test
await use(connection);
// Cleanup: Close connection
await connection.close();
}
});
Benefits:
- ✅ Cleanup always happens, even on test failure
- ✅ No try/finally boilerplate in tests
- ✅ Guaranteed resource cleanup
- ✅ Clear separation of concerns
Avoiding Duplication
Pattern 1: Extract Common Setup
Before:
test('test 1', async ({ admin }) => {
const key = await setupComplexKey(admin);
const token = await generateToken(key);
const validated = await validateToken(token);
// Test logic
});
test('test 2', async ({ admin }) => {
const key = await setupComplexKey(admin);
const token = await generateToken(key);
const validated = await validateToken(token);
// Different test logic
});
After:
const test = baseTest.extend({
validatedToken: async ({ admin }, use) => {
const key = await setupComplexKey(admin);
const token = await generateToken(key);
const validated = await validateToken(token);
await use(validated);
}
});
test('test 1', async ({ validatedToken }) => {
// Test logic with validatedToken
});
test('test 2', async ({ validatedToken }) => {
// Different test logic with validatedToken
});
Pattern 2: Conditional Fixtures
interface TestOptions {
withPermissions?: boolean;
permissionLevel?: 'read' | 'write' | 'admin';
}
function createTestContext(options: TestOptions = {}) {
return baseTest.extend({
testUser: async ({ asSystem }, use) => {
const user = await createUser();
if (options.withPermissions) {
await grantPermissions(user, options.permissionLevel || 'read');
}
await use(user);
}
});
}
// Use different contexts as needed
const adminTest = createTestContext({ withPermissions: true, permissionLevel: 'admin' });
const readOnlyTest = createTestContext({ withPermissions: true, permissionLevel: 'read' });
Real-World Examples
Example 1: Auth Testing
// tests/fixtures/auth.context.ts
import { test as baseTest } from '../config/base.context';
export const test = baseTest.extend({
// Pre-configured admin operations
admin: async ({ systemClient }, use) => {
await setupLocalKeys(systemClient, process.cwd());
await use(new AdminOperations(systemClient));
},
// Test key with common config
testKey: async ({ admin }, use) => {
const key = await admin.secrets.upsertSecret({
keyName: `test-key-${Date.now()}`,
secret: generateRandomSecret(),
keyType: 'hmac',
algorithm: 'aes',
isSigningKey: true
});
await use(key);
},
// Key hierarchy for testing
keyHierarchy: async ({ admin }, use) => {
const parent = await admin.secrets.upsertSecret({
keyName: 'test-parent',
secret: generateRandomSecret(),
keyType: 'symmetric',
algorithm: 'aes'
});
const child = await admin.secrets.upsertSecret({
keyName: 'test-child',
secret: generateRandomSecret(),
parentKeyName: parent.key_name,
keyType: 'hmac',
algorithm: 'aes'
});
await use({ parent, child });
}
});
// Usage
test('should decrypt key', async ({ testKey, admin }) => {
const result = await admin.secrets.decrypt(testKey.id);
expect(result.error_message).toBeNull();
});
test('should rotate key hierarchy', async ({ keyHierarchy, admin }) => {
// Test with pre-configured parent/child keys
});
Example 2: Graph Testing
// tests/fixtures/graph.context.ts
import { test as baseTest } from '../config/base.context';
export const test = baseTest.extend({
// Clean graph for each test
cleanGraph: async ({ systemClient }, use) => {
await systemClient.query('TRUNCATE TABLE graph.resource CASCADE');
await systemClient.query('TRUNCATE TABLE graph.statement CASCADE');
await use(systemClient);
},
// User with graph nodes
graphUser: async ({ cleanGraph, asSystem }, use) => {
const user = await asSystem(async (client) => {
const { user, org } = await createUserWithOrgSetup(client);
const userNode = await createGraphNode(client, {
nodeType: 'auth.user',
externalId: user.id,
ownerId: user.id
});
return { user, org, userNode };
});
await use(user);
},
// Connected graph structure
graphStructure: async ({ graphUser, asSystem }, use) => {
const structure = await asSystem(async (client) => {
const workspace = await createGraphNode(client, {
nodeType: 'studio.workspace',
externalId: `workspace-${Date.now()}`,
ownerId: graphUser.user.id
});
const document = await createGraphNode(client, {
nodeType: 'studio.document',
externalId: `doc-${Date.now()}`,
ownerId: graphUser.user.id
});
await createGraphEdge(client, {
fromNodeId: workspace.id,
toNodeId: document.id,
edgeType: 'contains'
});
return { ...graphUser, workspace, document };
});
await use(structure);
}
});
// Usage - parameterized with structure
test.each([
{ edgeType: 'contains', expected: true },
{ edgeType: 'references', expected: false },
])('should find $edgeType edges', async ({ edgeType, expected }, { graphStructure, asSystem }) => {
const result = await asSystem(async (client) => {
return await findEdgesByType(client, graphStructure.workspace.id, edgeType);
});
expect(result.length > 0).toBe(expected);
});
Example 3: Integration Testing
// Combining multiple fixtures
test('full integration flow', async ({ admin, graphUser, testKey }) => {
// All fixtures available and ready
// 1. Create JWT with testKey
const token = await admin.jwt.create({
keyName: testKey.key_name,
claims: { sub: graphUser.user.id, org: graphUser.org.id }
});
// 2. Validate token
const validated = await admin.jwt.validate(token);
expect(validated.valid).toBe(true);
// 3. Use token to access graph
await graph(graphUser.user.id, graphUser.org.id, async (ops) => {
const nodes = await ops.nodes.list({ nodeType: 'auth.user' });
expect(nodes).toContainEqual(expect.objectContaining({
external_id: graphUser.user.id
}));
});
});
Best Practices
✅ DO
-
Use test.extend() for shared setup
const test = baseTest.extend({ sharedSetup: ... }); -
Use test.each() for multiple scenarios
test.each([...scenarios])('test $name', async ({ param }) => { ... }); -
Compose fixtures for complex scenarios
const test = baseTest.extend({ simple: async ({}, use) => { ... }, complex: async ({ simple }, use) => { ... } }); -
Put cleanup in fixtures, not tests
async ({ }, use) => { const resource = await setup(); await use(resource); await cleanup(resource); // Automatic } -
Use descriptive fixture names
testKey, graphUser, validatedToken // Clear what they provide
❌ DON'T
-
Don't duplicate setup in tests
// Bad test('test 1', async () => { const setup = await commonSetup(); // Duplicated }); -
Don't forget cleanup will run even on failure
// Bad - manual cleanup that might not run try { const resource = await create(); // test } finally { await cleanup(); // Use fixture instead } -
Don't create unnecessary fixtures
// Bad - one-off setup doesn't need a fixture const test = baseTest.extend({ oneTimeUse: ... // Just do it in the test }); -
Don't make fixtures too complex
// Bad - too much logic in one fixture const test = baseTest.extend({ everything: async ({}, use) => { // 100 lines of setup } }); // Good - break into composable pieces const test = baseTest.extend({ part1: ..., part2: ..., combined: async ({ part1, part2 }, use) => { ... } });
Summary
Vitest's test.extend() and test.each() are powerful tools for:
- ✅ Eliminating duplication
- ✅ Creating reusable test contexts
- ✅ Testing multiple scenarios efficiently
- ✅ Automatic setup and cleanup
- ✅ Type-safe test fixtures
Key Takeaway: Invest time in creating good fixtures - they pay off massively in reduced duplication and improved test maintainability.
Related Documentation
- Test Contexts Guide - Available contexts and when to use them
- Writing Tests Guide - General test writing patterns
- Common Scenarios - Practical examples
- Fixtures Reference - Complete fixture API
Questions? See examples in tests/integration/auth/ and tests/integration/graph/ for real implementations.