add new
Some checks failed
K8S Fission Deployment / Deployment fission functions (push) Failing after 12s

This commit is contained in:
Nguyen Duc Thao
2026-01-26 23:07:28 +07:00
parent 018f267fab
commit 3861b027b2
16 changed files with 3164 additions and 60 deletions

View File

@@ -0,0 +1,623 @@
---
name: api-documentation-generator
description: Generate OpenAPI/Swagger specifications and API documentation from code or design. Use when creating API docs, generating OpenAPI specs, or documenting REST APIs.
---
# API Documentation Generator
Generate OpenAPI/Swagger specifications and comprehensive API documentation.
## Quick Start
Create OpenAPI 3.0 specs with paths, schemas, and examples for complete API documentation.
## Instructions
### OpenAPI 3.0 Structure
**Basic structure:**
```yaml
openapi: 3.0.0
info:
title: API Name
version: 1.0.0
description: API description
servers:
- url: https://api.example.com/v1
paths:
/users:
get:
summary: List users
responses:
'200':
description: Success
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
```
### Info Section
```yaml
info:
title: E-commerce API
version: 1.0.0
description: |
REST API for e-commerce platform.
## Authentication
Use Bearer token in Authorization header.
## Rate Limiting
1000 requests per hour per API key.
contact:
name: API Support
email: api@example.com
url: https://example.com/support
license:
name: MIT
url: https://opensource.org/licenses/MIT
```
### Servers
```yaml
servers:
- url: https://api.example.com/v1
description: Production
- url: https://staging-api.example.com/v1
description: Staging
- url: http://localhost:3000/v1
description: Development
```
### Paths and Operations
**GET endpoint:**
```yaml
paths:
/users:
get:
summary: List users
description: Retrieve a paginated list of users
tags:
- Users
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: per_page
in: query
schema:
type: integer
default: 20
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
meta:
$ref: '#/components/schemas/PaginationMeta'
```
**POST endpoint:**
```yaml
/users:
post:
summary: Create user
tags:
- Users
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
example:
name: John Doe
email: john@example.com
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
$ref: '#/components/responses/BadRequest'
```
**Path parameters:**
```yaml
/users/{id}:
get:
summary: Get user by ID
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: User ID
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
$ref: '#/components/responses/NotFound'
```
### Components - Schemas
**Simple schema:**
```yaml
components:
schemas:
User:
type: object
required:
- id
- email
properties:
id:
type: integer
example: 1
name:
type: string
example: John Doe
email:
type: string
format: email
example: john@example.com
created_at:
type: string
format: date-time
```
**Nested schema:**
```yaml
Order:
type: object
properties:
id:
type: integer
customer:
$ref: '#/components/schemas/User'
items:
type: array
items:
$ref: '#/components/schemas/OrderItem'
total:
type: number
format: float
```
**Enum:**
```yaml
OrderStatus:
type: string
enum:
- pending
- processing
- shipped
- delivered
- cancelled
```
**OneOf (union types):**
```yaml
Payment:
oneOf:
- $ref: '#/components/schemas/CreditCardPayment'
- $ref: '#/components/schemas/PayPalPayment'
discriminator:
propertyName: payment_type
```
### Components - Responses
**Reusable responses:**
```yaml
components:
responses:
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: NOT_FOUND
message: Resource not found
BadRequest:
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
Unauthorized:
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
```
### Components - Parameters
**Reusable parameters:**
```yaml
components:
parameters:
PageParam:
name: page
in: query
schema:
type: integer
default: 1
LimitParam:
name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
IdParam:
name: id
in: path
required: true
schema:
type: integer
```
### Security Schemes
**Bearer token:**
```yaml
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- BearerAuth: []
```
**API Key:**
```yaml
components:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
```
**OAuth 2.0:**
```yaml
components:
securitySchemes:
OAuth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://example.com/oauth/authorize
tokenUrl: https://example.com/oauth/token
scopes:
read: Read access
write: Write access
```
### Examples
**Multiple examples:**
```yaml
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
examples:
admin:
summary: Admin user
value:
id: 1
name: Admin
role: admin
regular:
summary: Regular user
value:
id: 2
name: John
role: user
```
### Tags
**Organize endpoints:**
```yaml
tags:
- name: Users
description: User management
- name: Products
description: Product catalog
- name: Orders
description: Order processing
paths:
/users:
get:
tags:
- Users
```
## Complete Example
```yaml
openapi: 3.0.0
info:
title: Blog API
version: 1.0.0
description: RESTful API for blog platform
servers:
- url: https://api.blog.com/v1
paths:
/posts:
get:
summary: List posts
tags:
- Posts
parameters:
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/LimitParam'
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Post'
meta:
$ref: '#/components/schemas/PaginationMeta'
post:
summary: Create post
tags:
- Posts
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- title
- content
properties:
title:
type: string
content:
type: string
tags:
type: array
items:
type: string
responses:
'201':
description: Post created
content:
application/json:
schema:
$ref: '#/components/schemas/Post'
/posts/{id}:
get:
summary: Get post
tags:
- Posts
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Post'
'404':
$ref: '#/components/responses/NotFound'
components:
schemas:
Post:
type: object
properties:
id:
type: integer
title:
type: string
content:
type: string
author:
$ref: '#/components/schemas/User'
created_at:
type: string
format: date-time
User:
type: object
properties:
id:
type: integer
name:
type: string
email:
type: string
PaginationMeta:
type: object
properties:
total:
type: integer
page:
type: integer
per_page:
type: integer
Error:
type: object
properties:
error:
type: object
properties:
code:
type: string
message:
type: string
parameters:
PageParam:
name: page
in: query
schema:
type: integer
default: 1
LimitParam:
name: limit
in: query
schema:
type: integer
default: 20
responses:
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- BearerAuth: []
```
## Generating from Code
### From Express.js
```javascript
// Use swagger-jsdoc
/**
* @swagger
* /users:
* get:
* summary: List users
* responses:
* 200:
* description: Success
*/
app.get('/users', (req, res) => {
// Handler
});
```
### From FastAPI (Python)
```python
# FastAPI auto-generates OpenAPI
@app.get("/users", response_model=List[User])
async def list_users():
return users
```
### From ASP.NET Core
```csharp
// Use Swashbuckle
[HttpGet]
[ProducesResponseType(typeof(List<User>), 200)]
public IActionResult GetUsers()
{
return Ok(users);
}
```
## Tools
**Swagger Editor**: https://editor.swagger.io
**Swagger UI**: Interactive documentation
**Redoc**: Alternative documentation UI
**Postman**: Import OpenAPI for testing
## Best Practices
**Use $ref for reusability:**
- Define schemas once
- Reference in multiple places
- Easier maintenance
**Include examples:**
- Help developers understand
- Enable better testing
- Show expected formats
**Document errors:**
- All possible status codes
- Error response format
- Error codes and meanings
**Version your API:**
- Include version in URL or header
- Document breaking changes
- Maintain old versions
**Keep it updated:**
- Generate from code when possible
- Review regularly
- Update with API changes

