15 KiB
15 KiB
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:
// correct
describe(NotFoundError.name, () => { ... });
// avoid
describe('NotFoundError', () => { ... });
Service test pattern
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)
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
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
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)
getByRole— accessible role + name (best)getByLabelText— form fieldsgetByPlaceholderText— if no labelgetByText— text contentgetByTestId— last resort
Avoid testing implementation details
// 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
// 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
// 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
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
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
// 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.tstop-levelif) - 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:covbefore 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.
# 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