Savvi Studio

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:

  1. test.extend() - Creating custom test contexts with shared setup
  2. Parameterized tests - Testing multiple scenarios efficiently
  3. Fixture composition - Building complex test contexts from simpler ones
  4. Lifecycle management - Automatic setup and cleanup

Table of Contents

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 $variable interpolation
  • ✅ 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

  1. Use test.extend() for shared setup

    const test = baseTest.extend({ sharedSetup: ... });
    
  2. Use test.each() for multiple scenarios

    test.each([...scenarios])('test $name', async ({ param }) => { ... });
    
  3. Compose fixtures for complex scenarios

    const test = baseTest.extend({
      simple: async ({}, use) => { ... },
      complex: async ({ simple }, use) => { ... }
    });
    
  4. Put cleanup in fixtures, not tests

    async ({ }, use) => {
      const resource = await setup();
      await use(resource);
      await cleanup(resource); // Automatic
    }
    
  5. Use descriptive fixture names

    testKey, graphUser, validatedToken // Clear what they provide
    

❌ DON'T

  1. Don't duplicate setup in tests

    // Bad
    test('test 1', async () => {
      const setup = await commonSetup(); // Duplicated
    });
    
  2. 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
    }
    
  3. 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
    });
    
  4. 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.


Questions? See examples in tests/integration/auth/ and tests/integration/graph/ for real implementations.