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
- Colocated unit tests validate single functions/classes in isolation
- Feature-level tests exercise the full feature with mocked repos
- E2E tests prove the app works end-to-end (minimal smoke specs initially)
- Per-feature containers allow tests to rebind without global state
- Mock repos are the default; only use real Payload in dedicated integration tests