324 lines
8.2 KiB
Markdown
324 lines
8.2 KiB
Markdown
# Vitest Mocking Guide for SvelteKit
|
|
|
|
This guide explains how to properly mock dependencies when testing SvelteKit applications with Vitest.
|
|
|
|
## Understanding Mocking in SvelteKit Context
|
|
|
|
SvelteKit has a unique architecture where code can run on both server and client. This affects how we mock:
|
|
|
|
1. **Server-only modules** (`$lib/server/*`, `*.server.ts`) - Only run on server
|
|
2. **Universal modules** - Can run on both server and client
|
|
3. **Environment variables** - Different modules for static vs dynamic access
|
|
|
|
## Key Principles
|
|
|
|
1. **`vi.mock()` is hoisted** - Always executed before imports
|
|
2. **Use factory functions** - Return mocked implementations
|
|
3. **Mock before import** - Mocks must be defined before the module is imported
|
|
4. **Clean up** - Always restore/reset mocks in `beforeEach` or `afterEach`
|
|
|
|
---
|
|
|
|
## Mocking Environment Variables ($env/dynamic/private)
|
|
|
|
**Problem:** Can't directly mock `$env/dynamic/private` because it's a SvelteKit magic module.
|
|
|
|
**Solution:** Create a config module that wraps env access, then mock the config.
|
|
|
|
### Example: Queue Config Module
|
|
|
|
```typescript
|
|
// src/lib/server/queue/config.ts
|
|
import { env } from '$env/dynamic/private';
|
|
|
|
export const queueConfig = {
|
|
concurrency: parseInt(env.QUEUE_CONCURRENCY || '2', 10),
|
|
maxRetries: parseInt(env.QUEUE_MAX_RETRIES || '3', 10),
|
|
tandoor: {
|
|
enabled: !!env.TANDOOR_TOKEN,
|
|
token: env.TANDOOR_TOKEN || null
|
|
}
|
|
};
|
|
```
|
|
|
|
### Mocking the Config in Tests
|
|
|
|
```typescript
|
|
import { vi, beforeEach, afterEach } from 'vitest';
|
|
import * as queueConfigModule from '$lib/server/queue/config';
|
|
|
|
// Mock the config module
|
|
vi.mock('$lib/server/queue/config', () => ({
|
|
queueConfig: {
|
|
concurrency: 2,
|
|
maxRetries: 3,
|
|
tandoor: { enabled: true, token: 'test-token' }
|
|
}
|
|
}));
|
|
|
|
describe('QueueProcessor', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Mocking External Service Modules
|
|
|
|
### Recommended Approach: Mock Entire Module
|
|
|
|
```typescript
|
|
import { vi } from 'vitest';
|
|
|
|
// IMPORTANT: Mock BEFORE importing the module that uses it
|
|
vi.mock('$lib/server/extraction', () => ({
|
|
extractTextAndThumbnail: vi.fn().mockResolvedValue({
|
|
bodyText: 'Mock recipe text',
|
|
thumbnail: 'https://mock.com/image.jpg'
|
|
})
|
|
}));
|
|
|
|
// NOW import the module that depends on these
|
|
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
|
|
import { extractTextAndThumbnail } from '$lib/server/extraction';
|
|
|
|
describe('QueueProcessor', () => {
|
|
it('should use mocked services', async () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
// Verify mock was called
|
|
expect(extractTextAndThumbnail).toHaveBeenCalledWith(
|
|
'https://instagram.com/p/test',
|
|
expect.any(Function)
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Mocking API Endpoints (SvelteKit RequestHandler)
|
|
|
|
When testing API endpoints, be aware that error responses must be properly awaited:
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
import { POST } from '../routes/api/queue/+server';
|
|
|
|
describe('POST /api/queue', () => {
|
|
it('should reject invalid URLs', async () => {
|
|
const request = new Request('http://localhost/api/queue', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: 'invalid-url' })
|
|
});
|
|
|
|
const response = await POST({ request } as any);
|
|
|
|
// ✅ CORRECT - Check status first
|
|
expect(response.status).toBe(400);
|
|
|
|
// ✅ CORRECT - Properly await error response
|
|
const data = await response.json();
|
|
expect(data.message).toContain('Invalid');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Common Pitfall: Not Awaiting Error Responses
|
|
|
|
```typescript
|
|
// ❌ WRONG - This will fail
|
|
it('should reject invalid input', async () => {
|
|
const response = await endpoint({ request } as any);
|
|
const data = response.json(); // Missing await!
|
|
expect(data.message).toBe('Error'); // data is a Promise
|
|
});
|
|
|
|
// ✅ CORRECT
|
|
it('should reject invalid input', async () => {
|
|
const response = await endpoint({ request } as any);
|
|
expect(response.status).toBe(400);
|
|
const data = await response.json(); // Properly awaited
|
|
expect(data.message).toBe('Error');
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Common Pitfalls and Solutions
|
|
|
|
### Problem 1: Mock Not Working
|
|
|
|
```typescript
|
|
// ❌ WRONG - Import before mock
|
|
import { queueProcessor } from './QueueProcessor';
|
|
vi.mock('./extraction');
|
|
|
|
// ✅ CORRECT - Mock before import
|
|
vi.mock('./extraction');
|
|
import { queueProcessor } from './QueueProcessor';
|
|
```
|
|
|
|
### Problem 2: Mocks Not Resetting Between Tests
|
|
|
|
```typescript
|
|
// ✅ SOLUTION - Always clean up
|
|
import { beforeEach, afterEach } from 'vitest';
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks(); // Clear call history
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks(); // Restore original implementations
|
|
});
|
|
```
|
|
|
|
### Problem 3: TypeScript Errors with Mocked Functions
|
|
|
|
```typescript
|
|
import { vi } from 'vitest';
|
|
|
|
// ✅ CORRECT - Type assertion
|
|
const mockFn = vi.fn<() => Promise<string>>();
|
|
mockFn.mockResolvedValue('test');
|
|
|
|
// OR use vi.mocked()
|
|
import { vi, type Mock } from 'vitest';
|
|
const mockFn = vi.fn() as Mock<() => Promise<string>>;
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Async Queue Processing
|
|
|
|
### Solution 1: Wait for Processing
|
|
|
|
```typescript
|
|
it('should process item', async () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
// Wait for processing with timeout
|
|
await vi.waitFor(
|
|
() => {
|
|
const updated = queueManager.get(item.id);
|
|
expect(updated?.status).toBe('success');
|
|
},
|
|
{ timeout: 5000, interval: 100 }
|
|
);
|
|
});
|
|
```
|
|
|
|
### Solution 2: Use Fake Timers
|
|
|
|
```typescript
|
|
import { vi } from 'vitest';
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should process after delay', async () => {
|
|
queueManager.enqueue('https://test.com');
|
|
|
|
// Fast-forward time
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
// Now check results
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices for SvelteKit + Vitest
|
|
|
|
1. **Always mock before import** - `vi.mock()` calls are hoisted but still need to be before your imports
|
|
2. **Use factory functions** - Return new instances to avoid state leaking between tests
|
|
3. **Clean up thoroughly** - Use `beforeEach`/`afterEach` to reset state
|
|
4. **Type your mocks** - Use TypeScript generics for type-safe mocks
|
|
5. **Test isolation** - Each test should be independent
|
|
6. **Mock at the right level** - Mock external boundaries (HTTP, DB), not internal logic
|
|
7. **Use `vi.waitFor()`** - For async operations instead of arbitrary `setTimeout()`
|
|
8. **Snapshot complex mocks** - Use `expect.any(Function)` for callbacks
|
|
9. **Always await `.json()` on responses** - Both success and error responses
|
|
|
|
---
|
|
|
|
## Quick Reference: Mock Cheat Sheet
|
|
|
|
```typescript
|
|
// Mock entire module
|
|
vi.mock('./module', () => ({ export: vi.fn() }));
|
|
|
|
// Mock with factory
|
|
vi.mock('./module', () => {
|
|
return { dynamicExport: () => 'value' };
|
|
});
|
|
|
|
// Spy on existing export
|
|
vi.spyOn(module, 'export').mockReturnValue('value');
|
|
|
|
// Mock return value
|
|
mockFn.mockReturnValue('sync value');
|
|
mockFn.mockResolvedValue('async value');
|
|
mockFn.mockRejectedValue(new Error('async error'));
|
|
|
|
// Mock implementation
|
|
mockFn.mockImplementation((arg) => arg * 2);
|
|
mockFn.mockImplementationOnce((arg) => arg * 3);
|
|
|
|
// Check calls
|
|
expect(mockFn).toHaveBeenCalled();
|
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
|
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
|
|
expect(mockFn).toHaveBeenLastCalledWith('arg');
|
|
|
|
// Reset/restore
|
|
vi.clearAllMocks(); // Clear call history
|
|
vi.resetAllMocks(); // + Reset implementations
|
|
vi.restoreAllMocks(); // + Restore original implementations
|
|
|
|
// Environment variables
|
|
vi.stubEnv('VAR_NAME', 'value');
|
|
vi.unstubAllEnvs();
|
|
|
|
// Timers
|
|
vi.useFakeTimers();
|
|
vi.advanceTimersByTime(1000);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
vi.useRealTimers();
|
|
|
|
// Async helpers
|
|
await vi.waitFor(() => expect(condition).toBe(true));
|
|
await vi.waitUntil(() => condition === true);
|
|
```
|
|
|
|
---
|
|
|
|
## Examples from This Project
|
|
|
|
See the following test files for real-world examples:
|
|
|
|
- `src/tests/queue-manager.spec.ts` - Mocking external services
|
|
- `src/tests/queue-processor.spec.ts` - Mocking config module and services
|
|
- `src/tests/queue-api.spec.ts` - Testing API endpoints with proper async handling
|
|
|
|
---
|
|
|
|
## Additional Resources
|
|
|
|
- [Vitest Mocking Guide](https://vitest.dev/guide/mocking.html)
|
|
- [SvelteKit Testing](https://kit.svelte.dev/docs/testing)
|
|
- [Vitest API Reference](https://vitest.dev/api/)
|