feat: adding new skills, including testing patterns and methodologies, along with bundled resources for better usability.

This commit is contained in:
duthaho
2026-03-30 12:18:00 +07:00
parent 0ff5ae4082
commit 7fa9a48c6c
89 changed files with 25808 additions and 923 deletions
+649 -50
View File
@@ -1,90 +1,689 @@
---
name: pytest
description: >
Trigger this skill whenever writing, debugging, or refactoring Python tests, or when pytest fixtures, parametrization, mocking, or coverage are mentioned. Activate for any .py test file, test_* function, conftest.py, pytest.ini, or pyproject.toml [tool.pytest] reference. Also use when the user asks about Python test patterns, test organization, or test-driven development in a Python context.
---
# pytest
## Description
Python testing with pytest including fixtures, parametrization, and mocking.
## When to Use
- Writing Python tests
- Test fixtures and setup
- Mocking dependencies
## When NOT to Use
- JavaScript or TypeScript testing -- use the `testing/vitest` skill instead
- Projects that explicitly mandate unittest-only by convention with no pytest dependency
- Non-Python test files or environments
---
## Core Patterns
### Basic Tests
### 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
def test_addition():
assert 1 + 1 == 2
def test_exception():
with pytest.raises(ValueError, match="Invalid"):
raise ValueError("Invalid input")
```
### Fixtures
```python
@pytest.fixture
def user():
return User(id=1, name="Test")
"""Fresh user instance per test."""
return User(id=1, name="Alice", email="alice@example.com")
@pytest.fixture
def db_session():
session = create_session()
yield session
session.close()
def test_with_fixtures(user, db_session):
db_session.add(user)
assert user.id is not None
def test_user_display_name(user):
assert user.display_name() == "Alice"
def test_user_email_domain(user):
assert user.email_domain() == "example.com"
```
### Parametrization
#### Class and Module Scope
Use broader scopes for expensive resources that are safe to share.
```python
@pytest.mark.parametrize("input,expected", [
@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, expected):
assert input.upper() == expected
def test_uppercase(input_text, expected):
assert input_text.upper() == expected
```
### Mocking
#### Custom IDs for Readable Output
```python
from unittest.mock import Mock, patch
def test_with_mock():
service = Mock()
service.get_user.return_value = {"id": 1}
result = service.get_user(1)
assert result["id"] == 1
@patch('module.external_api')
def test_with_patch(mock_api):
mock_api.fetch.return_value = {"data": []}
# Test code that uses external_api
@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. Use fixtures for test setup
2. Parametrize for multiple test cases
3. Mock external dependencies
4. Use descriptive test names
5. Keep tests independent
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
- **Shared state**: Use fresh fixtures
- **Over-mocking**: Only mock boundaries
- **Slow tests**: Use markers for slow tests
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
- `testing/vitest` -- JavaScript/TypeScript testing counterpart
- `languages/python` -- Python language patterns and idioms
- `methodology/test-driven-development` -- TDD workflow for writing tests first
- `devops/github-actions` — Running pytest in CI/CD pipelines
@@ -0,0 +1,248 @@
# pytest Fixture Patterns
Catalog of reusable fixture patterns for common testing scenarios.
## 1. Factory Fixture
Create multiple instances with customizable defaults.
```python
import pytest
from dataclasses import dataclass
@pytest.fixture
def make_user():
"""Factory fixture: creates User instances with sensible defaults."""
created = []
def _make_user(
name: str = "Test User",
email: str | None = None,
is_active: bool = True,
):
if email is None:
email = f"user-{len(created)}@test.com"
user = User(name=name, email=email, is_active=is_active)
created.append(user)
return user
yield _make_user
# Cleanup: delete all created users
for user in created:
user.delete()
```
Usage:
```python
def test_deactivate_user(make_user):
user = make_user(name="Alice", is_active=True)
user.deactivate()
assert not user.is_active
```
## 2. Database Session (SQLAlchemy)
Transaction-isolated database session that rolls back after each test.
```python
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="session")
def engine():
"""Create a test database engine (once per test session)."""
engine = create_engine("postgresql://test:test@localhost:5432/test_db")
yield engine
engine.dispose()
@pytest.fixture(scope="session")
def tables(engine):
"""Create all tables once, drop after all tests."""
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
@pytest.fixture
def db_session(engine, tables):
"""Provide a transactional database session that rolls back after each test."""
connection = engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection)()
yield session
session.close()
transaction.rollback()
connection.close()
```
## 3. Temporary Files and Directories
```python
@pytest.fixture
def sample_config(tmp_path: Path) -> Path:
"""Create a temporary config file with test content."""
config = tmp_path / "config.yaml"
config.write_text(
"""\
database:
host: localhost
port: 5432
debug: true
"""
)
return config
@pytest.fixture
def data_dir(tmp_path: Path) -> Path:
"""Create a temporary directory structure for testing."""
(tmp_path / "input").mkdir()
(tmp_path / "output").mkdir()
(tmp_path / "input" / "data.csv").write_text("id,name\n1,Alice\n2,Bob\n")
return tmp_path
```
## 4. Mock External Service
```python
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
@pytest.fixture
def mock_http_client():
"""Mock an HTTP client with pre-configured responses."""
client = MagicMock()
client.get.return_value = MagicMock(
status_code=200,
json=lambda: {"status": "ok"},
)
client.post.return_value = MagicMock(
status_code=201,
json=lambda: {"id": "new-123"},
)
return client
@pytest.fixture
def mock_payment_gateway():
"""Mock a payment gateway service."""
with patch("app.services.payment.PaymentGateway") as mock_cls:
instance = mock_cls.return_value
instance.charge.return_value = {
"transaction_id": "txn-test-123",
"status": "succeeded",
}
instance.refund.return_value = {
"refund_id": "ref-test-456",
"status": "refunded",
}
yield instance
# Async version
@pytest.fixture
def mock_email_service():
"""Mock an async email service."""
with patch("app.services.email.EmailService") as mock_cls:
instance = mock_cls.return_value
instance.send = AsyncMock(return_value={"message_id": "msg-test-789"})
yield instance
```
## 5. Authenticated Test Client (FastAPI)
```python
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.auth import create_access_token
@pytest.fixture
def auth_token():
"""Generate a valid JWT token for testing."""
return create_access_token(
data={"sub": "test-user-id", "role": "admin"},
expires_minutes=60,
)
@pytest.fixture
def auth_headers(auth_token: str) -> dict[str, str]:
"""HTTP headers with Bearer token."""
return {"Authorization": f"Bearer {auth_token}"}
@pytest.fixture
async def client():
"""Unauthenticated async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
async def auth_client(auth_headers):
"""Authenticated async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers=auth_headers,
) as c:
yield c
```
## 6. Environment Variables
```python
@pytest.fixture
def env_vars(monkeypatch):
"""Set environment variables for the test, automatically restored after."""
monkeypatch.setenv("DATABASE_URL", "postgresql://test:test@localhost/test")
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.delenv("PRODUCTION_API_KEY", raising=False)
```
## 7. Freezing Time
```python
@pytest.fixture
def frozen_time():
"""Freeze time to a specific moment."""
fixed = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
with patch("app.services.datetime") as mock_dt:
mock_dt.now.return_value = fixed
mock_dt.utcnow.return_value = fixed
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
yield fixed
```
Alternative: use `freezegun` library with `@freeze_time("2025-01-15 12:00:00")`.
## 8. Parametrized Fixture
```python
@pytest.fixture(params=["sqlite", "postgresql"])
def db_url(request):
"""Run tests against multiple database backends."""
urls = {
"sqlite": "sqlite:///test.db",
"postgresql": "postgresql://test:test@localhost/test",
}
return urls[request.param]
```
## Fixture Scope Reference
| Scope | Lifetime | Use For |
|-------|----------|---------|
| `function` (default) | Each test | Most fixtures, mutable state |
| `class` | Each test class | Shared setup for a class |
| `module` | Each test file | Expensive setup shared across file |
| `session` | Entire test run | Database engine, heavy resources |
## Tips
- Use `yield` (not `return`) when cleanup is needed after the test.
- Use `autouse=True` sparingly -- only for things every test needs.
- Keep fixtures small and composable -- combine them in tests, not in other fixtures.
- Use `monkeypatch` instead of `unittest.mock.patch` for env vars and attributes when possible.
- Name fixtures after what they provide, not what they do: `db_session` not `setup_database`.
@@ -0,0 +1,197 @@
"""
Starter conftest.py -- common fixtures for pytest.
Usage:
Place this file at the root of your tests/ directory.
pytest automatically discovers conftest.py and makes its fixtures
available to all tests in the same directory and below.
"""
import os
from collections.abc import AsyncGenerator, Generator
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# If using FastAPI + httpx:
# from httpx import ASGITransport, AsyncClient
# from app.main import app
# If using SQLAlchemy:
# from sqlalchemy import create_engine
# from sqlalchemy.orm import Session, sessionmaker
# from app.models import Base
# ---------------------------------------------------------------------------
# ==========================================================================
# Environment Variables
# ==========================================================================
@pytest.fixture(autouse=True)
def _test_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Set safe default environment variables for all tests.
autouse=True ensures this runs for every test automatically.
monkeypatch restores original values after each test.
"""
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("DEBUG", "true")
monkeypatch.setenv("SECRET_KEY", "test-secret-not-for-production")
monkeypatch.setenv("DATABASE_URL", "sqlite:///test.db")
# Remove any production secrets that should never leak into tests.
monkeypatch.delenv("PRODUCTION_API_KEY", raising=False)
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
# ==========================================================================
# Temporary Directory
# ==========================================================================
@pytest.fixture
def data_dir(tmp_path: Path) -> Path:
"""Provide a temporary directory with input/output subdirectories."""
(tmp_path / "input").mkdir()
(tmp_path / "output").mkdir()
return tmp_path
@pytest.fixture
def sample_file(tmp_path: Path) -> Path:
"""Create a sample text file for testing file operations."""
f = tmp_path / "sample.txt"
f.write_text("line 1\nline 2\nline 3\n")
return f
# ==========================================================================
# Database Session (SQLAlchemy)
# ==========================================================================
# Uncomment this section if using SQLAlchemy.
# TEST_DATABASE_URL = os.getenv(
# "TEST_DATABASE_URL", "postgresql://test:test@localhost:5432/test_db"
# )
#
#
# @pytest.fixture(scope="session")
# def engine():
# """Create database engine for the entire test session."""
# eng = create_engine(TEST_DATABASE_URL)
# yield eng
# eng.dispose()
#
#
# @pytest.fixture(scope="session")
# def tables(engine):
# """Create tables at start of session, drop at end."""
# Base.metadata.create_all(engine)
# yield
# Base.metadata.drop_all(engine)
#
#
# @pytest.fixture
# def db_session(engine, tables) -> Generator[Session, None, None]:
# """Transactional database session -- rolls back after each test."""
# connection = engine.connect()
# transaction = connection.begin()
# session = sessionmaker(bind=connection)()
#
# yield session
#
# session.close()
# transaction.rollback()
# connection.close()
# ==========================================================================
# HTTP Test Client (FastAPI)
# ==========================================================================
# Uncomment this section if using FastAPI.
# @pytest.fixture
# async def client() -> AsyncGenerator[AsyncClient, None]:
# """Async HTTP client for testing API endpoints."""
# transport = ASGITransport(app=app)
# async with AsyncClient(transport=transport, base_url="http://test") as c:
# yield c
#
#
# @pytest.fixture
# def auth_headers() -> dict[str, str]:
# """Authorization headers with a test JWT token."""
# from app.auth import create_access_token
# token = create_access_token(data={"sub": "test-user", "role": "admin"})
# return {"Authorization": f"Bearer {token}"}
#
#
# @pytest.fixture
# async def auth_client(auth_headers) -> AsyncGenerator[AsyncClient, None]:
# """Authenticated async HTTP client."""
# transport = ASGITransport(app=app)
# async with AsyncClient(
# transport=transport, base_url="http://test", headers=auth_headers
# ) as c:
# yield c
# ==========================================================================
# Mock External Services
# ==========================================================================
@pytest.fixture
def mock_http_client() -> MagicMock:
"""Generic mock HTTP client with default 200/201 responses."""
client = MagicMock()
client.get.return_value = MagicMock(
status_code=200,
json=lambda: {"status": "ok"},
)
client.post.return_value = MagicMock(
status_code=201,
json=lambda: {"id": "new-123"},
)
return client
# @pytest.fixture
# def mock_email_service():
# """Mock email service to prevent real emails in tests."""
# with patch("app.services.email.send_email") as mock_send:
# mock_send.return_value = {"message_id": "test-msg-001"}
# yield mock_send
# ==========================================================================
# Factory Fixtures
# ==========================================================================
# @pytest.fixture
# def make_user(db_session):
# """Factory fixture: creates User instances with defaults."""
# created = []
#
# def _make_user(
# name: str = "Test User",
# email: str | None = None,
# is_active: bool = True,
# ):
# from app.models import User
# if email is None:
# email = f"user-{len(created)}@test.com"
# user = User(name=name, email=email, is_active=is_active)
# db_session.add(user)
# db_session.flush()
# created.append(user)
# return user
#
# return _make_user
+799 -43
View File
@@ -1,89 +1,845 @@
---
name: vitest
description: >
Trigger this skill whenever writing, debugging, or refactoring JavaScript or TypeScript tests, or when Vitest mocking, coverage, or configuration are mentioned. Activate for any .test.ts, .test.tsx, .test.js, .spec.ts, .spec.js file, vitest.config.ts reference, or React component testing with Testing Library. Also use when the user asks about JS/TS test patterns, test organization, or vi.mock/vi.fn usage.
---
# Vitest
## Description
Modern JavaScript/TypeScript testing with Vitest including mocking and coverage.
## When to Use
- Testing JavaScript/TypeScript
- React component testing
- Unit and integration tests
## When NOT to Use
- Python testing -- use the `testing/pytest` skill instead
- Projects that explicitly mandate Jest-only by convention with no Vitest dependency
- Non-JavaScript/TypeScript projects
---
## Core Patterns
### Basic Tests
### 1. Test Structure
#### describe / it / expect
```typescript
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './format';
describe('math', () => {
it('should add numbers', () => {
expect(1 + 1).toBe(2);
describe('formatCurrency', () => {
it('should format whole dollars', () => {
expect(formatCurrency(100)).toBe('$100.00');
});
it('should throw on invalid input', () => {
expect(() => divide(1, 0)).toThrow('Division by zero');
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');
});
});
```
### Mocking
#### Lifecycle Hooks
```typescript
import { vi, describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import { Database } from './database';
// Mock module
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1 })
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' }),
}));
// Mock function
const callback = vi.fn();
callback('arg');
expect(callback).toHaveBeenCalledWith('arg');
```
// Import AFTER vi.mock declaration
import { sendEmail } from './email';
### Async Tests
describe('sendWelcomeEmail', () => {
beforeEach(() => {
vi.clearAllMocks();
});
```typescript
it('should fetch data', async () => {
const data = await fetchData();
expect(data).toEqual({ id: 1 });
});
it('should send email with welcome template', async () => {
await sendWelcomeEmail('alice@example.com');
it('should reject on error', async () => {
await expect(fetchData()).rejects.toThrow('Error');
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');
});
});
```
### React Testing
#### 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 { userEvent } from '@testing-library/user-event';
import { Greeting } from './Greeting';
it('should handle click', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
describe('Greeting', () => {
it('should display the user name', () => {
render(<Greeting name="Alice" />);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalled();
// 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 describe blocks for grouping
2. Prefer async/await for async tests
3. Use userEvent over fireEvent
4. Mock at module boundaries
5. Clean up after tests
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
- **Not awaiting async**: Always await promises
- **Stale mocks**: Clear mocks between tests
- **Testing implementation**: Test behavior
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
- `testing/pytest` -- Python testing counterpart
- `languages/typescript` -- TypeScript language patterns and strict typing
- `frameworks/react` -- React component patterns for component testing
- `methodology/test-driven-development` -- TDD workflow for writing tests first
- `devops/github-actions` — Running vitest in CI/CD pipelines
@@ -0,0 +1,242 @@
# Vitest Mock Patterns
Catalog of mocking patterns for common testing scenarios.
## 1. Module Mock (Full)
Replace an entire module with mock implementations.
```typescript
import { describe, it, expect, vi } from "vitest";
// Mock the entire module BEFORE importing code that uses it.
vi.mock("@/services/payment", () => ({
chargeCard: vi.fn().mockResolvedValue({
transactionId: "txn-123",
status: "succeeded",
}),
refundCharge: vi.fn().mockResolvedValue({
refundId: "ref-456",
status: "refunded",
}),
}));
import { chargeCard } from "@/services/payment";
import { checkout } from "@/services/checkout";
describe("checkout", () => {
it("should charge the card and return success", async () => {
const result = await checkout({ amount: 42, cardToken: "tok_test" });
expect(chargeCard).toHaveBeenCalledWith({
amount: 42,
token: "tok_test",
});
expect(result.status).toBe("succeeded");
});
});
```
## 2. Partial Module Mock
Mock only specific exports; keep the rest real.
```typescript
import { describe, it, expect, vi } from "vitest";
vi.mock("@/utils/config", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/utils/config")>();
return {
...actual,
// Override only this one export
getFeatureFlag: vi.fn().mockReturnValue(true),
};
});
import { getFeatureFlag, parseConfig } from "@/utils/config";
describe("with feature flag enabled", () => {
it("should use the new algorithm", () => {
// getFeatureFlag is mocked, parseConfig is real
expect(getFeatureFlag("new-algo")).toBe(true);
});
});
```
## 3. Manual Mock Reset / Per-Test Overrides
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fetchUser } from "@/api/users";
vi.mock("@/api/users");
// Type the mock for autocomplete
const mockFetchUser = vi.mocked(fetchUser);
beforeEach(() => {
vi.resetAllMocks(); // Clear call history AND implementations
});
describe("user profile", () => {
it("shows user data on success", async () => {
mockFetchUser.mockResolvedValueOnce({ id: "1", name: "Alice" });
// ...test
});
it("shows error on failure", async () => {
mockFetchUser.mockRejectedValueOnce(new Error("Network error"));
// ...test
});
});
```
## 4. API Mock with MSW (Mock Service Worker)
Best for integration tests that should exercise real fetch/axios code.
```typescript
// test/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({ id: params.id, name: "Alice", email: "alice@example.com" });
}),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new-1", ...body }, { status: 201 });
}),
];
```
```typescript
// test/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
```
```typescript
// test/setup.ts (referenced in vitest.config.ts setupFiles)
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "./mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```
```typescript
// Usage in tests -- override handlers per test
import { http, HttpResponse } from "msw";
import { server } from "../mocks/server";
it("handles server error", async () => {
server.use(
http.get("/api/users/:id", () => {
return HttpResponse.json({ error: "Not found" }, { status: 404 });
}),
);
// ...test error handling
});
```
## 5. Timer Mocks
Control `setTimeout`, `setInterval`, `Date.now`.
```typescript
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("should call the function after the delay", () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledOnce();
});
// Fake date: vi.setSystemTime(new Date("2025-01-15T12:00:00Z"))
```
## 6. Spy Patterns
Observe calls without replacing implementation.
```typescript
import { describe, it, expect, vi } from "vitest";
describe("logging", () => {
it("should log errors to console", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
logError("something went wrong");
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("something went wrong"),
);
consoleSpy.mockRestore();
});
});
// Spy on object method without changing behavior
it("should call save", () => {
const repo = new UserRepository();
const saveSpy = vi.spyOn(repo, "save");
repo.createUser({ name: "Alice" });
expect(saveSpy).toHaveBeenCalledOnce();
expect(saveSpy).toHaveBeenCalledWith(expect.objectContaining({ name: "Alice" }));
});
```
## 7. Global / Window Mocks
```typescript
// Mock window.location
vi.spyOn(window, "location", "get").mockReturnValue({ ...window.location, pathname: "/dashboard" });
// Mock localStorage
const storage: Record<string, string> = {};
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => storage[key] ?? null);
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => { storage[key] = val; });
// Mock fetch (when not using MSW)
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: "test" }) });
```
## 8. Class Mock
```typescript
vi.mock("@/services/analytics", () => ({
AnalyticsClient: vi.fn().mockImplementation(() => ({
track: vi.fn(),
identify: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
})),
}));
```
## Quick Reference: Mock Functions
| Method | Purpose |
|--------|---------|
| `vi.fn()` | Create a standalone mock function |
| `vi.fn().mockReturnValue(x)` | Always return `x` |
| `vi.fn().mockReturnValueOnce(x)` | Return `x` once, then default |
| `vi.fn().mockResolvedValue(x)` | Return `Promise.resolve(x)` |
| `vi.fn().mockRejectedValue(e)` | Return `Promise.reject(e)` |
| `vi.fn().mockImplementation(fn)` | Use custom implementation |
| `vi.spyOn(obj, "method")` | Spy on existing method |
| `vi.mocked(fn)` | Type helper for mocked function |
| `vi.mock("module")` | Auto-mock all exports |
| `vi.resetAllMocks()` | Reset history and implementations |
| `vi.restoreAllMocks()` | Restore original implementations |
| `vi.clearAllMocks()` | Clear call history only |
@@ -0,0 +1,100 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
// -------------------------------------------------------------------------
// Path aliases -- must match tsconfig.json "paths"
// -------------------------------------------------------------------------
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"@test": path.resolve(__dirname, "test"),
},
},
test: {
// -----------------------------------------------------------------------
// Environment
// -----------------------------------------------------------------------
// "node" -- default, for backend / library code
// "jsdom" -- for code that accesses DOM APIs (React, etc.)
// "happy-dom" -- faster jsdom alternative
environment: "jsdom",
// -----------------------------------------------------------------------
// Globals
// -----------------------------------------------------------------------
// Set to true to use describe/it/expect without importing from "vitest".
// Requires adding "vitest/globals" to tsconfig "types".
globals: true,
// -----------------------------------------------------------------------
// Setup files -- run before each test file
// -----------------------------------------------------------------------
setupFiles: [
"./test/setup.ts",
// "./test/mocks/server.ts", // MSW server setup
],
// -----------------------------------------------------------------------
// File patterns
// -----------------------------------------------------------------------
include: [
"src/**/*.{test,spec}.{ts,tsx}",
"test/**/*.{test,spec}.{ts,tsx}",
],
exclude: [
"node_modules",
"dist",
"e2e/**",
],
// -----------------------------------------------------------------------
// Coverage
// -----------------------------------------------------------------------
coverage: {
provider: "v8", // or "istanbul"
reporter: ["text", "text-summary", "lcov", "json"],
reportsDirectory: "./coverage",
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.d.ts",
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/**/index.ts", // barrel files
"src/types/**",
],
// Minimum thresholds -- fail if coverage drops below these.
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
// -----------------------------------------------------------------------
// Timeouts
// -----------------------------------------------------------------------
testTimeout: 10_000, // 10s per test
hookTimeout: 10_000, // 10s per beforeEach/afterEach
// -----------------------------------------------------------------------
// Reporters
// -----------------------------------------------------------------------
reporters: ["default"],
// For CI, add JUnit output:
// reporters: ["default", "junit"],
// outputFile: { junit: "./junit.xml" },
// -----------------------------------------------------------------------
// Other options
// -----------------------------------------------------------------------
// restoreMocks: true, // Automatically restore mocks after each test
// clearMocks: true, // Clear mock call history after each test
// mockReset: true, // Reset mocks (clear + remove implementations)
},
});