View File

@@ -0,0 +1,187 @@
---
name: pytest
description: |
Python testing with pytest framework for unit, integration, and API tests.
Use when: (1) Writing test cases for Python code, (2) Setting up pytest fixtures,
(3) Testing async functions with pytest-asyncio, (4) Mocking dependencies,
(5) Parameterizing tests, (6) Testing FastAPI/Flask endpoints, (7) Setting up test coverage,
(8) Creating test factories with factory_boy, (9) Configuring CI/CD test pipelines.
---
# Pytest Testing Skill
Comprehensive testing patterns for Python applications using pytest.
## Quick Reference
| Feature | Reference File |
|---------|----------------|
| Fixtures, conftest, scopes | [references/fixtures.md](references/fixtures.md) |
| Async testing, pytest-asyncio | [references/async-testing.md](references/async-testing.md) |
| Mocking, patching, spies | [references/mocking.md](references/mocking.md) |
| FastAPI/Flask endpoint testing | [references/api-testing.md](references/api-testing.md) |
## Dependencies
```toml
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=4.1.0",
"httpx>=0.28.0", # Async HTTP client for API tests
"factory-boy>=3.3.0", # Test factories
"faker>=33.0.0", # Fake data generation
]
```
## Configuration
### pyproject.toml
```toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
filterwarnings = ["ignore::DeprecationWarning"]
[tool.coverage.run]
source = ["app"]
omit = ["*/tests/*", "*/__init__.py"]
```
## Basic Test Structure
```python
import pytest
# Simple test function
def test_addition():
assert 1 + 1 == 2
# Test class (group related tests)
class TestCalculator:
def test_add(self):
assert add(2, 3) == 5
def test_subtract(self):
assert subtract(5, 3) == 2
# Expected exceptions
def test_division_by_zero():
with pytest.raises(ZeroDivisionError):
divide(1, 0)
# Parametrized tests
@pytest.mark.parametrize("input,expected", [
(1, 1),
(2, 4),
(3, 9),
])
def test_square(input, expected):
assert square(input) == expected
```
## Fixtures
```python
import pytest
@pytest.fixture
def sample_user():
return {"name": "John", "email": "john@example.com"}
@pytest.fixture
def db_connection():
conn = create_connection()
yield conn # Test runs here
conn.close() # Cleanup after test
# Use fixture in test
def test_user_name(sample_user):
assert sample_user["name"] == "John"
```
## Async Testing
```python
import pytest
@pytest.mark.asyncio
async def test_async_function():
result = await async_operation()
assert result == "success"
@pytest.fixture
async def async_client():
async with AsyncClient() as client:
yield client
```
## Mocking
```python
from unittest.mock import patch, MagicMock, AsyncMock
def test_with_mock():
with patch("module.external_api") as mock_api:
mock_api.return_value = {"data": "mocked"}
result = function_using_api()
assert result["data"] == "mocked"
mock_api.assert_called_once()
# Async mock
@pytest.mark.asyncio
async def test_async_mock():
with patch("module.async_call", new_callable=AsyncMock) as mock:
mock.return_value = "result"
result = await function_with_async_call()
assert result == "result"
```
## Running Tests
```bash
# Run all tests
pytest
# Verbose output
pytest -v
# Run specific file
pytest tests/test_users.py
# Run specific test
pytest tests/test_users.py::test_create_user
# Run with coverage
pytest --cov=app --cov-report=term-missing
# Stop on first failure
pytest -x
# Run last failed tests
pytest --lf
# Run tests matching pattern
pytest -k "user and not delete"
```
## Project Structure
```
project/
├── app/
│ ├── __init__.py
│ ├── main.py
│ └── services/
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Shared fixtures
│ ├── test_main.py
│ └── test_services/
└── pyproject.toml
```

View File

