mirror of
https://github.com/duthaho/claudekit.git
synced 2026-06-15 14:34:55 +03:00
feat: improved the Claude Kit as a plugin
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: testing
|
||||
description: >
|
||||
Use when writing, debugging, or configuring unit or integration tests with pytest, Vitest, or Jest. Also activate for fixtures, mocking, coverage, parametrization, jest.mock, vi.mock, jest.fn, vi.fn, conftest.py, vitest.config.ts, jest.config, Testing Library, @jest/globals, or any test configuration.
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
## When to Use
|
||||
|
||||
- Writing Python tests with pytest (fixtures, parametrize, markers, coverage)
|
||||
- Testing JavaScript/TypeScript with Vitest (React components, mocking, workspace)
|
||||
- NestJS or existing projects using Jest
|
||||
- Debugging test configuration, ESM issues, or flaky tests
|
||||
- Setting up coverage, CI integration, or test infrastructure
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- E2E browser testing — use `playwright`
|
||||
- Testing anti-patterns and methodology — use `testing-anti-patterns`
|
||||
- TDD workflow — use `test-driven-development`
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Framework | Reference | Key features |
|
||||
|-----------|-----------|-------------|
|
||||
| pytest | `references/pytest.md` | Fixtures, parametrize, conftest, markers, coverage, async tests |
|
||||
| Vitest | `references/vitest.md` | vi.mock, vi.fn, Testing Library, MSW, workspace, coverage |
|
||||
| Jest | `references/jest.md` | jest.mock, jest.fn, @jest/globals, NestJS testing, migration to Vitest |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Name tests descriptively.** `test_[function]_[scenario]_[expected]` (Python) or `it('should [behavior]')` (JS/TS).
|
||||
2. **Keep tests independent.** Never rely on execution order. Each test sets up its own state.
|
||||
3. **One assertion focus per test.** Multiple asserts OK if verifying the same behavior.
|
||||
4. **Mock at the boundary, not in the middle.** Mock external services, databases, and network calls. Don't mock internal functions.
|
||||
5. **Clear/restore mocks between tests.** `vi.clearAllMocks()` in `beforeEach` or `jest.restoreAllMocks()` in `afterEach`.
|
||||
6. **Use `userEvent` over `fireEvent`** for React component testing (simulates real user behavior).
|
||||
7. **Query by role and label, not test IDs** (`getByRole`, `getByLabelText` over `getByTestId`).
|
||||
8. **Run the full suite in CI with branch coverage.** Local development can use `-x` for fast feedback.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Forgetting to `await` in async tests.** Omitting `await` makes tests pass vacuously.
|
||||
2. **Mock hoisting confusion.** `vi.mock()`/`jest.mock()` calls are hoisted — variables referenced in mock implementations may be undefined.
|
||||
3. **Shared mutable fixtures.** A module-scoped fixture returning a mutable object gets modified by one test and breaks another.
|
||||
4. **Patching the wrong import path.** Patch where the import is looked up, not where it's defined.
|
||||
5. **Snapshot overuse.** Developers update snapshots without reviewing diffs. Prefer explicit assertions.
|
||||
6. **Not cleaning up fake timers.** Forgetting `vi.useRealTimers()` in `afterEach` breaks subsequent tests.
|
||||
7. **Testing implementation, not behavior.** Assert on outcomes, not internal method calls.
|
||||
8. **Running Jest where Vitest fits.** For new Vite/React/Next.js projects, Vitest is strictly better.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `testing-anti-patterns` — Common testing mistakes to avoid
|
||||
- `test-driven-development` — TDD workflow
|
||||
- `playwright` — End-to-end browser testing
|
||||
- `languages` — Language-specific test idioms
|
||||
@@ -0,0 +1,409 @@
|
||||
# Testing — Jest Patterns
|
||||
|
||||
|
||||
# Jest
|
||||
|
||||
## Overview
|
||||
|
||||
Testing patterns for projects that use Jest as their test runner — primarily NestJS backends and legacy React projects. For new TypeScript/React projects, prefer `vitest` (faster, native ESM, Vite-aligned). This skill focuses on Jest-specific patterns, NestJS integration, and the Jest-to-Vitest migration path.
|
||||
|
||||
## When to Use
|
||||
- NestJS projects (Jest is the default test runner)
|
||||
- Existing projects that already use Jest
|
||||
- React component testing with Jest + Testing Library
|
||||
- Debugging Jest configuration issues (ESM, TypeScript transforms, module resolution)
|
||||
|
||||
## When NOT to Use
|
||||
- **New Vite/React/Next.js projects** — use `vitest` (better ESM support, faster)
|
||||
- **Python testing** — use `pytest`
|
||||
- **E2E browser testing** — use `playwright`
|
||||
- **Cloudflare Workers** — use `vitest` with `@cloudflare/vitest-pool-workers`
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| I need... | Go to |
|
||||
|-----------|-------|
|
||||
| NestJS testing patterns | § NestJS Testing below |
|
||||
| Mock patterns | § Mocking below |
|
||||
| TypeScript config | § Configuration below |
|
||||
| ESM troubleshooting | § ESM Gotchas below |
|
||||
| Migration to Vitest | § Jest → Vitest Migration below |
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Test structure
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new UserService();
|
||||
});
|
||||
|
||||
it('should create a user with default role', () => {
|
||||
const user = service.create({ email: 'test@example.com', name: 'Test' });
|
||||
expect(user.role).toBe('member');
|
||||
});
|
||||
|
||||
it('should throw on duplicate email', () => {
|
||||
service.create({ email: 'test@example.com', name: 'A' });
|
||||
expect(() => service.create({ email: 'test@example.com', name: 'B' }))
|
||||
.toThrow('Email already exists');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Assertions
|
||||
|
||||
```typescript
|
||||
// Equality
|
||||
expect(result).toBe(42); // strict ===
|
||||
expect(result).toEqual({ id: '1' }); // deep equality
|
||||
expect(result).toStrictEqual(obj); // deep + type equality
|
||||
|
||||
// Truthiness
|
||||
expect(value).toBeTruthy();
|
||||
expect(value).toBeFalsy();
|
||||
expect(value).toBeNull();
|
||||
expect(value).toBeUndefined();
|
||||
expect(value).toBeDefined();
|
||||
|
||||
// Numbers
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(price).toBeCloseTo(9.99, 2);
|
||||
|
||||
// Strings
|
||||
expect(message).toMatch(/error/i);
|
||||
expect(message).toContain('failed');
|
||||
|
||||
// Arrays / objects
|
||||
expect(arr).toContain('item');
|
||||
expect(arr).toHaveLength(3);
|
||||
expect(obj).toHaveProperty('email', 'test@example.com');
|
||||
|
||||
// Exceptions
|
||||
expect(() => parse('{bad}')).toThrow(SyntaxError);
|
||||
expect(() => validate({})).toThrow('Required');
|
||||
|
||||
// Async
|
||||
await expect(fetchUser('missing')).rejects.toThrow('Not found');
|
||||
await expect(fetchUser('exists')).resolves.toHaveProperty('id');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mocking
|
||||
|
||||
### `jest.fn()` — standalone mock function
|
||||
|
||||
```typescript
|
||||
const callback = jest.fn();
|
||||
callback('arg1');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith('arg1');
|
||||
```
|
||||
|
||||
### `jest.spyOn()` — spy on existing methods
|
||||
|
||||
```typescript
|
||||
const spy = jest.spyOn(service, 'findOne').mockResolvedValue(mockUser);
|
||||
|
||||
await controller.getUser('123');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('123');
|
||||
spy.mockRestore(); // Restore original
|
||||
```
|
||||
|
||||
### `jest.mock()` — module mocking
|
||||
|
||||
```typescript
|
||||
// Auto-mock entire module
|
||||
jest.mock('./email.service');
|
||||
|
||||
// Manual mock with implementation
|
||||
jest.mock('./email.service', () => ({
|
||||
EmailService: jest.fn().mockImplementation(() => ({
|
||||
send: jest.fn().mockResolvedValue({ messageId: 'msg_123' }),
|
||||
})),
|
||||
}));
|
||||
```
|
||||
|
||||
### Mock return values
|
||||
|
||||
```typescript
|
||||
const mock = jest.fn();
|
||||
|
||||
mock.mockReturnValue(42); // Sync
|
||||
mock.mockReturnValueOnce(1); // First call only
|
||||
mock.mockResolvedValue({ ok: true }); // Async
|
||||
mock.mockRejectedValue(new Error()); // Async throw
|
||||
mock.mockImplementation((x) => x * 2); // Custom logic
|
||||
```
|
||||
|
||||
### Clear vs Reset vs Restore
|
||||
|
||||
| Method | Clears calls | Resets implementation | Restores original |
|
||||
|--------|-------------|----------------------|-------------------|
|
||||
| `mockClear()` | yes | no | no |
|
||||
| `mockReset()` | yes | yes (returns undefined) | no |
|
||||
| `mockRestore()` | yes | yes | yes (spyOn only) |
|
||||
|
||||
Use `jest.restoreAllMocks()` in `afterEach` to avoid mock leaks.
|
||||
|
||||
---
|
||||
|
||||
## NestJS Testing
|
||||
|
||||
### Unit test a service
|
||||
|
||||
```typescript
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UsersService } from './users.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
let prisma: jest.Mocked<PrismaService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UsersService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(UsersService);
|
||||
prisma = module.get(PrismaService);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for missing user', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(null);
|
||||
await expect(service.findOne('missing')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns user when found', async () => {
|
||||
const mockUser = { id: '1', email: 'test@example.com', name: 'Test' };
|
||||
prisma.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.findOne('1');
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { id: '1' } });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### E2E test a controller
|
||||
|
||||
```typescript
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('Users (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(() => app.close());
|
||||
|
||||
it('POST /users creates user', () =>
|
||||
request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.send({ email: 'test@example.com', name: 'Test' })
|
||||
.expect(201)
|
||||
.expect((res) => expect(res.body).toHaveProperty('id')));
|
||||
|
||||
it('POST /users rejects invalid payload', () =>
|
||||
request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.send({ email: 'bad' })
|
||||
.expect(400));
|
||||
});
|
||||
```
|
||||
|
||||
### Test a guard
|
||||
|
||||
```typescript
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
let guard: JwtAuthGuard;
|
||||
let jwtService: jest.Mocked<JwtService>;
|
||||
|
||||
beforeEach(() => {
|
||||
jwtService = { verifyAsync: jest.fn() } as any;
|
||||
guard = new JwtAuthGuard(jwtService);
|
||||
});
|
||||
|
||||
const mockContext = (authHeader?: string): ExecutionContext => ({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
headers: { authorization: authHeader },
|
||||
}),
|
||||
}),
|
||||
}) as any;
|
||||
|
||||
it('rejects missing token', async () => {
|
||||
await expect(guard.canActivate(mockContext())).rejects.toThrow('Missing bearer token');
|
||||
});
|
||||
|
||||
it('accepts valid token', async () => {
|
||||
jwtService.verifyAsync.mockResolvedValue({ sub: 'user_1', role: 'admin' });
|
||||
await expect(guard.canActivate(mockContext('Bearer valid.jwt.token'))).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### TypeScript with `ts-jest`
|
||||
|
||||
```typescript
|
||||
// jest.config.ts
|
||||
import type { Config } from 'jest';
|
||||
|
||||
const config: Config = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/*.spec.ts', '**/*.test.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.module.ts',
|
||||
'!src/main.ts',
|
||||
'!src/**/*.dto.ts',
|
||||
'!src/**/*.entity.ts',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: { branches: 80, functions: 80, lines: 80, statements: 80 },
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### SWC transform (faster)
|
||||
|
||||
Replace `ts-jest` with `@swc/jest` for 5-10x faster transforms:
|
||||
|
||||
```typescript
|
||||
// jest.config.ts
|
||||
const config: Config = {
|
||||
transform: {
|
||||
'^.+\\.tsx?$': ['@swc/jest'],
|
||||
},
|
||||
// ... rest same
|
||||
};
|
||||
```
|
||||
|
||||
### React + Testing Library
|
||||
|
||||
```typescript
|
||||
// jest.config.ts
|
||||
const config: Config = {
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterSetup: ['<rootDir>/jest.setup.ts'],
|
||||
transform: { '^.+\\.tsx?$': ['@swc/jest'] },
|
||||
moduleNameMapper: {
|
||||
'\\.(css|less|scss)$': 'identity-obj-proxy',
|
||||
'\\.(jpg|png|svg)$': '<rootDir>/__mocks__/fileMock.ts',
|
||||
},
|
||||
};
|
||||
|
||||
// jest.setup.ts
|
||||
import '@testing-library/jest-dom';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ESM Gotchas
|
||||
|
||||
Jest's ESM support is still experimental. Common issues and fixes:
|
||||
|
||||
| Problem | Fix |
|
||||
|---------|-----|
|
||||
| `SyntaxError: Cannot use import` | Add `transform` with `ts-jest` or `@swc/jest` |
|
||||
| Module not found for `.js` imports | Set `moduleNameMapper` or use `ts-jest` with `useESM: true` |
|
||||
| `jest.mock()` doesn't work with ESM | Use `jest.unstable_mockModule()` (experimental) |
|
||||
| Dynamic `import()` in tests | Set `transform` to handle the syntax |
|
||||
| `__dirname` undefined | ESM doesn't have `__dirname`; use `import.meta.url` + `fileURLToPath` |
|
||||
|
||||
**If fighting ESM issues takes more than 30 minutes, migrate to Vitest.** Vitest handles ESM natively and is a near-drop-in replacement.
|
||||
|
||||
---
|
||||
|
||||
## Jest → Vitest Migration
|
||||
|
||||
For projects outgrowing Jest's ESM limitations or wanting faster transforms:
|
||||
|
||||
| Jest | Vitest |
|
||||
|------|--------|
|
||||
| `jest.fn()` | `vi.fn()` |
|
||||
| `jest.mock('./mod')` | `vi.mock('./mod')` |
|
||||
| `jest.spyOn(obj, 'method')` | `vi.spyOn(obj, 'method')` |
|
||||
| `jest.useFakeTimers()` | `vi.useFakeTimers()` |
|
||||
| `jest.config.ts` | `vitest.config.ts` |
|
||||
| `@jest/globals` | `vitest` |
|
||||
| `ts-jest` / `@swc/jest` | Not needed (native TS) |
|
||||
| `jest.setup.ts` → `setupFilesAfterSetup` | `vitest.config.ts` → `setupFiles` |
|
||||
|
||||
Most tests migrate with a find-replace of `jest` → `vi` and `@jest/globals` → `vitest`. Run `npx vitest --reporter=verbose` to catch edge cases.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Mock leaks between tests.** Always call `jest.restoreAllMocks()` in `afterEach`. Without it, one test's mock infects the next.
|
||||
2. **Forgetting `await` on async assertions.** `expect(fn()).rejects.toThrow()` without `await` silently passes even if the promise resolves.
|
||||
3. **Using `jest.mock()` with ESM.** Module-level `jest.mock()` doesn't work reliably with ESM. Use `jest.unstable_mockModule()` or switch to Vitest.
|
||||
4. **Testing implementation, not behavior.** Asserting `mock.toHaveBeenCalledTimes(3)` tests internal calls, not outcomes. Assert on the return value or side effect instead.
|
||||
5. **Slow transforms.** Default `ts-jest` is slow. Switch to `@swc/jest` for 5-10x speedup with zero config change.
|
||||
6. **Not closing NestJS app in E2E tests.** Missing `afterAll(() => app.close())` leaks connections and causes "open handle" warnings.
|
||||
7. **Snapshot overuse.** `toMatchSnapshot()` on large objects makes tests pass everything — any change auto-updates. Use targeted assertions instead.
|
||||
8. **Running Jest where Vitest fits.** For new Vite/React/Next.js projects, Vitest is strictly better (native ESM, faster, same API). Only use Jest when the framework mandates it (NestJS) or the project already depends on it.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `vitest` — preferred runner for new TypeScript/React projects
|
||||
- `nestjs` — NestJS framework (Jest is the default runner)
|
||||
- `react` — React component patterns
|
||||
- `testing-anti-patterns` — test quality pitfalls (applies to Jest too)
|
||||
- `test-driven-development` — TDD methodology
|
||||
@@ -0,0 +1,686 @@
|
||||
# Testing — pytest Patterns
|
||||
|
||||
|
||||
# pytest
|
||||
|
||||
## When to Use
|
||||
|
||||
- Writing Python tests
|
||||
- Test fixtures and setup
|
||||
- Mocking dependencies
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- JavaScript or TypeScript testing -- use the `vitest` skill instead
|
||||
- Projects that explicitly mandate unittest-only by convention with no pytest dependency
|
||||
- Non-Python test files or environments
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Fixtures
|
||||
|
||||
Fixtures provide reusable setup and teardown logic. They are requested by name as test function parameters.
|
||||
|
||||
#### Function-Scoped Fixtures (default)
|
||||
|
||||
A new instance is created for every test that requests it.
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from myapp.models import User
|
||||
from myapp.db import Session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user():
|
||||
"""Fresh user instance per test."""
|
||||
return User(id=1, name="Alice", email="alice@example.com")
|
||||
|
||||
|
||||
def test_user_display_name(user):
|
||||
assert user.display_name() == "Alice"
|
||||
|
||||
|
||||
def test_user_email_domain(user):
|
||||
assert user.email_domain() == "example.com"
|
||||
```
|
||||
|
||||
#### Class and Module Scope
|
||||
|
||||
Use broader scopes for expensive resources that are safe to share.
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="class")
|
||||
def api_client():
|
||||
"""Shared across all tests in a test class."""
|
||||
client = APIClient(base_url="http://testserver")
|
||||
client.authenticate(token="test-token")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def database_schema():
|
||||
"""Created once per test module, shared across all tests in the file."""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def redis_connection():
|
||||
"""Created once for the entire test session."""
|
||||
conn = Redis(host="localhost", port=6379, db=15)
|
||||
conn.flushdb()
|
||||
yield conn
|
||||
conn.flushdb()
|
||||
conn.close()
|
||||
```
|
||||
|
||||
#### Yield Fixtures for Teardown
|
||||
|
||||
`yield` separates setup from teardown. Code after `yield` runs after the test completes, even if the test fails.
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
session = Session()
|
||||
session.begin()
|
||||
yield session
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config(tmp_path):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("debug: true\nlog_level: INFO\n")
|
||||
yield config_file
|
||||
# tmp_path is automatically cleaned up by pytest
|
||||
```
|
||||
|
||||
#### Autouse Fixtures
|
||||
|
||||
Apply a fixture to every test automatically without requesting it by name.
|
||||
|
||||
```python
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_environment(monkeypatch):
|
||||
"""Ensure each test starts with clean environment variables."""
|
||||
monkeypatch.delenv("API_KEY", raising=False)
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def freeze_time():
|
||||
"""Pin time for deterministic tests."""
|
||||
with freeze_time("2025-06-15T12:00:00Z"):
|
||||
yield
|
||||
```
|
||||
|
||||
#### Factory Fixtures
|
||||
|
||||
Return a factory function when tests need multiple instances with varying parameters.
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def make_user():
|
||||
"""Factory that creates users with sensible defaults."""
|
||||
created = []
|
||||
|
||||
def _make_user(name="Test User", role="viewer", active=True):
|
||||
user = User(name=name, role=role, active=active)
|
||||
created.append(user)
|
||||
return user
|
||||
|
||||
yield _make_user
|
||||
|
||||
# Teardown: clean up all created users
|
||||
for u in created:
|
||||
u.delete()
|
||||
|
||||
|
||||
def test_admin_permissions(make_user):
|
||||
admin = make_user(name="Admin", role="admin")
|
||||
viewer = make_user(name="Viewer", role="viewer")
|
||||
assert admin.can_delete_users() is True
|
||||
assert viewer.can_delete_users() is False
|
||||
```
|
||||
|
||||
#### Parametrized Fixtures with request.param
|
||||
|
||||
Run the same test against multiple fixture variants.
|
||||
|
||||
```python
|
||||
@pytest.fixture(params=["sqlite", "postgresql"])
|
||||
def db_engine(request):
|
||||
"""Test against multiple database backends."""
|
||||
if request.param == "sqlite":
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
elif request.param == "postgresql":
|
||||
engine = create_engine("postgresql://test:test@localhost/testdb")
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_insert_and_query(db_engine):
|
||||
# This test runs twice: once with sqlite, once with postgresql
|
||||
with db_engine.connect() as conn:
|
||||
conn.execute(text("CREATE TABLE t (id INT)"))
|
||||
conn.execute(text("INSERT INTO t VALUES (1)"))
|
||||
result = conn.execute(text("SELECT * FROM t")).fetchall()
|
||||
assert len(result) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Parametrize
|
||||
|
||||
#### Single Parameter
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("email", [
|
||||
"user@example.com",
|
||||
"admin@test.org",
|
||||
"name+tag@domain.co.uk",
|
||||
])
|
||||
def test_valid_email_accepted(email):
|
||||
assert is_valid_email(email) is True
|
||||
```
|
||||
|
||||
#### Multiple Parameters
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("input_text, expected", [
|
||||
("hello", "HELLO"),
|
||||
("world", "WORLD"),
|
||||
("", ""),
|
||||
("already UPPER", "ALREADY UPPER"),
|
||||
])
|
||||
def test_uppercase(input_text, expected):
|
||||
assert input_text.upper() == expected
|
||||
```
|
||||
|
||||
#### Custom IDs for Readable Output
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("status_code, should_retry", [
|
||||
pytest.param(200, False, id="success-no-retry"),
|
||||
pytest.param(429, True, id="rate-limited-retry"),
|
||||
pytest.param(500, True, id="server-error-retry"),
|
||||
pytest.param(404, False, id="not-found-no-retry"),
|
||||
])
|
||||
def test_retry_logic(status_code, should_retry):
|
||||
response = MockResponse(status_code=status_code)
|
||||
assert should_retry_request(response) is should_retry
|
||||
```
|
||||
|
||||
#### Indirect Parametrize
|
||||
|
||||
Pass parameters through a fixture rather than directly to the test.
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def user_role(request):
|
||||
"""Create a user with the given role."""
|
||||
return User(name="Test", role=request.param)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_role", ["admin", "editor", "viewer"], indirect=True)
|
||||
def test_dashboard_access(user_role):
|
||||
if user_role.role == "admin":
|
||||
assert user_role.can_access("/admin/dashboard") is True
|
||||
else:
|
||||
assert user_role.can_access("/admin/dashboard") is False
|
||||
```
|
||||
|
||||
#### Stacking Parametrize Decorators
|
||||
|
||||
Creates the cartesian product of all parameter sets.
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("method", ["GET", "POST", "PUT", "DELETE"])
|
||||
@pytest.mark.parametrize("auth", ["token", "session", "none"])
|
||||
def test_endpoint_auth(method, auth):
|
||||
# Runs 4 x 3 = 12 test cases
|
||||
response = make_request(method=method, auth_type=auth)
|
||||
if auth == "none":
|
||||
assert response.status_code == 401
|
||||
else:
|
||||
assert response.status_code in (200, 201, 204)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Mocking
|
||||
|
||||
#### monkeypatch -- Environment Variables and Attributes
|
||||
|
||||
```python
|
||||
def test_reads_api_key_from_env(monkeypatch):
|
||||
monkeypatch.setenv("API_KEY", "test-key-12345")
|
||||
config = load_config()
|
||||
assert config.api_key == "test-key-12345"
|
||||
|
||||
|
||||
def test_missing_api_key_raises(monkeypatch):
|
||||
monkeypatch.delenv("API_KEY", raising=False)
|
||||
with pytest.raises(ConfigError, match="API_KEY is required"):
|
||||
load_config()
|
||||
|
||||
|
||||
def test_override_attribute(monkeypatch):
|
||||
monkeypatch.setattr("myapp.settings.MAX_RETRIES", 0)
|
||||
assert retry_request(failing_url) is None # No retries attempted
|
||||
|
||||
|
||||
def test_override_dict_item(monkeypatch):
|
||||
monkeypatch.setitem(app_config, "timeout", 1)
|
||||
assert app_config["timeout"] == 1
|
||||
```
|
||||
|
||||
#### unittest.mock.patch
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, Mock, AsyncMock
|
||||
|
||||
|
||||
@patch("myapp.services.payment.stripe.Charge.create")
|
||||
def test_charge_customer(mock_charge):
|
||||
mock_charge.return_value = Mock(id="ch_123", status="succeeded")
|
||||
|
||||
result = process_payment(amount=1000, currency="usd", token="tok_visa")
|
||||
|
||||
mock_charge.assert_called_once_with(
|
||||
amount=1000, currency="usd", source="tok_visa"
|
||||
)
|
||||
assert result.charge_id == "ch_123"
|
||||
|
||||
|
||||
@patch("myapp.services.email.send_email")
|
||||
@patch("myapp.services.user.UserRepository.find_by_id")
|
||||
def test_send_welcome_email(mock_find, mock_send):
|
||||
mock_find.return_value = User(id=1, email="new@example.com")
|
||||
mock_send.return_value = True
|
||||
|
||||
send_welcome(user_id=1)
|
||||
|
||||
mock_send.assert_called_once_with(
|
||||
to="new@example.com", template="welcome"
|
||||
)
|
||||
```
|
||||
|
||||
#### responses Library for HTTP Mocking
|
||||
|
||||
```python
|
||||
import responses
|
||||
import requests
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_user_from_api():
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.example.com/users/1",
|
||||
json={"id": 1, "name": "Alice"},
|
||||
status=200,
|
||||
)
|
||||
|
||||
result = fetch_user(user_id=1)
|
||||
|
||||
assert result["name"] == "Alice"
|
||||
assert len(responses.calls) == 1
|
||||
assert responses.calls[0].request.url == "https://api.example.com/users/1"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_timeout_handling():
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.example.com/users/1",
|
||||
body=requests.exceptions.ConnectionError("Connection timed out"),
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceUnavailableError):
|
||||
fetch_user(user_id=1)
|
||||
```
|
||||
|
||||
#### pytest-mock's mocker Fixture
|
||||
|
||||
```python
|
||||
def test_with_mocker(mocker):
|
||||
mock_repo = mocker.patch("myapp.services.OrderRepository")
|
||||
mock_repo.return_value.get_by_id.return_value = Order(
|
||||
id=1, status="pending"
|
||||
)
|
||||
|
||||
service = OrderService()
|
||||
order = service.get_order(1)
|
||||
|
||||
assert order.status == "pending"
|
||||
mock_repo.return_value.get_by_id.assert_called_once_with(1)
|
||||
|
||||
|
||||
def test_spy_on_method(mocker):
|
||||
spy = mocker.spy(UserService, "validate_email")
|
||||
|
||||
service = UserService()
|
||||
service.register("alice@example.com")
|
||||
|
||||
spy.assert_called_once_with(service, "alice@example.com")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Async Testing
|
||||
|
||||
#### pytest-asyncio Basics
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_fetch():
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get("https://httpbin.org/get")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exception():
|
||||
with pytest.raises(ValueError, match="invalid"):
|
||||
await validate_async_input("")
|
||||
```
|
||||
|
||||
#### Async Fixtures
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def async_db_session():
|
||||
session = AsyncSession(bind=async_engine)
|
||||
await session.begin()
|
||||
yield session
|
||||
await session.rollback()
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_query(async_db_session):
|
||||
result = await async_db_session.execute(
|
||||
select(User).where(User.active == True)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
assert len(users) >= 0
|
||||
```
|
||||
|
||||
#### Configuring asyncio Mode
|
||||
|
||||
In `pyproject.toml` or `pytest.ini`, set the default mode to avoid repeating the marker:
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
```
|
||||
|
||||
With `asyncio_mode = "auto"`, any `async def test_*` function is automatically treated as async -- no `@pytest.mark.asyncio` needed.
|
||||
|
||||
---
|
||||
|
||||
### 5. Test Organization
|
||||
|
||||
#### conftest.py Hierarchy
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # Session/global fixtures (db connection, app client)
|
||||
├── unit/
|
||||
│ ├── conftest.py # Unit-specific fixtures (mocked services)
|
||||
│ ├── test_models.py
|
||||
│ └── test_utils.py
|
||||
├── integration/
|
||||
│ ├── conftest.py # Integration fixtures (real db session, test server)
|
||||
│ ├── test_api.py
|
||||
│ └── test_repositories.py
|
||||
└── e2e/
|
||||
├── conftest.py # E2E fixtures (browser, full app)
|
||||
└── test_workflows.py
|
||||
```
|
||||
|
||||
Fixtures in a `conftest.py` are available to all tests in the same directory and below. No imports needed.
|
||||
|
||||
#### Test Discovery
|
||||
|
||||
pytest discovers tests by default based on these rules:
|
||||
- Files matching `test_*.py` or `*_test.py`
|
||||
- Classes prefixed with `Test` (no `__init__` method)
|
||||
- Functions prefixed with `test_`
|
||||
|
||||
Configure custom discovery in `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
```
|
||||
|
||||
#### Markers
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
# Built-in markers
|
||||
@pytest.mark.skip(reason="Not implemented yet")
|
||||
def test_future_feature():
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="Unix-only functionality"
|
||||
)
|
||||
def test_unix_permissions():
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Known bug #1234, fix pending")
|
||||
def test_known_broken():
|
||||
result = buggy_function()
|
||||
assert result == "expected"
|
||||
```
|
||||
|
||||
#### Custom Markers
|
||||
|
||||
Register markers in `pyproject.toml` to avoid warnings:
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"integration: marks integration tests requiring external services",
|
||||
"smoke: critical path tests for quick validation",
|
||||
]
|
||||
```
|
||||
|
||||
```python
|
||||
@pytest.mark.slow
|
||||
def test_full_data_migration():
|
||||
migrate_all_records() # Takes 30+ seconds
|
||||
assert count_records() == EXPECTED_TOTAL
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_health_endpoint(client):
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
Run selectively:
|
||||
|
||||
```bash
|
||||
pytest -m "smoke" # Only smoke tests
|
||||
pytest -m "not slow" # Skip slow tests
|
||||
pytest -m "integration and not slow" # Integration but not slow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Coverage
|
||||
|
||||
#### Basic Usage
|
||||
|
||||
```bash
|
||||
pytest --cov=src --cov-report=term-missing
|
||||
pytest --cov=src --cov-report=html # Generates htmlcov/
|
||||
pytest --cov=src --cov-branch # Enable branch coverage
|
||||
```
|
||||
|
||||
#### Configuration in pyproject.toml
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=80"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src"]
|
||||
branch = true
|
||||
omit = [
|
||||
"*/migrations/*",
|
||||
"*/tests/*",
|
||||
"*/__pycache__/*",
|
||||
"*/conftest.py",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if TYPE_CHECKING:",
|
||||
"raise NotImplementedError",
|
||||
"@overload",
|
||||
]
|
||||
fail_under = 80
|
||||
show_missing = true
|
||||
```
|
||||
|
||||
#### .coveragerc Alternative
|
||||
|
||||
If not using `pyproject.toml`, create `.coveragerc`:
|
||||
|
||||
```ini
|
||||
[run]
|
||||
source = src
|
||||
branch = true
|
||||
|
||||
[report]
|
||||
fail_under = 80
|
||||
show_missing = true
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
if TYPE_CHECKING:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Assertions
|
||||
|
||||
#### pytest.raises for Exceptions
|
||||
|
||||
```python
|
||||
def test_raises_value_error():
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_age("not-a-number")
|
||||
assert "invalid literal" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_raises_with_match():
|
||||
with pytest.raises(PermissionError, match=r"User .+ lacks role 'admin'"):
|
||||
authorize(user=viewer, required_role="admin")
|
||||
```
|
||||
|
||||
#### pytest.approx for Floating Point
|
||||
|
||||
```python
|
||||
def test_circle_area():
|
||||
assert calculate_area(radius=5) == pytest.approx(78.5398, rel=1e-4)
|
||||
|
||||
|
||||
def test_approx_list():
|
||||
result = distribute_evenly(total=100, buckets=3)
|
||||
assert result == pytest.approx([33.33, 33.33, 33.34], abs=0.01)
|
||||
```
|
||||
|
||||
#### Custom Assertion Helpers
|
||||
|
||||
Build reusable assertion logic for domain-specific validation.
|
||||
|
||||
```python
|
||||
def assert_valid_api_response(response, expected_status=200):
|
||||
"""Reusable assertion for API responses."""
|
||||
assert response.status_code == expected_status, (
|
||||
f"Expected {expected_status}, got {response.status_code}: "
|
||||
f"{response.text}"
|
||||
)
|
||||
data = response.json()
|
||||
assert "error" not in data, f"Unexpected error: {data['error']}"
|
||||
return data
|
||||
|
||||
|
||||
def test_create_user(client):
|
||||
response = client.post("/users", json={"name": "Alice"})
|
||||
data = assert_valid_api_response(response, expected_status=201)
|
||||
assert data["name"] == "Alice"
|
||||
assert "id" in data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Name tests descriptively** -- Use `test_[function]_[scenario]_[expected]` so failures are self-explanatory without reading the test body. `test_parse_date_invalid_format_raises_valueerror` tells you everything.
|
||||
|
||||
2. **Keep tests independent** -- Never rely on test execution order. Each test should set up its own state via fixtures and tear it down afterward. Shared mutable state between tests is the top cause of flaky suites.
|
||||
|
||||
3. **One assertion focus per test** -- A test can have multiple `assert` statements, but they should all verify the same behavior. If you need to check two independent behaviors, write two tests.
|
||||
|
||||
4. **Use fixtures over setup methods** -- Prefer composable fixtures over `setUp`/`tearDown` methods or `setup_function`. Fixtures are explicit about dependencies, reusable across files via `conftest.py`, and support scoping.
|
||||
|
||||
5. **Mock at the boundary, not in the middle** -- Mock external services, databases, and network calls. Do not mock internal functions unless they are truly expensive. Over-mocking produces tests that pass but verify nothing.
|
||||
|
||||
6. **Use `tmp_path` for file operations** -- pytest's built-in `tmp_path` fixture provides a unique temporary directory per test. Never write to the real filesystem in tests.
|
||||
|
||||
7. **Pin randomness and time** -- When testing code that depends on randomness or the current time, use `random.seed()` or a time-freezing library to make tests deterministic.
|
||||
|
||||
8. **Run the full suite in CI with branch coverage** -- Local development can use `pytest -x` for fast feedback (stop on first failure), but CI must run the full suite with `--cov-branch` to catch untested branches and regressions.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Shared mutable fixtures** -- A module-scoped fixture returning a mutable object (list, dict, instance) gets modified by one test and breaks another. Return fresh copies or use function scope for mutable data.
|
||||
|
||||
2. **Patching the wrong import path** -- `@patch("myapp.services.requests.get")` patches where `requests.get` is looked up, not where it is defined. If `services.py` does `from requests import get`, you must patch `myapp.services.get`, not `requests.get`.
|
||||
|
||||
3. **Forgetting to await in async tests** -- Omitting `await` makes the test pass vacuously because it never actually runs the coroutine. Always `await` the function under test and use `@pytest.mark.asyncio`.
|
||||
|
||||
4. **Tests that depend on execution order** -- If test B relies on side effects from test A, parallel test execution (pytest-xdist) and `--randomly` will expose the coupling immediately. Fix by making each test self-contained.
|
||||
|
||||
5. **Asserting on mock call count without checking arguments** -- `mock.assert_called_once()` confirms the call count but not what was passed. Use `assert_called_once_with(...)` or inspect `mock.call_args` to verify the actual arguments.
|
||||
|
||||
6. **Ignoring warnings as errors** -- Configure `filterwarnings = ["error"]` in `pyproject.toml` to catch deprecation warnings early. A passing test suite that emits 50 deprecation warnings is a time bomb.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `vitest` -- JavaScript/TypeScript testing counterpart
|
||||
- `python` -- Python language patterns and idioms
|
||||
- `test-driven-development` -- TDD workflow for writing tests first
|
||||
- `github-actions` — Running pytest in CI/CD pipelines
|
||||
@@ -0,0 +1,842 @@
|
||||
# Testing — Vitest Patterns
|
||||
|
||||
|
||||
# Vitest
|
||||
|
||||
## When to Use
|
||||
|
||||
- Testing JavaScript/TypeScript
|
||||
- React component testing
|
||||
- Unit and integration tests
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Python testing -- use the `pytest` skill instead
|
||||
- Projects that explicitly mandate Jest-only by convention with no Vitest dependency
|
||||
- Non-JavaScript/TypeScript projects
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Test Structure
|
||||
|
||||
#### describe / it / expect
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatCurrency } from './format';
|
||||
|
||||
describe('formatCurrency', () => {
|
||||
it('should format whole dollars', () => {
|
||||
expect(formatCurrency(100)).toBe('$100.00');
|
||||
});
|
||||
|
||||
it('should format cents correctly', () => {
|
||||
expect(formatCurrency(9.5)).toBe('$9.50');
|
||||
});
|
||||
|
||||
it('should handle zero', () => {
|
||||
expect(formatCurrency(0)).toBe('$0.00');
|
||||
});
|
||||
|
||||
it('should throw on negative values', () => {
|
||||
expect(() => formatCurrency(-5)).toThrow('Amount must be non-negative');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Lifecycle Hooks
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||
import { Database } from './database';
|
||||
|
||||
describe('UserRepository', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Runs once before all tests in this describe block
|
||||
db = await Database.connect('test://localhost/testdb');
|
||||
await db.migrate();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Runs before each test
|
||||
await db.seed({ users: [{ id: 1, name: 'Alice' }] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.truncate('users');
|
||||
});
|
||||
|
||||
it('should find user by id', async () => {
|
||||
const user = await db.users.findById(1);
|
||||
expect(user).toEqual({ id: 1, name: 'Alice' });
|
||||
});
|
||||
|
||||
it('should return null for missing user', async () => {
|
||||
const user = await db.users.findById(999);
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### test.each for Parametrized Tests
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, test } from 'vitest';
|
||||
import { validateEmail } from './validators';
|
||||
|
||||
describe('validateEmail', () => {
|
||||
test.each([
|
||||
{ email: 'user@example.com', expected: true },
|
||||
{ email: 'admin@test.org', expected: true },
|
||||
{ email: 'name+tag@domain.co.uk', expected: true },
|
||||
])('should accept valid email: $email', ({ email, expected }) => {
|
||||
expect(validateEmail(email)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ email: '', reason: 'empty string' },
|
||||
{ email: 'no-at-sign', reason: 'missing @' },
|
||||
{ email: '@no-local.com', reason: 'missing local part' },
|
||||
{ email: 'spaces in@email.com', reason: 'contains spaces' },
|
||||
])('should reject invalid email ($reason): $email', ({ email }) => {
|
||||
expect(validateEmail(email)).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Nested describe Blocks
|
||||
|
||||
```typescript
|
||||
describe('ShoppingCart', () => {
|
||||
describe('when empty', () => {
|
||||
it('should have zero total', () => {
|
||||
const cart = new ShoppingCart();
|
||||
expect(cart.total()).toBe(0);
|
||||
});
|
||||
|
||||
it('should have zero item count', () => {
|
||||
const cart = new ShoppingCart();
|
||||
expect(cart.itemCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with items', () => {
|
||||
let cart: ShoppingCart;
|
||||
|
||||
beforeEach(() => {
|
||||
cart = new ShoppingCart();
|
||||
cart.add({ name: 'Widget', price: 9.99, quantity: 2 });
|
||||
cart.add({ name: 'Gadget', price: 24.99, quantity: 1 });
|
||||
});
|
||||
|
||||
it('should calculate total', () => {
|
||||
expect(cart.total()).toBeCloseTo(44.97);
|
||||
});
|
||||
|
||||
it('should count all items', () => {
|
||||
expect(cart.itemCount()).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Mocking
|
||||
|
||||
#### vi.mock for Module Mocking
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { sendWelcomeEmail } from './onboarding';
|
||||
|
||||
// Mock the entire email module -- hoisted to the top of the file automatically
|
||||
vi.mock('./email', () => ({
|
||||
sendEmail: vi.fn().mockResolvedValue({ messageId: 'msg-123' }),
|
||||
}));
|
||||
|
||||
// Import AFTER vi.mock declaration
|
||||
import { sendEmail } from './email';
|
||||
|
||||
describe('sendWelcomeEmail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should send email with welcome template', async () => {
|
||||
await sendWelcomeEmail('alice@example.com');
|
||||
|
||||
expect(sendEmail).toHaveBeenCalledWith({
|
||||
to: 'alice@example.com',
|
||||
template: 'welcome',
|
||||
subject: 'Welcome to our platform!',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the message id', async () => {
|
||||
const result = await sendWelcomeEmail('alice@example.com');
|
||||
expect(result.messageId).toBe('msg-123');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### vi.fn for Function Spies
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('EventEmitter', () => {
|
||||
it('should call listener on emit', () => {
|
||||
const emitter = new EventEmitter();
|
||||
const listener = vi.fn();
|
||||
|
||||
emitter.on('click', listener);
|
||||
emitter.emit('click', { x: 10, y: 20 });
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith({ x: 10, y: 20 });
|
||||
});
|
||||
|
||||
it('should track multiple calls', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
callback('first');
|
||||
callback('second');
|
||||
callback('third');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
expect(callback.mock.calls).toEqual([['first'], ['second'], ['third']]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### vi.spyOn
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import * as mathUtils from './math-utils';
|
||||
|
||||
describe('calculateTax', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should use the tax rate function', () => {
|
||||
const spy = vi.spyOn(mathUtils, 'getTaxRate').mockReturnValue(0.08);
|
||||
|
||||
const result = calculateTax(100);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith();
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
|
||||
it('should spy without changing behavior', () => {
|
||||
const spy = vi.spyOn(console, 'warn');
|
||||
|
||||
triggerDeprecationWarning();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('deprecated')
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### mockResolvedValue / mockRejectedValue
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('UserService', () => {
|
||||
it('should return user on successful fetch', async () => {
|
||||
const fetchUser = vi.fn().mockResolvedValue({ id: 1, name: 'Alice' });
|
||||
|
||||
const user = await fetchUser(1);
|
||||
expect(user).toEqual({ id: 1, name: 'Alice' });
|
||||
});
|
||||
|
||||
it('should throw on failed fetch', async () => {
|
||||
const fetchUser = vi.fn().mockRejectedValue(new Error('User not found'));
|
||||
|
||||
await expect(fetchUser(999)).rejects.toThrow('User not found');
|
||||
});
|
||||
|
||||
it('should return different values on successive calls', async () => {
|
||||
const getToken = vi.fn()
|
||||
.mockResolvedValueOnce('token-1')
|
||||
.mockResolvedValueOnce('token-2')
|
||||
.mockRejectedValueOnce(new Error('Expired'));
|
||||
|
||||
expect(await getToken()).toBe('token-1');
|
||||
expect(await getToken()).toBe('token-2');
|
||||
await expect(getToken()).rejects.toThrow('Expired');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### MSW (Mock Service Worker) for API Mocking
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { fetchUsers } from './api-client';
|
||||
|
||||
const server = setupServer(
|
||||
http.get('https://api.example.com/users', () => {
|
||||
return HttpResponse.json([
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
]);
|
||||
}),
|
||||
|
||||
http.post('https://api.example.com/users', async ({ request }) => {
|
||||
const body = await request.json() as { name: string };
|
||||
return HttpResponse.json(
|
||||
{ id: 3, name: body.name },
|
||||
{ status: 201 }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('API Client', () => {
|
||||
it('should fetch users', async () => {
|
||||
const users = await fetchUsers();
|
||||
expect(users).toHaveLength(2);
|
||||
expect(users[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should handle server errors', async () => {
|
||||
server.use(
|
||||
http.get('https://api.example.com/users', () => {
|
||||
return HttpResponse.json(
|
||||
{ message: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
await expect(fetchUsers()).rejects.toThrow('Server error');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. React Testing
|
||||
|
||||
#### Render and Query
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Greeting } from './Greeting';
|
||||
|
||||
describe('Greeting', () => {
|
||||
it('should display the user name', () => {
|
||||
render(<Greeting name="Alice" />);
|
||||
|
||||
// getBy* throws if not found -- use for elements that must exist
|
||||
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display admin badge for regular users', () => {
|
||||
render(<Greeting name="Alice" role="viewer" />);
|
||||
|
||||
// queryBy* returns null if not found -- use for asserting absence
|
||||
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display admin badge for admins', () => {
|
||||
render(<Greeting name="Alice" role="admin" />);
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### userEvent for Interactions
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LoginForm } from './LoginForm';
|
||||
|
||||
describe('LoginForm', () => {
|
||||
it('should submit credentials', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<LoginForm onSubmit={onSubmit} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
|
||||
await user.type(screen.getByLabelText('Password'), 'secret123');
|
||||
await user.click(screen.getByRole('button', { name: 'Sign In' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
email: 'alice@example.com',
|
||||
password: 'secret123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show validation error on empty submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Sign In' }));
|
||||
|
||||
expect(screen.getByText('Email is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle password visibility', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={vi.fn()} />);
|
||||
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show password' }));
|
||||
expect(passwordInput).toHaveAttribute('type', 'text');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### findBy for Async Rendering and waitFor
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UserProfile } from './UserProfile';
|
||||
|
||||
describe('UserProfile', () => {
|
||||
it('should load and display user data', async () => {
|
||||
render(<UserProfile userId={1} />);
|
||||
|
||||
// findBy* waits for the element to appear (async query)
|
||||
const heading = await screen.findByRole('heading', { name: 'Alice' });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading state initially', () => {
|
||||
render(<UserProfile userId={1} />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update after action', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserProfile userId={1} />);
|
||||
|
||||
await screen.findByRole('heading', { name: 'Alice' });
|
||||
await user.click(screen.getByRole('button', { name: 'Deactivate' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Status: Inactive')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Testing with Context Providers
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from './ThemeContext';
|
||||
import { ThemedButton } from './ThemedButton';
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, options?: { theme?: 'light' | 'dark' }) {
|
||||
const theme = options?.theme ?? 'light';
|
||||
return render(
|
||||
<ThemeProvider value={theme}>
|
||||
{ui}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemedButton', () => {
|
||||
it('should apply light theme styles', () => {
|
||||
renderWithProviders(<ThemedButton>Click me</ThemedButton>, { theme: 'light' });
|
||||
expect(screen.getByRole('button')).toHaveClass('btn-light');
|
||||
});
|
||||
|
||||
it('should apply dark theme styles', () => {
|
||||
renderWithProviders(<ThemedButton>Click me</ThemedButton>, { theme: 'dark' });
|
||||
expect(screen.getByRole('button')).toHaveClass('btn-dark');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Async Testing
|
||||
|
||||
#### Promises and async/await
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fetchUser, processQueue } from './services';
|
||||
|
||||
describe('async operations', () => {
|
||||
it('should resolve with user data', async () => {
|
||||
const user = await fetchUser(1);
|
||||
expect(user).toEqual({ id: 1, name: 'Alice' });
|
||||
});
|
||||
|
||||
it('should reject with descriptive error', async () => {
|
||||
await expect(fetchUser(-1)).rejects.toThrow('Invalid user ID');
|
||||
});
|
||||
|
||||
it('should process all items in queue', async () => {
|
||||
const results = await processQueue(['a', 'b', 'c']);
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.every((r) => r.status === 'done')).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Fake Timers
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { debounce } from './debounce';
|
||||
|
||||
describe('debounce', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not call function before delay', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 300);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call function after delay', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 300);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should reset timer on subsequent calls', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 300);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(200);
|
||||
debounced(); // reset
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Fake Timers with Date
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { isExpired } from './token';
|
||||
|
||||
describe('isExpired', () => {
|
||||
it('should detect expired tokens', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
|
||||
|
||||
const token = { expiresAt: '2025-06-15T11:00:00Z' };
|
||||
expect(isExpired(token)).toBe(true);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Snapshot Testing
|
||||
|
||||
#### toMatchSnapshot
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
describe('Badge', () => {
|
||||
it('should match snapshot for success variant', () => {
|
||||
const { container } = render(<Badge variant="success">Active</Badge>);
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### toMatchInlineSnapshot
|
||||
|
||||
Inline snapshots embed the expected value directly in the test file. Vitest updates them automatically on first run.
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatError } from './errors';
|
||||
|
||||
describe('formatError', () => {
|
||||
it('should format validation error', () => {
|
||||
const error = formatError({ field: 'email', rule: 'required' });
|
||||
|
||||
expect(error).toMatchInlineSnapshot(`
|
||||
{
|
||||
"code": "VALIDATION_ERROR",
|
||||
"field": "email",
|
||||
"message": "email is required",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### When to Use Snapshots (and When Not To)
|
||||
|
||||
**Use snapshots for:**
|
||||
- Serialized output that is tedious to write by hand (large objects, rendered markup)
|
||||
- Catching unintended changes in generated output
|
||||
- Error message formatting
|
||||
|
||||
**Do not use snapshots for:**
|
||||
- Business logic assertions -- write explicit `expect(value).toBe(expected)` instead
|
||||
- Frequently changing output -- snapshot churn leads to mindless updates
|
||||
- Large component trees -- a small change deep in the tree makes the diff unreadable; test specific elements instead
|
||||
|
||||
---
|
||||
|
||||
### 6. Coverage
|
||||
|
||||
#### vitest.config.ts Coverage Settings
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8', // or 'istanbul'
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/**/*.d.ts',
|
||||
'src/**/index.ts', // barrel files
|
||||
'src/test-utils/**',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Running Coverage
|
||||
|
||||
```bash
|
||||
vitest run --coverage # Run once with coverage
|
||||
vitest --coverage # Watch mode with coverage
|
||||
vitest run --coverage.provider=v8 # Override provider via CLI
|
||||
```
|
||||
|
||||
#### Per-File Thresholds
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
thresholds: {
|
||||
// Global thresholds
|
||||
statements: 80,
|
||||
// Per-glob overrides for critical paths
|
||||
'src/auth/**': {
|
||||
statements: 95,
|
||||
branches: 95,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Setup and Configuration
|
||||
|
||||
#### vitest.config.ts Basics
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true, // Use describe/it/expect without imports
|
||||
environment: 'jsdom', // DOM environment for React (or 'happy-dom')
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist', 'e2e'],
|
||||
testTimeout: 10_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Setup File
|
||||
|
||||
```typescript
|
||||
// src/test-setup.ts
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
// Automatic cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
```
|
||||
|
||||
#### Workspace Configuration
|
||||
|
||||
For monorepos with multiple packages:
|
||||
|
||||
```typescript
|
||||
// vitest.workspace.ts
|
||||
import { defineWorkspace } from 'vitest/config';
|
||||
|
||||
export default defineWorkspace([
|
||||
{
|
||||
extends: './vitest.config.ts',
|
||||
test: {
|
||||
name: 'ui',
|
||||
include: ['packages/ui/**/*.test.{ts,tsx}'],
|
||||
environment: 'jsdom',
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: './vitest.config.ts',
|
||||
test: {
|
||||
name: 'api',
|
||||
include: ['packages/api/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
#### Environment Per File
|
||||
|
||||
Use a magic comment at the top of a test file to override the environment:
|
||||
|
||||
```typescript
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('DOM-heavy tests', () => {
|
||||
it('should create elements', () => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'Hello';
|
||||
expect(div.textContent).toBe('Hello');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Globals Mode
|
||||
|
||||
When `globals: true` is set in config, you do not need to import `describe`, `it`, `expect`, `vi`, etc. Add the types to `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `userEvent` over `fireEvent`** -- `userEvent` simulates real user behavior (focus, keystrokes, blur) while `fireEvent` dispatches raw DOM events. `userEvent` catches bugs that `fireEvent` misses, such as disabled buttons still receiving clicks.
|
||||
|
||||
2. **Query by role and label, not test IDs** -- Prefer `getByRole('button', { name: 'Submit' })` and `getByLabelText('Email')` over `getByTestId('submit-btn')`. Accessible queries validate your markup and are resilient to refactors.
|
||||
|
||||
3. **Clear mocks between tests** -- Call `vi.clearAllMocks()` in `beforeEach` or `vi.restoreAllMocks()` in `afterEach`. Leaked mock state between tests causes order-dependent failures that are painful to debug.
|
||||
|
||||
4. **Keep tests focused on one behavior** -- Each `it` block should test a single user-observable behavior. If your test description contains "and", split it into two tests.
|
||||
|
||||
5. **Avoid testing implementation details** -- Do not assert on component state, internal method calls, or private variables. Test what the user sees and what the component outputs. Implementation tests break on every refactor without catching real bugs.
|
||||
|
||||
6. **Use MSW for network mocking over vi.mock on fetch** -- MSW intercepts at the network level, so your tests exercise the actual fetch/axios code paths. Mocking `fetch` directly skips serialization, headers, and error handling logic.
|
||||
|
||||
7. **Colocate tests with source files** -- Place `Button.test.tsx` next to `Button.tsx`. This makes it obvious which files have tests and simplifies imports. Reserve a top-level `e2e/` folder only for end-to-end tests.
|
||||
|
||||
8. **Run tests in watch mode during development** -- `vitest` (no flags) starts in watch mode and re-runs only affected tests on file change. Use `vitest run` in CI for a single full run with exit code.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Forgetting to await userEvent calls** -- Every `userEvent` method is async. Omitting `await` causes the assertion to run before the interaction completes, leading to false passes or intermittent failures.
|
||||
|
||||
2. **vi.mock hoisting confusion** -- `vi.mock()` calls are hoisted to the top of the file. If you define a mock implementation that references a variable declared below the `vi.mock` call, it will be `undefined`. Use `vi.mock` with a factory function or move the variable above.
|
||||
|
||||
3. **Not cleaning up after fake timers** -- Forgetting `vi.useRealTimers()` in `afterEach` causes subsequent tests to silently use fake timers, producing mysterious timeouts and passing tests that should fail.
|
||||
|
||||
4. **Using `getBy` queries for elements that may not exist** -- `getByText('Error')` throws immediately if the element is absent. When asserting that something is NOT rendered, use `queryByText('Error')` which returns `null`.
|
||||
|
||||
5. **Snapshot overuse** -- Developers update snapshots without reviewing the diff. Over time, snapshots become rubber stamps. Limit snapshots to serialized output and error formatting; use explicit assertions for behavior.
|
||||
|
||||
6. **Testing third-party library internals** -- Do not test that React Router navigates correctly or that Zustand updates state. Test that your component renders the right thing after navigation or state change. Trust library authors; test your code.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `pytest` -- Python testing counterpart
|
||||
- `typescript` -- TypeScript language patterns and strict typing
|
||||
- `react` -- React component patterns for component testing
|
||||
- `test-driven-development` -- TDD workflow for writing tests first
|
||||
- `github-actions` — Running vitest in CI/CD pipelines
|
||||
Reference in New Issue
Block a user