# Testing Strategy A layered approach: per-feature DI containers + colocated unit tests + Playwright e2e. ## Test placement | Level | Location | Tool | Example | |---|---|---|---| | **Unit (colocated)** | `packages//src/entities/article.test.ts` | Vitest | Schema validation, type guards | | **Feature level** | `packages//tests/feature/.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//tests/feature-test-setup.ts`: ```typescript 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: ```typescript 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`: ```typescript 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`: ```typescript 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`: ```typescript 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: ```typescript 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`: ```typescript export default defineConfig({ test: { // ... globals: true, // Mock payload module globally mockReset: true, }, }); ``` In your test file: ```typescript 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 ```bash # 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`: ```json { "scripts": { "test": "turbo run test", "test:e2e": "turbo run test:e2e" } } ``` Root `turbo.json`: ```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