# 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
```
