MeshiTrack/docs/instructions/testing.md

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)

  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

// 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 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.

# 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