Files
agentic-dev/docs/guides/testing-strategy.md

5.8 KiB

Testing Strategy

A layered approach: per-feature DI containers + colocated unit tests + Playwright e2e.

Test placement

Level Location Tool Example
Unit (colocated) packages/<feature>/src/entities/article.test.ts Vitest Schema validation, type guards
Feature level packages/<feature>/tests/feature/<name>.feature.test.ts Vitest + DI container Use cases, controllers, integration
E2E (app) apps/web-next/e2e/blog.spec.ts Playwright Full user flow across frontend + backend

Colocated vs feature-level: Colocated tests (*.test.ts next to source) test isolated units. Feature-level tests (tests/ folder) wire the DI container and test interactions between layers.

Per-feature DI in tests

Each feature owns its own container with mock implementations. Tests can rebind specific implementations without touching other features.

Create packages/<feature>/tests/feature-test-setup.ts:

import { container } from "@/di/container.js";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface.js";
import { ARTICLES_REPOSITORY } from "@/di/symbols.js";

// Create a scoped test container
export function getTestContainer() {
  return container;
}

// Rebind a specific implementation for a test
export function rebindRepository(impl: IArticlesRepository) {
  container.rebind(ARTICLES_REPOSITORY).toConstantValue(impl);
}

Use in test:

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getTestContainer, rebindRepository } from "./feature-test-setup.js";
import { CreateArticleUseCase } from "@/application/use-cases/create-article.use-case.js";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository.js";

describe("CreateArticleUseCase", () => {
  let container: Container;

  beforeEach(() => {
    container = getTestContainer();
    rebindRepository(new MockArticlesRepository()); // Fresh mock per test
  });

  it("creates an article", async () => {
    const useCase = container.get(CreateArticleUseCase);
    const result = await useCase.execute({ title: "Test" });
    expect(result.title).toBe("Test");
  });
});

Vitest setup per package

Each feature package has vitest.config.ts:

import { defineConfig } from "vitest/config";
import path from "path";

export default defineConfig({
  test: {
    environment: "node",
    globals: true,
    include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
    setupFiles: [],
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

The @/ alias resolves to src/ — use it in tests to import from the feature: import { Article } from "@/entities/index.js".

Playwright setup (apps)

Each app has playwright.config.ts:

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: "list",
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
  },
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
  ],
  webServer: {
    command: "pnpm dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 60_000,
  },
});

The webServer block auto-starts the dev server before tests. Set reuseExistingServer: true locally to reuse a running dev server; CI always starts fresh.

Smoke spec example

apps/web-next/e2e/home.spec.ts:

import { test, expect } from "@playwright/test";

test("home page renders site name + nav", async ({ page }) => {
  await page.goto("/");
  await expect(page.locator("h1").first()).toBeVisible();
  await expect(page.locator("nav a").first()).toBeVisible();
});

Mocking Payload in feature tests

Option 1: Mock at DI level (preferred)

Create a test mock repository and rebind it:

class TestArticlesRepository extends MockArticlesRepository {
  // Override behaviors as needed for the test
  async getPublished() {
    return [{ id: "1", title: "Published", status: "published", ... }];
  }
}

beforeEach(() => {
  rebindRepository(new TestArticlesRepository());
});

Option 2: Mock the payload module (for infrastructure tests)

Edit packages/blog/vitest.config.ts:

export default defineConfig({
  test: {
    // ...
    globals: true,
    // Mock payload module globally
    mockReset: true,
  },
});

In your test file:

import { vi } from "vitest";
import { getPayload } from "payload";

vi.mock("payload", () => ({
  getPayload: vi.fn().mockResolvedValue({
    findByID: vi.fn().mockResolvedValue({ id: "1", title: "Article" }),
  }),
}));

Running tests

# All tests (unit + feature + e2e)
pnpm test

# Just unit tests
pnpm test --filter "@repo/blog" -- src/

# Just feature tests
pnpm test --filter "@repo/blog" -- tests/

# Just e2e
pnpm test:e2e

# E2E with UI
pnpm test:e2e -- --ui

CI integration

Root package.json:

{
  "scripts": {
    "test": "turbo run test",
    "test:e2e": "turbo run test:e2e"
  }
}

Root turbo.json:

{
  "tasks": {
    "test": {
      "outputs": ["coverage/**"],
      "cache": false
    },
    "test:e2e": {
      "dependsOn": ["^build"],
      "cache": false
    }
  }
}

Key principles

  1. Colocated unit tests validate single functions/classes in isolation
  2. Feature-level tests exercise the full feature with mocked repos
  3. E2E tests prove the app works end-to-end (minimal smoke specs initially)
  4. Per-feature containers allow tests to rebind without global state
  5. Mock repos are the default; only use real Payload in dedicated integration tests