From a4d6fa0cb5a67dde1cf9be59f31ee8bd0afe69ed Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 5 May 2026 09:33:23 +0200 Subject: [PATCH] docs(guides): rewrite testing-strategy for vertical features (per-feature DI, colocated tests, Playwright) --- docs/guides/testing-strategy.md | 250 ++++++++++++++++++++++++++++---- 1 file changed, 223 insertions(+), 27 deletions(-) diff --git a/docs/guides/testing-strategy.md b/docs/guides/testing-strategy.md index ac6a85c..a985e89 100644 --- a/docs/guides/testing-strategy.md +++ b/docs/guides/testing-strategy.md @@ -1,41 +1,237 @@ # Testing Strategy -## Test Layers +A layered approach: per-feature DI containers + colocated unit tests + Playwright e2e. -| Layer | Tool | What to Test | -|---|---|---| -| Entities | Vitest (unit) | Zod schema validation, error classes | -| Use Cases | Vitest (unit) | Business logic with mock implementations via DI | -| Controllers | Vitest (unit) | Input validation, use case delegation, error mapping | -| Infrastructure | Vitest (integration) | Real DB via test containers, Payload API calls | -| UI Components | Vitest + Storybook | Rendering, props, accessibility | -| Full App | Playwright (E2E) | User flows across both Next.js and TanStack Start | +## Test placement -## Running Tests +| 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 | -```bash -pnpm test # All tests via Turborepo -cd packages/core && pnpm vitest run # Core unit tests only -cd packages/core && pnpm vitest watch # Core tests in watch mode -``` +**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. -## Test Pattern (DI Container) +## Per-feature DI in tests -All tests that use the DI container must initialize and destroy it: +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 "reflect-metadata"; -import { beforeEach, afterEach } from "vitest"; -import { initializeContainer, destroyContainer } from "@/di/container.js"; +import { container } from "@/di/container.js"; +import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface.js"; +import { ARTICLES_REPOSITORY } from "@/di/symbols.js"; -beforeEach(() => { initializeContainer(); }); -afterEach(() => { destroyContainer(); }); +// 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); +} ``` -This ensures each test gets fresh mock instances (singleton scope resets). +Use in test: -## Test File Location +```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"; -- Core tests: `packages/core/tests/unit/{use-cases,controllers}/{domain}/` -- UI tests: co-located next to component (`*.test.tsx`) -- E2E tests: `tests/e2e/` +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