# 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 ', '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; 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 ', '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 ', '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(); 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(); 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 { 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 { 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