Testing
Chrome Extension Starter uses Vitest for fast, modern unit testing with full TypeScript support.
Overview
Testing Stack:
- Vitest — Fast unit test runner
- @testing-library/preact — Component testing utilities
- jsdom — DOM environment for testing
- @vitest/coverage-v8 — Code coverage reports
Running Tests
Basic Commands
# Run all tests once
pnpm test
# Run tests in watch mode
pnpm test:watch
# Run tests with coverage report
pnpm test:cov
Watch Mode
Watch mode automatically reruns tests when files change:
pnpm test:watch
Features:
- Auto-detect changed files
- Only rerun affected tests
- Interactive CLI for filtering tests
- Press
ato run all tests - Press
qto quit
Test Structure
Tests are located in __tests__/ directory:
__tests__/
├── dom.test.ts # DOM utilities tests
├── i18n.test.ts # i18n function tests
├── logger.test.ts # Logger tests
├── messaging.test.ts # Messaging system tests
├── migration.test.ts # Migration system tests
├── setting.test.ts # Settings tests
├── storage.test.ts # Storage utilities tests
└── utils.test.ts # General utilities tests
Writing Tests
Basic Test
import { describe, it, expect } from 'vitest';
import { myFunction } from '@/shared/lib/utils';
describe('myFunction', () => {
it('should return expected value', () => {
const result = myFunction('input');
expect(result).toBe('expected output');
});
it('should handle edge cases', () => {
expect(myFunction('')).toBe('');
expect(myFunction(null)).toBeNull();
});
});
Async Tests
import { describe, it, expect } from 'vitest';
import { fetchData } from '@/shared/lib/api';
describe('fetchData', () => {
it('should fetch data successfully', async () => {
const data = await fetchData();
expect(data).toBeDefined();
expect(data.status).toBe('success');
});
it('should handle errors', async () => {
await expect(fetchData('invalid')).rejects.toThrow('Invalid input');
});
});
Component Tests
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/preact';
import { MyComponent } from '@/shared/components/MyComponent';
describe('MyComponent', () => {
it('should render correctly', () => {
render(<MyComponent title="Test" />);
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('should handle click events', async () => {
const { user } = render(<MyComponent onClick={handleClick} />);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).toHaveBeenCalled();
});
});
Mocking
Mock Chrome API
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('Background script', () => {
beforeEach(() => {
// Mock chrome.storage API
global.chrome = {
storage: {
local: {
get: vi.fn((keys, callback) => {
callback({ theme: 'dark' });
}),
set: vi.fn((items, callback) => {
callback?.();
})
}
}
} as any;
});
it('should save settings', async () => {
await saveSettings({ theme: 'dark' });
expect(chrome.storage.local.set).toHaveBeenCalledWith(
{ theme: 'dark' },
expect.any(Function)
);
});
});
Mock Modules
import { describe, it, expect, vi } from 'vitest';
// Mock entire module
vi.mock('@/shared/lib/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn()
}
}));
import { logger } from '@/shared/lib/logger';
import { myFunction } from '@/shared/lib/utils';
describe('myFunction', () => {
it('should log info', () => {
myFunction();
expect(logger.info).toHaveBeenCalledWith('Function called');
});
});
Mock Functions
import { describe, it, expect, vi } from 'vitest';
describe('Callback handling', () => {
it('should call callback with result', async () => {
const callback = vi.fn();
await processData(callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith({ success: true });
});
});
Testing Core Modules
Testing Messaging
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMessenger } from '@/shared/lib/messaging';
import type { MessageMap } from '@/shared/types';
describe('Messaging', () => {
let sendMessageMock: any;
beforeEach(() => {
sendMessageMock = vi.fn((msg, callback) => {
callback({ ok: true });
});
global.chrome = {
runtime: { sendMessage: sendMessageMock },
tabs: {
query: vi.fn((query, callback) => {
callback([{ id: 1 }]);
}),
sendMessage: vi.fn((tabId, msg, callback) => {
callback({ ok: true });
})
}
} as any;
});
it('should send message to active tab', async () => {
const bus = createMessenger<MessageMap>();
const result = await bus.sendToActive('CHANGE_BG', { color: 'red' });
expect(result).toEqual({ ok: true });
expect(chrome.tabs.sendMessage).toHaveBeenCalled();
});
});
Testing Storage
import { describe, it, expect, beforeEach } from 'vitest';
import { createTypedStorage } from '@/shared/lib/storage';
import type { StorageSchema } from '@/shared/types';
describe('Storage', () => {
let mockStorage: Record<string, any>;
beforeEach(() => {
mockStorage = {};
global.chrome = {
storage: {
local: {
get: vi.fn((keys, callback) => {
const result = Array.isArray(keys)
? keys.reduce((acc, key) => {
acc[key] = mockStorage[key];
return acc;
}, {})
: mockStorage;
callback(result);
}),
set: vi.fn((items, callback) => {
Object.assign(mockStorage, items);
callback?.();
})
}
}
} as any;
});
it('should get value with fallback', async () => {
const kv = createTypedStorage<StorageSchema>();
const value = await kv.get('local', 'username', 'Guest');
expect(value).toBe('Guest');
});
it('should set value', async () => {
const kv = createTypedStorage<StorageSchema>();
await kv.set('local', 'username', 'John');
expect(mockStorage.username).toBe('John');
});
});
Testing Migration
import { describe, it, expect, beforeEach } from 'vitest';
import { runMigrations } from '@/shared/lib/migration';
import { kv } from '@/shared/lib/storage';
describe('Migration', () => {
beforeEach(async () => {
await kv.clear('sync');
await kv.clear('local');
});
it('should run pending migrations', async () => {
await kv.set('sync', 'version', '1.0.0');
await kv.set('sync', 'oldSettings', { theme: 'dark' });
await runMigrations();
const newSettings = await kv.get('sync', 'settings');
expect(newSettings).toBeDefined();
expect(newSettings.theme).toBe('dark');
});
it('should skip already-applied migrations', async () => {
await kv.set('sync', 'version', '1.3.0');
await runMigrations();
const version = await kv.get('sync', 'version');
expect(version).toBe('1.3.0');
});
});
Coverage Reports
Generate Coverage
pnpm test:cov
Output:
--------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------|---------|----------|---------|---------|-------------------
All files | 85.5 | 78.2 | 90.1 | 85.5 |
messaging.ts | 92.3 | 85.7 | 100 | 92.3 | 45-48
storage.ts | 88.5 | 75.0 | 88.8 | 88.5 | 67,89-92
migration.ts | 80.0 | 71.4 | 83.3 | 80.0 | 123-130,145
--------------------|---------|----------|---------|---------|-------------------
Coverage Thresholds
Set minimum coverage in vitest.config.ts:
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80
}
}
}
});
View HTML Report
After running pnpm test:cov, open:
coverage/index.html
Best Practices
1. Test File Naming
✓ component.test.ts
✓ utils.test.ts
✓ messaging.test.tsx
✗ component.spec.ts (not configured)
✗ test-utils.ts (won't be detected)
2. Use Descriptive Test Names
// ✓ Good
it('should return user data when valid ID is provided', async () => {
// ...
});
// ✗ Bad
it('works', () => {
// ...
});
3. Arrange-Act-Assert Pattern
it('should update settings', async () => {
// Arrange
const initialSettings = { theme: 'light' };
await kv.set('sync', 'settings', initialSettings);
// Act
await updateSettings({ theme: 'dark' });
// Assert
const updatedSettings = await kv.get('sync', 'settings');
expect(updatedSettings.theme).toBe('dark');
});
4. Clean Up After Tests
import { afterEach, beforeEach } from 'vitest';
beforeEach(() => {
// Set up test environment
});
afterEach(() => {
// Clean up
vi.clearAllMocks();
});
5. Test Edge Cases
describe('parseVersion', () => {
it('should parse valid version', () => {
expect(parseVersion('1.2.3')).toEqual([1, 2, 3]);
});
it('should handle missing patch', () => {
expect(parseVersion('1.2')).toEqual([1, 2, 0]);
});
it('should handle invalid input', () => {
expect(parseVersion('invalid')).toEqual([0, 0, 0]);
});
});
6. Avoid Test Interdependence
// ✓ Good: Each test is independent
describe('Storage', () => {
it('should set value', async () => {
await kv.set('local', 'key', 'value1');
expect(await kv.get('local', 'key')).toBe('value1');
});
it('should update value', async () => {
await kv.set('local', 'key', 'value2');
expect(await kv.get('local', 'key')).toBe('value2');
});
});
// ✗ Bad: Second test depends on first
describe('Storage', () => {
it('should set value', async () => {
await kv.set('local', 'key', 'value1');
});
it('should read previously set value', async () => {
expect(await kv.get('local', 'key')).toBe('value1'); // Fragile!
});
});
Debugging Tests
VS Code Integration
Add launch configuration in .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Tests",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["test"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}
Debug Single Test
import { describe, it } from 'vitest';
describe.only('Focus this suite', () => {
it.only('Focus this test', () => {
// Only this test will run
});
});
Debug Output
import { describe, it } from 'vitest';
it('should debug values', () => {
const result = myFunction();
console.log('Result:', result); // Visible in test output
expect(result).toBeDefined();
});
Continuous Integration
GitHub Actions
.github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'pnpm'
- run: pnpm install
- run: pnpm test:cov
- uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
Troubleshooting
Tests Not Running
Check test file pattern:
vitest.config.ts
export default defineConfig({
test: {
include: ['__tests__/**/*.test.{ts,tsx}']
}
});
Import Errors
Configure path aliases:
vitest.config.ts
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
});
Mock Not Working
Ensure mock is hoisted:
// Mock before imports
vi.mock('@/shared/lib/logger');
import { logger } from '@/shared/lib/logger';