MeshiTrack/docs/instructions/testing.md

517 lines
15 KiB
Markdown

# Testing Best Practices — MeshiTrack
> Instruction file for testing strategy, tools, and patterns across the monorepo.
## Testing Stack
| Layer | Tool | Package |
| ----------------- | ------------------------------ | ----------------- |
| Unit tests (API) | Vitest | `packages/api` |
| Unit tests (Web) | Vitest | `packages/web` |
| Component tests | React Testing Library | `packages/web` |
| Integration tests | Vitest + mongodb-memory-server | `packages/api` |
| E2E tests | Playwright | root/e2e |
| API mocking (web) | MSW (Mock Service Worker) | `packages/web` |
| Shared validation | Vitest | `packages/shared` |
## Directory Structure
```
packages/api/
├── src/
│ └── modules/products/
│ ├── products.routes.test.ts # Route tests (inject)
│ ├── products.service.test.ts # Service unit tests
│ └── products.integration.test.ts # Integration (mongodb-memory-server)
└── vitest.config.ts
packages/web/
├── src/
│ └── components/features/products/
│ ├── ProductCard.tsx
│ └── __tests__/
│ └── ProductCard.test.tsx
└── e2e/ # or root-level
├── playwright.config.ts
└── tests/
└── products.spec.ts
```
## Unit Testing (Fastify API)
### Test one thing at a time
Each test file tests a single class or route module. Mock all dependencies.
### Use `ClassName.name` for `describe` labels
Use the constructor's `.name` property instead of string literals for `describe` block labels. This keeps test output accurate after refactors and avoids stale string mismatches:
```typescript
// correct
describe(NotFoundError.name, () => { ... });
// avoid
describe('NotFoundError', () => { ... });
```
### Service test pattern
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProductsService } from './products.service.js';
import { ProductsRepository } from './products.repository.js';
describe('ProductsService', () => {
const mockRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
findByBarcode: vi.fn(),
};
let service: ProductsService;
beforeEach(() => {
vi.clearAllMocks();
service = new ProductsService({ productsRepository: mockRepo as any });
});
describe('create', () => {
it('should create a product with household scope', async () => {
const dto = {
name: 'Chicken Breast',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
};
const expected = { id: '1', householdId: 'hh1', createdBy: 'user1', ...dto };
mockRepo.create.mockResolvedValue(expected);
const result = await service.create('hh1', 'user1', dto);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Chicken Breast',
householdId: 'hh1',
createdBy: 'user1',
}),
);
expect(result.id).toBe('1');
});
it('should throw ConflictError for duplicate barcode', async () => {
mockRepo.findByBarcode.mockResolvedValue({ id: 'existing' });
await expect(service.create('hh1', 'user1', { ...dto, barcode: '123456' })).rejects.toThrow(
ConflictError,
);
});
});
});
```
### Route test pattern (using Fastify inject)
```typescript
import { describe, it, expect } from 'vitest';
import Fastify from 'fastify';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import productRoutes from './products.routes.js';
describe('Products Routes', () => {
async function buildTestApp() {
const app = Fastify({ logger: false });
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
// Register mocked auth/DI as needed
await app.register(productRoutes);
return app;
}
it('GET /api/v1/products returns 200', async () => {
const app = await buildTestApp();
const response = await app.inject({
method: 'GET',
url: '/api/v1/products',
headers: {
authorization: 'Bearer <test-jwt>',
'x-household-id': 'test-household-id',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toHaveProperty('items');
});
});
```
## Integration Testing (Fastify API)
### Use `mongodb-memory-server` for real MongoDB
```typescript
import { MongoMemoryServer } from 'mongodb-memory-server';
import mongoose from 'mongoose';
import Fastify from 'fastify';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import { fastifyAwilixPlugin } from '@fastify/awilix';
describe('ProductsModule Integration', () => {
let app: ReturnType<typeof Fastify>;
let mongod: MongoMemoryServer;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
const uri = mongod.getUri();
app = Fastify({ logger: false });
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
await app.register(fastifyAwilixPlugin, { disposeOnClose: true, disposeOnResponse: true, strictBooleanEnforced: true });
// Connect mongoose to in-memory MongoDB
await mongoose.connect(uri);
// Register route modules (with test auth mock)
await app.register(productRoutes);
return { app, mongod };
}
afterAll(async () => {
await app.close();
await mongoose.disconnect();
await mongod.stop();
});
it('POST /api/v1/products → creates and returns product', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/products',
payload: {
name: 'Chicken Breast',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
},
headers: {
authorization: 'Bearer <test-jwt>',
'x-household-id': 'test-hh',
},
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.name).toBe('Chicken Breast');
expect(body.id).toBeDefined();
});
it('GET /api/v1/products → returns paginated results', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/products',
headers: {
authorization: 'Bearer <test-jwt>',
'x-household-id': 'test-hh',
},
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toBeInstanceOf(Array);
expect(body.pagination).toHaveProperty('hasMore');
});
});
```
## Component Testing (Next.js Web)
### React Testing Library patterns
```typescript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCard } from '../ProductCard';
const mockProduct = {
id: '1',
name: 'Chicken Breast',
category: 'meat',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
};
describe('ProductCard', () => {
it('displays product name and calories', () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
expect(screen.getByText(/165 kcal/)).toBeInTheDocument();
});
it('calls onEdit when edit button clicked', async () => {
const onEdit = vi.fn();
render(<ProductCard product={mockProduct} onEdit={onEdit} />);
await userEvent.click(screen.getByRole('button', { name: /edit/i }));
expect(onEdit).toHaveBeenCalledWith('1');
});
});
```
### Query priority (from React Testing Library docs)
1. `getByRole` — accessible role + name (best)
2. `getByLabelText` — form fields
3. `getByPlaceholderText` — if no label
4. `getByText` — text content
5. `getByTestId` — last resort
### Avoid testing implementation details
```typescript
// Bad: testing internal state
expect(component.state.isOpen).toBe(true);
// Good: testing visible behavior
expect(screen.getByRole('dialog')).toBeVisible();
```
## API Mocking with MSW
### Setup MSW for web tests
```typescript
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('*/api/v1/products', () => {
return HttpResponse.json({
data: [
{ id: '1', name: 'Chicken', category: 'meat', nutrition: { calories: 165 } },
{ id: '2', name: 'Rice', category: 'grains', nutrition: { calories: 130 } },
],
pagination: { cursor: null, hasMore: false },
});
}),
http.post('*/api/v1/products', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
}),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```
## E2E Testing with Playwright
### Configuration
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e/tests',
baseURL: 'http://localhost:3000',
webServer: [
{
command: 'docker compose up -d && npm run dev',
url: 'http://localhost:3000',
timeout: 120_000,
reuseExistingServer: !process.env.CI,
},
],
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});
```
### Test pattern
```typescript
import { test, expect } from '@playwright/test';
test.describe('Product Library', () => {
test.beforeEach(async ({ page }) => {
// Login via Keycloak (use API to get token, set cookie)
await loginAsTestUser(page);
});
test('can create a new product', async ({ page }) => {
await page.goto('/products');
await page.click('button:has-text("Add Product")');
await page.fill('[name="name"]', 'Test Product');
await page.selectOption('[name="category"]', 'meat');
await page.fill('[name="servingSize"]', '100');
await page.fill('[name="nutrition.calories"]', '200');
await page.click('button:has-text("Save")');
await expect(page.getByText('Test Product')).toBeVisible();
});
test('can search products by name', async ({ page }) => {
await page.goto('/products');
await page.fill('[placeholder="Search products..."]', 'chicken');
await expect(page.getByText('Chicken Breast')).toBeVisible();
await expect(page.getByText('Rice')).not.toBeVisible();
});
});
```
## Shared Package Testing
### Test Zod schemas directly
```typescript
import { CreateProductSchema, NutritionInfoSchema } from '../validation';
describe('CreateProductSchema', () => {
it('accepts valid product input', () => {
const input = {
name: 'Chicken Breast',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
};
expect(CreateProductSchema.safeParse(input).success).toBe(true);
});
it('rejects negative calories', () => {
const input = {
name: 'Bad Product',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: -10, protein: 0, carbs: 0, fat: 0 },
};
const result = CreateProductSchema.safeParse(input);
expect(result.success).toBe(false);
});
it('trims whitespace from name', () => {
const input = {
name: ' Chicken Breast ',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
};
const result = CreateProductSchema.parse(input);
expect(result.name).toBe('Chicken Breast');
});
});
```
## Test Data Factories
### Create reusable test data builders
```typescript
// test/factories/product.factory.ts
import { faker } from '@faker-js/faker';
import { ProductCategory, ServingUnit } from '@meshitrack/shared';
export function buildProduct(overrides: Partial<Product> = {}): Product {
return {
id: faker.string.uuid(),
householdId: 'test-household-1',
name: faker.food.ingredient(),
brand: faker.company.name(),
category: faker.helpers.arrayElement(Object.values(ProductCategory)),
servingSize: faker.number.int({ min: 1, max: 500 }),
servingUnit: faker.helpers.arrayElement(Object.values(ServingUnit)),
nutrition: buildNutrition(),
tags: [],
createdBy: 'test-user-1',
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}
export function buildNutrition(overrides: Partial<NutritionInfo> = {}): NutritionInfo {
return {
calories: faker.number.int({ min: 0, max: 900 }),
protein: faker.number.float({ min: 0, max: 60, fractionDigits: 1 }),
carbs: faker.number.float({ min: 0, max: 100, fractionDigits: 1 }),
fat: faker.number.float({ min: 0, max: 50, fractionDigits: 1 }),
...overrides,
};
}
```
## Coverage Targets
All packages enforce coverage thresholds via `vitest.config.ts`. CI will fail if coverage drops below these levels.
| Scope | Lines | Functions | Branches | Statements |
| ----------------- | ----- | --------- | -------- | ---------- |
| `packages/api` | 100% | 100% | 90% | 100% |
| `packages/shared` | 100% | 100% | 90% | 100% |
| `packages/web` | TBD | TBD | TBD | TBD |
### Coverage Provider
- **V8** (`@vitest/coverage-v8`) — native V8 engine coverage, fast, zero-config
- Reports: `text`, `lcov`, `json-summary`, `html`
- Reports directory: `./coverage` (gitignored)
### Excluding Code from Coverage
Use `/* v8 ignore start */` / `/* v8 ignore stop */` for code that cannot be unit-tested:
- Entry-point bootstrap blocks (`main.ts` top-level `if`)
- Mongoose schema defaults that only run at document creation
- Framework-internal error handler branches (Zod validation, response serialization)
### Best Practices
- Mark untestable lines with `/* v8 ignore */` comments explaining why
- Keep thresholds at 100% for lines/functions/statements — this forces new code to include tests
- Branch threshold at 90% accommodates config ternaries and null-coalescing guards
- Run `npm run test:cov` before merging to verify thresholds
## Running Tests
**`npx` is banned. Never use it.** Always use `npm run <script>` to invoke Vitest, Playwright, or any other tool.
```bash
# All tests (via turbo)
npm run test
# Specific package
npm run test -w packages/api
# With coverage (specific package)
npm run test:cov -w packages/api
# All packages with coverage (via turbo)
npm run test:cov
# Watch mode (development)
npm run test -- --watch -w packages/api
```