docs(guides): rewrite testing-strategy for vertical features (per-feature DI, colocated tests, Playwright)
This commit is contained in:
@@ -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/<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 |
|
||||
|
||||
```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/<feature>/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
|
||||
|
||||
Reference in New Issue
Block a user