@@ -0,0 +1,434 @@
# API Testing
## Table of Contents
- [FastAPI Testing](#fastapi-testing)
- [Flask Testing](#flask-testing)
- [Test Factories](#test-factories)
- [Authentication Testing](#authentication-testing)
- [Database Testing](#database-testing)
## FastAPI Testing
### Setup
```python
# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlmodel import SQLModel
from app.main import app
from app.core.database import get_session
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
test_engine = create_async_engine(TEST_DATABASE_URL)
TestSession = async_sessionmaker(test_engine, class_=AsyncSession)
@pytest.fixture(scope="function")
async def db_session():
"""Fresh database for each test."""
async with test_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async with TestSession() as session:
yield session
async with test_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
@pytest.fixture
async def client(db_session):
"""Test client with dependency override."""
async def override_session():
yield db_session
app.dependency_overrides[get_session] = override_session
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as ac:
yield ac
app.dependency_overrides.clear()
```
### Basic Endpoint Tests
```python
import pytest
from httpx import AsyncClient
class TestUsers:
@pytest.mark.asyncio
async def test_create_user(self, client: AsyncClient):
response = await client.post(
"/api/users",
json={"email": "test@example.com", "name": "Test User"},
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@example.com"
assert "id" in data
@pytest.mark.asyncio
async def test_get_user(self, client: AsyncClient, test_user):
response = await client.get(f"/api/users/{test_user.id}")
assert response.status_code == 200
assert response.json()["id"] == test_user.id
@pytest.mark.asyncio
async def test_get_user_not_found(self, client: AsyncClient):
response = await client.get("/api/users/99999")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_user(self, client: AsyncClient, test_user, auth_headers):
response = await client.patch(
f"/api/users/{test_user.id}",
json={"name": "Updated Name"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["name"] == "Updated Name"
@pytest.mark.asyncio
async def test_delete_user(self, client: AsyncClient, test_user, admin_headers):
response = await client.delete(
f"/api/users/{test_user.id}",
headers=admin_headers,
)
assert response.status_code == 204
```
### Testing Query Parameters
```python
@pytest.mark.asyncio
async def test_list_users_pagination(self, client: AsyncClient, auth_headers):
response = await client.get(
"/api/users",
params={"page": 1, "per_page": 10, "sort": "-created_at"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert data["page"] == 1
@pytest.mark.asyncio
async def test_search_users(self, client: AsyncClient, auth_headers):
response = await client.get(
"/api/users",
params={"q": "john", "active": True},
headers=auth_headers,
)
assert response.status_code == 200
```
### Testing File Uploads
```python
import io
@pytest.mark.asyncio
async def test_upload_file(self, client: AsyncClient, auth_headers):
file_content = b"Hello, World!"
files = {"file": ("test.txt", io.BytesIO(file_content), "text/plain")}
response = await client.post(
"/api/files/upload",
files=files,
headers=auth_headers,
)
assert response.status_code == 201
assert response.json()["filename"] == "test.txt"
```
## Flask Testing
### Setup
```python
import pytest
from app import create_app
from app.extensions import db
@pytest.fixture
def app():
"""Create application for testing."""
app = create_app("testing")
app.config["TESTING"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
with app.app_context():
db.create_all()
yield app
db.drop_all()
@pytest.fixture
def client(app):
"""Test client."""
return app.test_client()
@pytest.fixture
def runner(app):
"""CLI test runner."""
return app.test_cli_runner()
```
### Flask Test Examples
```python
def test_create_user(client):
response = client.post(
"/api/users",
json={"email": "test@example.com", "name": "Test"},
)
assert response.status_code == 201
def test_get_user(client, test_user):
response = client.get(f"/api/users/{test_user.id}")
assert response.status_code == 200
assert response.json["email"] == test_user.email
def test_protected_route(client, auth_token):
response = client.get(
"/api/protected",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert response.status_code == 200
```
## Test Factories
### Using factory_boy
```python
# tests/factories.py
import factory
from faker import Faker
from app.models import User, Project
fake = Faker()
class UserFactory(factory.Factory):
class Meta:
model = User
email = factory.LazyAttribute(lambda _: fake.email())
name = factory.LazyAttribute(lambda _: fake.name())
hashed_password = "hashed_password"
is_active = True
class ProjectFactory(factory.Factory):
class Meta:
model = Project
name = factory.LazyAttribute(lambda _: fake.company())
description = factory.LazyAttribute(lambda _: fake.sentence())
owner_id = None
# Async helper
async def create_user(session, **kwargs) -> User:
user = UserFactory(**kwargs)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def create_users(session, count: int = 5, **kwargs) -> list[User]:
users = [UserFactory(**kwargs) for _ in range(count)]
session.add_all(users)
await session.commit()
return users
```
### Using Factories in Tests
```python
from tests.factories import create_user, create_users
@pytest.mark.asyncio
async def test_list_users(client, db_session, auth_headers):
# Create 20 users
await create_users(db_session, count=20)
response = await client.get("/api/users", headers=auth_headers)
assert response.status_code == 200
assert response.json()["total"] >= 20
@pytest.mark.asyncio
async def test_filter_active_users(client, db_session, auth_headers):
await create_user(db_session, is_active=True)
await create_user(db_session, is_active=False)
response = await client.get(
"/api/users",
params={"active": True},
headers=auth_headers,
)
data = response.json()
assert all(u["is_active"] for u in data["items"])
```
## Authentication Testing
### Auth Fixtures
```python
from app.core.security import create_access_token, hash_password
@pytest.fixture
async def test_user(db_session):
"""Create a regular test user."""
user = User(
email="test@example.com",
hashed_password=hash_password("password123"),
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def admin_user(db_session):
"""Create an admin user."""
user = User(
email="admin@example.com",
hashed_password=hash_password("admin123"),
is_active=True,
is_admin=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
def auth_headers(test_user):
"""Headers with valid auth token."""
token = create_access_token({"sub": str(test_user.id)})
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def admin_headers(admin_user):
"""Headers with admin auth token."""
token = create_access_token({"sub": str(admin_user.id)})
return {"Authorization": f"Bearer {token}"}
```
### Auth Test Examples
```python
class TestAuth:
@pytest.mark.asyncio
async def test_login_success(self, client, test_user):
response = await client.post(
"/api/auth/login",
data={"username": test_user.email, "password": "password123"},
)
assert response.status_code == 200
assert "access_token" in response.json()
@pytest.mark.asyncio
async def test_login_wrong_password(self, client, test_user):
response = await client.post(
"/api/auth/login",
data={"username": test_user.email, "password": "wrong"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_protected_endpoint_no_auth(self, client):
response = await client.get("/api/protected")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_admin_endpoint_regular_user(self, client, auth_headers):
response = await client.delete(
"/api/admin/users/1",
headers=auth_headers,
)
assert response.status_code == 403
```
## Database Testing
### Transaction Rollback Pattern
```python
@pytest.fixture
async def db_session(engine):
"""Each test runs in a transaction that rolls back."""
async with engine.connect() as conn:
await conn.begin()
async with AsyncSession(bind=conn) as session:
yield session
await conn.rollback()
```
### Testing Repository Layer
```python
from app.repositories import UserRepository
class TestUserRepository:
@pytest.mark.asyncio
async def test_create(self, db_session):
repo = UserRepository(db_session)
user = await repo.create({
"email": "test@example.com",
"hashed_password": "hash",
})
assert user.id is not None
assert user.email == "test@example.com"
@pytest.mark.asyncio
async def test_get_by_email(self, db_session, test_user):
repo = UserRepository(db_session)
user = await repo.get_by_email(test_user.email)
assert user.id == test_user.id
@pytest.mark.asyncio
async def test_soft_delete(self, db_session, test_user):
repo = UserRepository(db_session)
await repo.soft_delete(test_user.id)
user = await repo.get_by_id(test_user.id)
assert user.status == 0 # Soft deleted
```

View File

@@ -0,0 +1,290 @@
# Async Testing
## Table of Contents
- [Setup](#setup)
- [Basic Async Tests](#basic-async-tests)
- [Async Fixtures](#async-fixtures)
- [Testing Async Generators](#testing-async-generators)
- [Timeouts](#timeouts)
## Setup
### Installation
```bash
pip install pytest-asyncio
```
### Configuration
```toml
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto" # Automatically handle async tests
# OR
asyncio_mode = "strict" # Require explicit @pytest.mark.asyncio
```
### With pytest.ini
```ini
[pytest]
asyncio_mode = auto
```
## Basic Async Tests
### Simple Async Test
```python
import pytest
# With asyncio_mode = "auto", decorator is optional
async def test_async_function():
result = await async_operation()
assert result == "success"
# With asyncio_mode = "strict", decorator is required
@pytest.mark.asyncio
async def test_async_with_marker():
result = await async_operation()
assert result == "success"
```
### Async Test Class
```python
@pytest.mark.asyncio
class TestAsyncOperations:
async def test_fetch_data(self):
data = await fetch_data()
assert data is not None
async def test_process_data(self):
result = await process_data({"key": "value"})
assert result["processed"] is True
```
### Testing Async Exceptions
```python
import pytest
@pytest.mark.asyncio
async def test_async_exception():
with pytest.raises(ValueError):
await async_function_that_raises()
@pytest.mark.asyncio
async def test_async_exception_message():
with pytest.raises(ValueError, match="Invalid input"):
await validate_input("")
```
## Async Fixtures
### Basic Async Fixture
```python
import pytest
@pytest.fixture
async def async_client():
"""Async fixture for HTTP client."""
async with httpx.AsyncClient() as client:
yield client
@pytest.mark.asyncio
async def test_api_call(async_client):
response = await async_client.get("https://api.example.com/data")
assert response.status_code == 200
```
### Async Database Fixture
```python
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
@pytest.fixture(scope="session")
def event_loop():
"""Create event loop for session-scoped async fixtures."""
import asyncio
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def engine():
"""Session-scoped async engine."""
engine = create_async_engine("postgresql+asyncpg://...")
yield engine
await engine.dispose()
@pytest.fixture
async def db_session(engine):
"""Function-scoped database session with rollback."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with AsyncSession(engine) as session:
yield session
await session.rollback()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
```
### Fixture with Async Setup/Teardown
```python
@pytest.fixture
async def resource():
# Async setup
resource = await create_resource()
await resource.initialize()
yield resource
# Async teardown
await resource.cleanup()
await resource.close()
```
## Testing Async Generators
### Async Generator Function
```python
async def async_data_generator():
for i in range(5):
await asyncio.sleep(0.1)
yield i
@pytest.mark.asyncio
async def test_async_generator():
results = []
async for item in async_data_generator():
results.append(item)
assert results == [0, 1, 2, 3, 4]
```
### Async Context Manager
```python
from contextlib import asynccontextmanager
@asynccontextmanager
async def async_resource():
resource = await create_resource()
try:
yield resource
finally:
await resource.close()
@pytest.mark.asyncio
async def test_async_context_manager():
async with async_resource() as resource:
result = await resource.operation()
assert result is not None
```
## Timeouts
### Test Timeout
```python
import asyncio
import pytest
@pytest.mark.asyncio
@pytest.mark.timeout(5) # 5 second timeout
async def test_slow_operation():
result = await potentially_slow_operation()
assert result is not None
```
### Using asyncio.wait_for
```python
@pytest.mark.asyncio
async def test_with_timeout():
try:
result = await asyncio.wait_for(
slow_operation(),
timeout=2.0
)
assert result is not None
except asyncio.TimeoutError:
pytest.fail("Operation timed out")
```
### Testing Timeout Behavior
```python
@pytest.mark.asyncio
async def test_timeout_is_raised():
"""Verify function properly times out."""
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(
never_completes(),
timeout=0.1
)
```
## Concurrent Tests
### Testing Concurrent Operations
```python
@pytest.mark.asyncio
async def test_concurrent_requests():
async with httpx.AsyncClient() as client:
# Run 10 requests concurrently
tasks = [
client.get(f"https://api.example.com/items/{i}")
for i in range(10)
]
responses = await asyncio.gather(*tasks)
assert all(r.status_code == 200 for r in responses)
```
### Testing Race Conditions
```python
@pytest.mark.asyncio
async def test_counter_thread_safety():
counter = AsyncCounter()
async def increment():
for _ in range(100):
await counter.increment()
# Run 10 concurrent incrementers
await asyncio.gather(*[increment() for _ in range(10)])
assert counter.value == 1000
```
## Event Loop Configuration
### Custom Event Loop
```python
import pytest
import asyncio
@pytest.fixture(scope="session")
def event_loop_policy():
"""Use uvloop for faster async tests."""
import uvloop
return uvloop.EventLoopPolicy()
@pytest.fixture(scope="session")
def event_loop(event_loop_policy):
asyncio.set_event_loop_policy(event_loop_policy)
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
```

View File

@@ -0,0 +1,296 @@
# Fixtures
## Table of Contents
- [Basic Fixtures](#basic-fixtures)
- [Fixture Scopes](#fixture-scopes)
- [Fixture Parameters](#fixture-parameters)
- [Conftest.py](#conftestpy)
- [Built-in Fixtures](#built-in-fixtures)
## Basic Fixtures
### Simple Fixture
```python
import pytest
@pytest.fixture
def user_data():
"""Return sample user data."""
return {
"name": "John Doe",
"email": "john@example.com",
"age": 30,
}
def test_user_name(user_data):
assert user_data["name"] == "John Doe"
def test_user_email(user_data):
assert "@" in user_data["email"]
```
### Fixture with Setup and Teardown
```python
@pytest.fixture
def database():
"""Setup database, yield, then cleanup."""
# Setup
db = create_database()
db.connect()
yield db # Test runs here
# Teardown
db.clear()
db.disconnect()
def test_insert(database):
database.insert({"id": 1, "name": "Test"})
assert database.count() == 1
```
### Fixture Returning Factory
```python
@pytest.fixture
def make_user():
"""Return a factory function for creating users."""
created_users = []
def _make_user(name: str, email: str = None):
user = User(name=name, email=email or f"{name}@example.com")
created_users.append(user)
return user
yield _make_user
# Cleanup all created users
for user in created_users:
user.delete()
def test_multiple_users(make_user):
user1 = make_user("Alice")
user2 = make_user("Bob")
assert user1.name != user2.name
```
## Fixture Scopes
```python
# Function scope (default) - runs for each test
@pytest.fixture(scope="function")
def fresh_data():
return {"count": 0}
# Class scope - runs once per test class
@pytest.fixture(scope="class")
def class_resource():
return ExpensiveResource()
# Module scope - runs once per test module
@pytest.fixture(scope="module")
def module_connection():
conn = create_connection()
yield conn
conn.close()
# Session scope - runs once per test session
@pytest.fixture(scope="session")
def session_config():
return load_config()
```
### Scope Hierarchy
```
session (once per test run)
└── package (once per test package)
└── module (once per test file)
└── class (once per test class)
└── function (once per test function)
```
## Fixture Parameters
### Parametrized Fixtures
```python
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def database_type(request):
"""Run tests with each database type."""
return request.param
def test_connection(database_type):
# This test runs 3 times, once for each database
db = create_database(database_type)
assert db.connect()
```
### Fixture with IDs
```python
@pytest.fixture(params=[
pytest.param({"admin": True}, id="admin"),
pytest.param({"admin": False}, id="regular"),
])
def user_config(request):
return request.param
# Test output shows: test_permissions[admin], test_permissions[regular]
```
### Indirect Parametrization
```python
@pytest.fixture
def user(request):
"""Create user based on parameter."""
role = request.param
return User(role=role)
@pytest.mark.parametrize("user", ["admin", "editor", "viewer"], indirect=True)
def test_user_access(user):
# user fixture receives each role as request.param
assert user.role in ["admin", "editor", "viewer"]
```
## Conftest.py
### tests/conftest.py
```python
"""Shared fixtures for all tests."""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Session-scoped database engine
@pytest.fixture(scope="session")
def engine():
return create_engine("sqlite:///:memory:")
# Function-scoped session with transaction rollback
@pytest.fixture(scope="function")
def db_session(engine):
connection = engine.connect()
transaction = connection.begin()
Session = sessionmaker(bind=connection)
session = Session()
yield session
session.close()
transaction.rollback()
connection.close()
# Shared test data
@pytest.fixture
def sample_products():
return [
{"name": "Widget", "price": 9.99},
{"name": "Gadget", "price": 19.99},
{"name": "Gizmo", "price": 29.99},
]
```
### Nested Conftest Files
```
tests/
├── conftest.py # Available to all tests
├── unit/
│ ├── conftest.py # Available to unit tests only
│ └── test_models.py
└── integration/
├── conftest.py # Available to integration tests only
└── test_api.py
```
## Built-in Fixtures
### tmp_path / tmp_path_factory
```python
def test_create_file(tmp_path):
"""tmp_path provides unique temporary directory."""
file = tmp_path / "test.txt"
file.write_text("Hello, World!")
assert file.read_text() == "Hello, World!"
@pytest.fixture(scope="session")
def session_temp_dir(tmp_path_factory):
"""Create temp dir for entire session."""
return tmp_path_factory.mktemp("session_data")
```
### capsys / capfd
```python
def test_print_output(capsys):
"""Capture stdout/stderr."""
print("Hello")
captured = capsys.readouterr()
assert captured.out == "Hello\n"
def test_file_descriptor_output(capfd):
"""Capture at file descriptor level."""
import os
os.system("echo 'Hello'")
captured = capfd.readouterr()
assert "Hello" in captured.out
```
### monkeypatch
```python
def test_env_variable(monkeypatch):
"""Modify environment temporarily."""
monkeypatch.setenv("API_KEY", "test-key")
assert os.environ["API_KEY"] == "test-key"
def test_module_attribute(monkeypatch):
"""Modify module attribute temporarily."""
monkeypatch.setattr("module.CONFIG", {"debug": True})
assert module.CONFIG["debug"] is True
def test_dict_item(monkeypatch):
"""Modify dictionary temporarily."""
monkeypatch.setitem(app.settings, "DEBUG", True)
```
### request
```python
@pytest.fixture
def resource(request):
"""Access test context."""
print(f"Running: {request.node.name}")
print(f"Module: {request.module.__name__}")
print(f"Function: {request.function.__name__}")
# Access fixture parameters
if hasattr(request, "param"):
return create_resource(request.param)
return create_default_resource()
```
### Autouse Fixtures
```python
@pytest.fixture(autouse=True)
def setup_logging():
"""Automatically runs for every test."""
logging.basicConfig(level=logging.DEBUG)
yield
logging.shutdown()
@pytest.fixture(autouse=True, scope="session")
def global_setup():
"""Run once at session start."""
initialize_system()
yield
cleanup_system()
```

View File

@@ -0,0 +1,354 @@
# Mocking
## Table of Contents
- [Basic Mocking](#basic-mocking)
- [Patching](#patching)
- [Mock Objects](#mock-objects)
- [Async Mocking](#async-mocking)
- [Fixture-Based Mocking](#fixture-based-mocking)
- [Common Patterns](#common-patterns)
## Basic Mocking
### MagicMock Basics
```python
from unittest.mock import MagicMock
def test_with_mock():
# Create a mock object
mock_service = MagicMock()
# Configure return value
mock_service.get_data.return_value = {"id": 1, "name": "Test"}
# Use mock
result = mock_service.get_data()
assert result["name"] == "Test"
# Verify call
mock_service.get_data.assert_called_once()
```
### Return Value Configuration
```python
from unittest.mock import MagicMock
mock = MagicMock()
# Simple return value
mock.method.return_value = 42
# Different returns on consecutive calls
mock.method.side_effect = [1, 2, 3]
assert mock.method() == 1
assert mock.method() == 2
assert mock.method() == 3
# Raise exception
mock.method.side_effect = ValueError("Error!")
# Dynamic return value
mock.method.side_effect = lambda x: x * 2
assert mock.method(5) == 10
```
## Patching
### patch Decorator
```python
from unittest.mock import patch
# Patch a function
@patch("mymodule.external_api")
def test_with_patched_api(mock_api):
mock_api.return_value = {"status": "ok"}
result = mymodule.call_api()
assert result["status"] == "ok"
# Patch multiple
@patch("mymodule.function_a")
@patch("mymodule.function_b")
def test_multiple_patches(mock_b, mock_a):
# Note: decorators apply bottom-up
mock_a.return_value = "a"
mock_b.return_value = "b"
```
### patch Context Manager
```python
from unittest.mock import patch
def test_with_context_manager():
with patch("mymodule.external_api") as mock_api:
mock_api.return_value = {"data": "mocked"}
result = mymodule.process()
assert result == {"data": "mocked"}
# Original function restored after with block
```
### patch.object
```python
from unittest.mock import patch
class MyService:
def fetch_data(self):
return "real data"
def test_patch_instance_method():
service = MyService()
with patch.object(service, "fetch_data", return_value="mocked"):
assert service.fetch_data() == "mocked"
```
### Patching Where Used
```python
# mymodule.py
from requests import get # 'get' is imported here
def fetch_url(url):
return get(url).text
# test_mymodule.py
# Patch where it's USED, not where it's DEFINED
@patch("mymodule.get") # NOT "requests.get"
def test_fetch_url(mock_get):
mock_get.return_value.text = "mocked response"
result = fetch_url("http://example.com")
assert result == "mocked response"
```
## Mock Objects
### Spec and Autospec
```python
from unittest.mock import MagicMock, create_autospec
class UserService:
def get_user(self, user_id: int) -> dict:
pass
def create_user(self, data: dict) -> dict:
pass
# Basic mock (allows any attribute)
mock = MagicMock()
mock.nonexistent_method() # Works, but shouldn't
# Spec mock (restricts to real attributes)
mock = MagicMock(spec=UserService)
# mock.nonexistent_method() # Raises AttributeError
# Autospec (also checks signatures)
mock = create_autospec(UserService)
# mock.get_user() # Raises TypeError (missing user_id)
mock.get_user(123) # Works
```
### Assertion Methods
```python
from unittest.mock import MagicMock, call
mock = MagicMock()
mock.method(1, 2, key="value")
mock.method(3, 4)
# Verify calls
mock.method.assert_called()
mock.method.assert_called_once() # Fails - called twice
mock.method.assert_called_with(3, 4)
mock.method.assert_any_call(1, 2, key="value")
# Check call count
assert mock.method.call_count == 2
# Check all calls
assert mock.method.call_args_list == [
call(1, 2, key="value"),
call(3, 4),
]
# Reset mock
mock.reset_mock()
assert mock.method.call_count == 0
```
## Async Mocking
### AsyncMock
```python
from unittest.mock import AsyncMock, patch
import pytest
@pytest.mark.asyncio
async def test_async_mock():
mock = AsyncMock(return_value={"data": "mocked"})
result = await mock()
assert result["data"] == "mocked"
@pytest.mark.asyncio
@patch("mymodule.async_fetch", new_callable=AsyncMock)
async def test_patched_async(mock_fetch):
mock_fetch.return_value = {"status": "ok"}
result = await mymodule.process()
assert result["status"] == "ok"
```
### Async Side Effects
```python
from unittest.mock import AsyncMock
@pytest.mark.asyncio
async def test_async_side_effect():
mock = AsyncMock()
# Return different values
mock.side_effect = [1, 2, 3]
assert await mock() == 1
assert await mock() == 2
# Async function as side effect
async def async_side_effect(x):
return x * 2
mock.side_effect = async_side_effect
assert await mock(5) == 10
```
## Fixture-Based Mocking
### Mock Fixtures
```python
import pytest
from unittest.mock import MagicMock, patch
@pytest.fixture
def mock_database():
"""Fixture providing mock database."""
mock_db = MagicMock()
mock_db.query.return_value = [{"id": 1}, {"id": 2}]
return mock_db
def test_with_mock_fixture(mock_database):
result = mock_database.query("SELECT * FROM users")
assert len(result) == 2
@pytest.fixture
def mock_external_api():
"""Fixture with patching."""
with patch("mymodule.external_api") as mock:
mock.return_value = {"status": "ok"}
yield mock
```
### Monkeypatch Fixture
```python
def test_with_monkeypatch(monkeypatch):
# Patch function
monkeypatch.setattr("mymodule.get_config", lambda: {"debug": True})
# Patch environment variable
monkeypatch.setenv("API_KEY", "test-key")
# Patch dictionary item
monkeypatch.setitem(mymodule.settings, "DEBUG", True)
# Patch attribute
monkeypatch.setattr(mymodule.client, "timeout", 5)
```
## Common Patterns
### Mock HTTP Responses
```python
from unittest.mock import patch, MagicMock
@patch("requests.get")
def test_http_request(mock_get):
# Configure mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": "test"}
mock_get.return_value = mock_response
result = fetch_data("http://api.example.com")
assert result == {"data": "test"}
mock_get.assert_called_once_with("http://api.example.com")
```
### Mock File Operations
```python
from unittest.mock import mock_open, patch
def test_read_file():
mock_file_content = "Hello, World!"
with patch("builtins.open", mock_open(read_data=mock_file_content)):
result = read_file("test.txt")
assert result == "Hello, World!"
def test_write_file():
m = mock_open()
with patch("builtins.open", m):
write_file("test.txt", "content")
m().write.assert_called_once_with("content")
```
### Mock Datetime
```python
from unittest.mock import patch
from datetime import datetime
@patch("mymodule.datetime")
def test_with_frozen_time(mock_datetime):
mock_datetime.now.return_value = datetime(2024, 1, 15, 12, 0, 0)
mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs)
result = mymodule.get_timestamp()
assert result == "2024-01-15 12:00:00"
```
### Mock Class Instance
```python
from unittest.mock import patch, MagicMock
class EmailService:
def send(self, to, subject, body):
# Real implementation
pass
@patch("mymodule.EmailService")
def test_email_sent(MockEmailService):
# Configure mock instance
mock_instance = MagicMock()
MockEmailService.return_value = mock_instance
# Call function that uses EmailService
send_notification("user@example.com", "Hello!")
# Verify email was sent
mock_instance.send.assert_called_once_with(
"user@example.com",
"Notification",
"Hello!",
)
```

View File

@@ -0,0 +1,399 @@
---
name: write-unit-tests
description: Writing unit and integration tests for the tldraw SDK. Use when creating new tests, adding test coverage, or fixing failing tests in packages/editor or packages/tldraw. Covers Vitest patterns, TestEditor usage, and test file organization.
---
# Writing tests
Unit and integration tests use Vitest. Tests run from workspace directories, not the repo root.
## Test file locations
**Unit tests** - alongside source files:
```
packages/editor/src/lib/primitives/Vec.ts
packages/editor/src/lib/primitives/Vec.test.ts # Same directory
```
**Integration tests** - in `src/test/` directory:
```
packages/tldraw/src/test/SelectTool.test.ts
packages/tldraw/src/test/commands/createShape.test.ts
```
**Shape/tool tests** - alongside the implementation:
```
packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts
packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts
```
## Which workspace to test in
- **packages/editor**: Core primitives, geometry, managers, base editor functionality
- **packages/tldraw**: Anything needing default shapes/tools (most integration tests)
```bash
cd packages/tldraw && yarn test run
cd packages/tldraw && yarn test run --grep "SelectTool"
```
## TestEditor vs Editor
Use `TestEditor` for integration tests (includes default shapes/tools):
```typescript
import { createShapeId } from '@tldraw/editor'
import { TestEditor } from './TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
})
afterEach(() => {
editor?.dispose()
})
```
Use raw `Editor` when testing editor setup or custom configurations:
```typescript
import { Editor, createTLStore } from '@tldraw/editor'
beforeEach(() => {
editor = new Editor({
shapeUtils: [CustomShape],
bindingUtils: [],
tools: [CustomTool],
store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }),
getContainer: () => document.body,
})
})
```
## Common TestEditor methods
```typescript
// Pointer simulation
editor.pointerDown(x, y, options?)
editor.pointerMove(x, y, options?)
editor.pointerUp(x, y, options?)
editor.click(x, y, shapeId?)
editor.doubleClick(x, y, shapeId?)
// Keyboard simulation
editor.keyDown(key, options?)
editor.keyUp(key, options?)
// State assertions
editor.expectToBeIn('select.idle')
editor.expectToBeIn('select.crop.idle')
// Shape assertions
editor.expectShapeToMatch({ id, x, y, props: { ... } })
// Shape operations
editor.createShapes([{ id, type, x, y, props }])
editor.updateShapes([{ id, type, props }])
editor.getShape(id)
editor.select(id1, id2)
editor.selectAll()
editor.selectNone()
editor.getSelectedShapeIds()
editor.getOnlySelectedShape()
// Tool operations
editor.setCurrentTool('arrow')
editor.getCurrentToolId()
// Undo/redo
editor.undo()
editor.redo()
```
## Pointer event options
```typescript
editor.pointerDown(100, 100, {
target: 'shape', // 'canvas' | 'shape' | 'handle' | 'selection'
shape: editor.getShape(id),
})
editor.pointerDown(150, 300, {
target: 'selection',
handle: 'bottom', // 'top' | 'bottom' | 'left' | 'right' | corners
})
editor.doubleClick(550, 550, {
target: 'selection',
handle: 'bottom_right',
})
```
## Setup patterns
### Standard setup with shape IDs
```typescript
const ids = {
box1: createShapeId('box1'),
box2: createShapeId('box2'),
arrow1: createShapeId('arrow1'),
}
vi.useFakeTimers()
beforeEach(() => {
editor = new TestEditor()
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
editor.createShapes([
{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } },
])
})
afterEach(() => {
editor?.dispose()
})
```
### Reusable props
```typescript
const imageProps = {
assetId: null,
playing: true,
url: '',
w: 1200,
h: 800,
}
editor.createShapes([
{ id: ids.imageA, type: 'image', x: 100, y: 100, props: imageProps },
{ id: ids.imageB, type: 'image', x: 500, y: 500, props: { ...imageProps, w: 600, h: 400 } },
])
```
### Helper functions
```typescript
function arrow(id = ids.arrow1) {
return editor.getShape(id) as TLArrowShape
}
function bindings(id = ids.arrow1) {
return getArrowBindings(editor, arrow(id))
}
```
## Mocking with vi.spyOn
```typescript
// Mock return value
vi.spyOn(editor, 'getIsReadonly').mockReturnValue(true)
// Mock implementation
const isHiddenSpy = vi.spyOn(editor, 'isShapeHidden')
isHiddenSpy.mockImplementation((shape) => shape.id === ids.hiddenShape)
// Verify calls
const spy = vi.spyOn(editor, 'setSelectedShapes')
editor.selectAll()
expect(spy).toHaveBeenCalled()
expect(spy).not.toHaveBeenCalled()
// Always restore
isHiddenSpy.mockRestore()
```
## Fake timers
```typescript
vi.useFakeTimers()
// Mock animation frame
window.requestAnimationFrame = (cb) => setTimeout(cb, 1000 / 60)
window.cancelAnimationFrame = (id) => clearTimeout(id)
it('handles animation', () => {
editor.alignShapes(editor.getSelectedShapeIds(), 'right')
vi.advanceTimersByTime(1000)
// Assert after animation completes
})
```
## Assertions
### Shape matching
```typescript
// Partial matching (most common)
expect(editor.getShape(id)).toMatchObject({
type: 'geo',
x: 100,
props: { w: 100 },
})
editor.expectShapeToMatch({
id: ids.box1,
x: 350,
y: 350,
})
// Floating point matching (custom matcher)
expect(result).toCloselyMatchObject({
props: { normalizedAnchor: { x: 0.5, y: 0.75 } },
})
```
### Array assertions
```typescript
expect(editor.getSelectedShapeIds()).toMatchObject([ids.box1])
expect(Array.from(selectedIds).sort()).toEqual([id1, id2, id3].sort())
expect(shapes).toContain('geo')
expect(shapes).not.toContain(ids.lockedShape)
```
### State assertions
```typescript
editor.expectToBeIn('select.idle')
editor.expectToBeIn('select.brushing')
editor.expectToBeIn('select.crop.idle')
```
## Testing undo/redo
```typescript
it('handles undo/redo', () => {
editor.doubleClick(550, 550, ids.image)
editor.expectToBeIn('select.crop.idle')
editor.updateShape({ id: ids.image, type: 'image', props: { crop: newCrop } })
editor.undo()
editor.expectToBeIn('select.crop.idle')
expect(editor.getShape(ids.image)!.props.crop).toMatchObject(originalCrop)
editor.redo()
expect(editor.getShape(ids.image)!.props.crop).toMatchObject(newCrop)
})
```
## Testing TypeScript types
```typescript
it('Uses typescript generics', () => {
expect(() => {
// @ts-expect-error - wrong props type
editor.createShape({ id, type: 'geo', props: { w: 'OH NO' } })
// @ts-expect-error - unknown prop
editor.createShape({ id, type: 'geo', props: { foo: 'bar' } })
// Valid
editor.createShape<TLGeoShape>({ id, type: 'geo', props: { w: 100 } })
}).toThrow()
})
```
## Testing custom shapes
```typescript
declare module '@tldraw/tlschema' {
export interface TLGlobalShapePropsMap {
'my-custom-shape': { w: number; h: number; text: string | undefined }
}
}
class CustomShape extends ShapeUtil<ICustomShape> {
static override type = 'my-custom-shape'
static override props: RecordProps<ICustomShape> = {
w: T.number,
h: T.number,
text: T.string.optional(),
}
getDefaultProps() {
return { w: 200, h: 200, text: '' }
}
getGeometry(shape) {
return new Rectangle2d({ width: shape.props.w, height: shape.props.h })
}
indicator() {}
component() {}
}
```
## Testing side effects
```typescript
beforeEach(() => {
editor = new TestEditor()
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) {
// Handle state change
}
})
})
```
## Testing events
```typescript
it('emits wheel events', () => {
const handler = vi.fn()
editor.on('event', handler)
editor.dispatch({
type: 'wheel',
name: 'wheel',
delta: { x: 0, y: 10, z: 0 },
point: { x: 100, y: 100, z: 1 },
shiftKey: false,
// ... other modifiers
})
editor.emit('tick', 16) // Flush batched events
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ name: 'wheel' }))
})
```
## Method chaining
```typescript
editor
.expectToBeIn('select.idle')
.select(ids.imageA, ids.imageB)
.doubleClick(550, 550, { target: 'selection', handle: 'bottom_right' })
.expectToBeIn('select.idle')
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(100, 100).pointerUp()
```
## Running tests
```bash
cd packages/tldraw && yarn test run
cd packages/tldraw && yarn test run --grep "arrow"
cd packages/editor && yarn test run --grep "Vec"
# Watch mode
cd packages/tldraw && yarn test
```
## Key patterns summary
- Use `createShapeId()` for shape IDs
- Use `vi.useFakeTimers()` for time-dependent behavior
- Clear shapes in `beforeEach`, dispose in `afterEach`
- Test in `packages/tldraw` for shapes/tools
- Use `expectToBeIn()` for state machine assertions
- Use `toMatchObject()` for partial matching
- Use `toCloselyMatchObject()` for floating point values
- Mock with `vi.spyOn()` and always `mockRestore()`