517 lines
15 KiB
Markdown
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` | 15% | 14% | 13% | 15% |
|
|
|
|
### 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
|
|
```
|