by thetestingacademy
Testing patterns for Turborepo and pnpm monorepos covering workspace dependency testing, affected package detection, parallel test execution, and shared test utilities
npx @qaskills/cli add turborepo-monorepo-testingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert QA engineer specializing in testing Turborepo and pnpm monorepo projects. When the user asks you to write, review, or debug tests in a monorepo context, or set up shared test infrastructure across workspaces, follow these detailed instructions.
Always organize monorepo testing with this structure:
my-monorepo/
turbo.json
vitest.workspace.ts
pnpm-workspace.yaml
packages/
shared/
src/
index.ts
utils/
__tests__/
utils.test.ts
vitest.config.ts
package.json
web/
src/
app/
components/
__tests__/
unit/
integration/
e2e/
home.spec.ts
vitest.config.ts
playwright.config.ts
package.json
api/
src/
routes/
services/
__tests__/
unit/
integration/
vitest.config.ts
package.json
test-utils/
src/
fixtures/
user.fixture.ts
product.fixture.ts
mocks/
api-client.mock.ts
database.mock.ts
helpers/
render.tsx
setup-server.ts
index.ts
package.json
config/
vitest/
base.config.ts
react.config.ts
node.config.ts
tsconfig/
base.json
react.json
node.json
package.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalEnv": ["NODE_ENV", "CI"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"inputs": [
"src/**",
"__tests__/**",
"vitest.config.*",
"tsconfig.json"
],
"env": ["DATABASE_URL", "TEST_DATABASE_URL"]
},
"test:unit": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"inputs": [
"src/**",
"__tests__/unit/**",
"vitest.config.*"
]
},
"test:integration": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"inputs": [
"src/**",
"__tests__/integration/**",
"vitest.config.*"
],
"env": ["DATABASE_URL", "TEST_DATABASE_URL"]
},
"test:e2e": {
"dependsOn": ["^build", "build"],
"outputs": ["test-results/**", "playwright-report/**"],
"inputs": [
"e2e/**",
"playwright.config.*",
"src/**"
]
},
"lint": {
"dependsOn": ["^build"],
"inputs": [
"src/**",
"__tests__/**",
"e2e/**",
".eslintrc.*",
"eslint.config.*"
]
},
"typecheck": {
"dependsOn": ["^build"],
"inputs": [
"src/**",
"__tests__/**",
"tsconfig.json"
]
}
}
}
// vitest.workspace.ts (root)
import { defineWorkspace } from 'vitest/config';
export default defineWorkspace([
'packages/shared/vitest.config.ts',
'packages/web/vitest.config.ts',
'packages/api/vitest.config.ts',
]);
// packages/config/vitest/base.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
clearMocks: true,
restoreMocks: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'dist/',
'**/*.d.ts',
'**/*.config.*',
'**/index.ts',
'**/__tests__/**',
'**/test-utils/**',
],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
reporters: process.env.CI
? ['default', 'junit']
: ['default'],
outputFile: process.env.CI
? { junit: './test-results/junit.xml' }
: undefined,
},
});
// packages/api/vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config';
import baseConfig from '@repo/config/vitest/base.config';
export default mergeConfig(
baseConfig,
defineConfig({
test: {
environment: 'node',
include: ['__tests__/**/*.test.ts'],
setupFiles: ['__tests__/setup.ts'],
testTimeout: 10000,
pool: 'forks',
poolOptions: {
forks: {
singleFork: false,
},
},
},
})
);
// packages/web/vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import baseConfig from '@repo/config/vitest/base.config';
export default mergeConfig(
baseConfig,
defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
include: ['__tests__/**/*.test.{ts,tsx}'],
setupFiles: ['__tests__/setup.ts'],
css: true,
},
})
);
{
"name": "@repo/test-utils",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./fixtures": "./src/fixtures/index.ts",
"./mocks": "./src/mocks/index.ts",
"./helpers": "./src/helpers/index.ts"
},
"dependencies": {
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.0.0",
"msw": "^2.0.0"
},
"peerDependencies": {
"vitest": "^2.0.0"
}
}
// packages/test-utils/src/fixtures/user.fixture.ts
import { faker } from '@faker-js/faker';
export interface TestUser {
id: string;
email: string;
name: string;
role: 'admin' | 'user' | 'editor';
createdAt: Date;
}
export function createTestUser(overrides: Partial<TestUser> = {}): TestUser {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
role: 'user',
createdAt: faker.date.recent(),
...overrides,
};
}
export function createTestUsers(count: number, overrides: Partial<TestUser> = {}): TestUser[] {
return Array.from({ length: count }, () => createTestUser(overrides));
}
export const adminUser = createTestUser({ role: 'admin', email: 'admin@test.com' });
export const regularUser = createTestUser({ role: 'user', email: 'user@test.com' });
// packages/test-utils/src/fixtures/product.fixture.ts
import { faker } from '@faker-js/faker';
export interface TestProduct {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
}
export function createTestProduct(overrides: Partial<TestProduct> = {}): TestProduct {
return {
id: faker.string.uuid(),
name: faker.commerce.productName(),
price: parseFloat(faker.commerce.price({ min: 1, max: 500 })),
category: faker.commerce.department(),
inStock: true,
...overrides,
};
}
// packages/test-utils/src/mocks/api-client.mock.ts
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { createTestUser, createTestProduct } from '../fixtures';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json({
users: [createTestUser(), createTestUser()],
total: 2,
});
}),
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json(
createTestUser({ id: params.id as string })
);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json(
createTestUser(body),
{ status: 201 }
);
}),
http.get('/api/products', () => {
return HttpResponse.json({
products: [createTestProduct(), createTestProduct()],
total: 2,
});
}),
];
export const server = setupServer(...handlers);
export function setupMockServer() {
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
}
// packages/test-utils/src/helpers/render.tsx
import React, { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// Add your app-wide providers here
function AllProviders({ children }: { children: React.ReactNode }) {
return (
<>
{children}
</>
);
}
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
const user = userEvent.setup();
return {
user,
...render(ui, { wrapper: AllProviders, ...options }),
};
}
export { render, screen, waitFor, within } from '@testing-library/react';
export { userEvent };
# Run tests only for packages affected by changes since main
pnpm turbo test --filter=...[origin/main]
# Run tests for a specific package and its dependents
pnpm turbo test --filter=@repo/shared...
# Run tests for packages affected by changes in the last commit
pnpm turbo test --filter=...[HEAD~1]
# Dry run to see what would be tested
pnpm turbo test --filter=...[origin/main] --dry-run
// scripts/run-affected-tests.ts
import { execSync } from 'child_process';
function getAffectedPackages(): string[] {
const baseBranch = process.env.BASE_BRANCH || 'origin/main';
try {
const output = execSync(
`pnpm turbo test --filter=...[${baseBranch}] --dry-run=json`,
{ encoding: 'utf-8' }
);
const result = JSON.parse(output);
return result.packages || [];
} catch {
console.warn('Could not determine affected packages, running all tests');
return ['*'];
}
}
function runAffectedTests(): void {
const affected = getAffectedPackages();
if (affected.length === 0) {
console.log('No packages affected, skipping tests');
process.exit(0);
}
console.log(`Running tests for affected packages: ${affected.join(', ')}`);
const filterArgs = affected
.map((pkg) => `--filter=${pkg}`)
.join(' ');
try {
execSync(`pnpm turbo test ${filterArgs}`, {
stdio: 'inherit',
});
} catch {
process.exit(1);
}
}
runAffectedTests();
// packages/api/__tests__/integration/shared-integration.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { parseSkillMd, serializeSkillMd } from '@repo/shared';
import { SkillService } from '../../src/services/skill-service';
import { createTestDatabase, cleanupDatabase } from '@repo/test-utils/helpers';
describe('Shared + API Integration', () => {
let db: ReturnType<typeof createTestDatabase>;
beforeAll(async () => {
db = await createTestDatabase();
});
afterAll(async () => {
await cleanupDatabase(db);
});
it('should parse SKILL.md and store in database via service', async () => {
const markdown = `---
name: "Test Skill"
description: "A test skill for integration testing"
version: 1.0.0
author: test
tags: [testing]
testingTypes: [unit]
frameworks: [vitest]
languages: [typescript]
domains: [web]
agents: [claude-code]
---
# Test Skill
This is a test skill body.
`;
// Uses @repo/shared parser
const parsed = parseSkillMd(markdown);
expect(parsed.frontmatter.name).toBe('Test Skill');
// Uses API service to store
const service = new SkillService(db);
const stored = await service.createSkill(parsed);
expect(stored.id).toBeDefined();
expect(stored.name).toBe('Test Skill');
// Round-trip: retrieve and serialize back
const retrieved = await service.getSkill(stored.id);
const serialized = serializeSkillMd(retrieved);
expect(serialized).toContain('name: "Test Skill"');
expect(serialized).toContain('# Test Skill');
});
it('should validate shared types are compatible with API endpoints', async () => {
const service = new SkillService(db);
const skills = await service.listSkills({
testingTypes: ['unit'],
languages: ['typescript'],
page: 1,
limit: 10,
});
// Verify the response matches the shared SkillSummary type
for (const skill of skills.items) {
expect(skill).toHaveProperty('id');
expect(skill).toHaveProperty('name');
expect(skill).toHaveProperty('description');
expect(skill).toHaveProperty('version');
expect(Array.isArray(skill.tags)).toBe(true);
expect(Array.isArray(skill.testingTypes)).toBe(true);
}
});
});
// packages/web/__tests__/integration/api-integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestUser, createTestProduct } from '@repo/test-utils/fixtures';
import { server } from '@repo/test-utils/mocks';
describe('Web + API Integration', () => {
beforeAll(() => server.listen());
afterAll(() => server.close());
it('should fetch and transform API data for UI rendering', async () => {
const response = await fetch('/api/users');
const data = await response.json();
expect(data.users).toHaveLength(2);
expect(data.users[0]).toHaveProperty('name');
expect(data.users[0]).toHaveProperty('email');
});
it('should handle API error responses gracefully', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
})
);
const response = await fetch('/api/users');
expect(response.status).toBe(500);
const data = await response.json();
expect(data.error).toBe('Internal Server Error');
});
});
// packages/web/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: path.join(__dirname, 'playwright-report') }],
process.env.CI ? ['github'] : ['list'],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 7'] },
},
],
webServer: {
command: 'pnpm turbo build --filter=@repo/web && pnpm turbo start --filter=@repo/web',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
# .github/workflows/test.yml
name: Test
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for affected detection
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Turbo remote cache
- name: Configure Turbo cache
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
turbo-${{ runner.os }}-
# Run only affected unit and integration tests
- name: Run affected tests
run: pnpm turbo test:unit test:integration --filter=...[origin/main]
# Always run full lint
- name: Lint
run: pnpm turbo lint
# Type checking
- name: Type check
run: pnpm turbo typecheck
# Upload coverage from all packages
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-reports
path: packages/*/coverage/
e2e:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm --filter @repo/web exec playwright install --with-deps
- name: Build all packages
run: pnpm turbo build
- name: Run E2E tests
run: pnpm turbo test:e2e
- name: Upload E2E report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: packages/web/playwright-report/
{
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"test": "turbo test",
"test:unit": "turbo test:unit",
"test:integration": "turbo test:integration",
"test:e2e": "turbo test:e2e",
"test:affected": "turbo test --filter=...[origin/main]",
"test:watch": "vitest --workspace=vitest.workspace.ts",
"test:coverage": "turbo test -- --coverage",
"lint": "turbo lint",
"typecheck": "turbo typecheck",
"format": "prettier --write .",
"format:check": "prettier --check .",
"clean": "turbo clean && rm -rf node_modules"
}
}
{
"name": "@repo/api",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup src/index.ts",
"test": "vitest run",
"test:unit": "vitest run __tests__/unit",
"test:integration": "vitest run __tests__/integration",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src __tests__",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist coverage"
}
}
// packages/api/__tests__/unit/dependencies.test.ts
import { describe, it, expect } from 'vitest';
describe('Workspace Dependency Resolution', () => {
it('should import shared types correctly', async () => {
const shared = await import('@repo/shared');
expect(shared).toHaveProperty('parseSkillMd');
expect(shared).toHaveProperty('serializeSkillMd');
expect(typeof shared.parseSkillMd).toBe('function');
});
it('should import test-utils fixtures', async () => {
const { createTestUser } = await import('@repo/test-utils/fixtures');
const user = createTestUser();
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('email');
expect(user).toHaveProperty('name');
});
it('should verify shared constants are accessible', async () => {
const { AGENTS, CATEGORIES } = await import('@repo/shared');
expect(Array.isArray(AGENTS)).toBe(true);
expect(AGENTS.length).toBeGreaterThan(0);
expect(Array.isArray(CATEGORIES)).toBe(true);
});
it('should verify shared schemas validate correctly', async () => {
const { skillFrontmatterSchema } = await import('@repo/shared');
const validData = {
name: 'Test Skill',
description: 'A valid description that is long enough',
version: '1.0.0',
author: 'test',
tags: ['testing'],
testingTypes: ['unit'],
frameworks: ['vitest'],
languages: ['typescript'],
domains: ['web'],
agents: ['claude-code'],
};
const result = skillFrontmatterSchema.safeParse(validData);
expect(result.success).toBe(true);
});
});
// scripts/verify-build-outputs.test.ts
import { describe, it, expect } from 'vitest';
import { existsSync, readFileSync } from 'fs';
import path from 'path';
describe('Build Output Verification', () => {
const packages = ['shared', 'cli', 'sdk', 'api'];
for (const pkg of packages) {
const distPath = path.join(__dirname, '..', 'packages', pkg, 'dist');
it(`${pkg}: dist directory should exist after build`, () => {
expect(existsSync(distPath)).toBe(true);
});
it(`${pkg}: should have a valid entry point`, () => {
const pkgJsonPath = path.join(__dirname, '..', 'packages', pkg, 'package.json');
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
const mainEntry = pkgJson.main || pkgJson.exports?.['.'];
expect(mainEntry).toBeDefined();
const resolvedEntry = path.join(__dirname, '..', 'packages', pkg, mainEntry);
expect(existsSync(resolvedEntry)).toBe(true);
});
it(`${pkg}: TypeScript declarations should be present`, () => {
const dtsFiles = require('fast-glob').sync('**/*.d.ts', { cwd: distPath });
expect(dtsFiles.length).toBeGreaterThan(0);
});
}
});
// packages/test-utils/src/helpers/setup-database.ts
import { execSync } from 'child_process';
import { randomUUID } from 'crypto';
interface TestDatabaseConfig {
connectionString: string;
databaseName: string;
cleanup: () => Promise<void>;
}
export async function createTestDatabase(): Promise<TestDatabaseConfig> {
const baseName = 'test_db';
const databaseName = `${baseName}_${randomUUID().slice(0, 8)}`;
const baseUrl = process.env.TEST_DATABASE_URL || 'postgresql://localhost:5432';
// Create isolated test database
execSync(`createdb ${databaseName}`, {
env: { ...process.env, PGHOST: 'localhost' },
});
const connectionString = `${baseUrl}/${databaseName}`;
// Run migrations
execSync('pnpm drizzle-kit push', {
env: { ...process.env, DATABASE_URL: connectionString },
cwd: process.cwd(),
});
return {
connectionString,
databaseName,
cleanup: async () => {
execSync(`dropdb --if-exists ${databaseName}`, {
env: { ...process.env, PGHOST: 'localhost' },
});
},
};
}
// packages/api/__tests__/setup.ts
import { beforeAll, afterAll } from 'vitest';
import { server } from '@repo/test-utils/mocks';
// Each worker gets its own mock server
beforeAll(() => {
server.listen({ onUnhandledRequest: 'warn' });
});
afterAll(() => {
server.close();
});
// vitest.config.ts with parallel configuration
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Run test files in parallel (default)
fileParallelism: true,
// Each test file runs in its own worker thread
pool: 'threads',
poolOptions: {
threads: {
// Match CPU core count for optimal parallelism
minThreads: 1,
maxThreads: process.env.CI ? 4 : undefined,
},
},
// Isolate each test file to prevent state leakage
isolate: true,
// Sequence configuration for deterministic order when needed
sequence: {
shuffle: true, // Randomize to detect order dependencies
},
},
});
// scripts/merge-coverage.ts
import { execSync } from 'child_process';
import { existsSync, mkdirSync } from 'fs';
import path from 'path';
const rootDir = path.resolve(__dirname, '..');
const mergedDir = path.join(rootDir, 'coverage-merged');
if (!existsSync(mergedDir)) {
mkdirSync(mergedDir, { recursive: true });
}
const packages = ['shared', 'web', 'api', 'cli', 'sdk'];
// Collect all coverage JSON files
const coverageFiles = packages
.map((pkg) => path.join(rootDir, 'packages', pkg, 'coverage', 'coverage-final.json'))
.filter((f) => existsSync(f));
if (coverageFiles.length === 0) {
console.log('No coverage files found. Run tests with coverage first.');
process.exit(0);
}
// Merge using nyc
const fileArgs = coverageFiles.map((f) => `--include="${f}"`).join(' ');
execSync(
`npx nyc merge ${coverageFiles.map((f) => path.dirname(f)).join(' ')} ${mergedDir}/coverage.json`,
{ stdio: 'inherit' }
);
// Generate merged report
execSync(
`npx nyc report --temp-dir=${mergedDir} --reporter=text --reporter=html --report-dir=${mergedDir}/html`,
{ stdio: 'inherit' }
);
console.log(`Merged coverage report generated at ${mergedDir}/html/index.html`);
inputs array for test tasks so Turbo can compute hashes correctly. Missing inputs cause stale cache hits."@repo/shared": "workspace:*" in package.json to ensure pnpm links internal packages instead of fetching from npm.@repo/test-utils instead of duplicating across packages.--filter=...[origin/main] on PRs but run the full pnpm turbo test on main branch merges.node_modules/.cache/turbo between CI runs. Turbo will skip unchanged packages.vitest --workspace=vitest.workspace.ts in watch mode during development to get instant feedback across all packages.tsc --noEmit as a separate Turbo task (typecheck) instead of bundling it with tests. It catches different classes of errors.pnpm-workspace.yaml catalog or syncpack.--filter, Turbo runs every package's tests. Always use affected detection for PRs.main: ./src/index.ts), not compiled output, during development and testing.dependsOn: ["^build"] for test tasks -- If test tasks don't depend on upstream builds, shared package changes won't be picked up, causing false positives.mergeConfig to extend per-package.inputs for Turbo tasks -- Without explicit inputs, Turbo hashes all files, causing unnecessary cache invalidation.dependsOn: ["build"] for the web package plus ["^build"] for all dependencies.@repo/shared) instead of relative paths (../../shared/src) to avoid breakage when packages move.clean script that removes node_modules/.cache/turbo and run it when debugging mysterious test failures.pnpm turbo testpnpm turbo test --filter=...[origin/main]pnpm turbo test --filter=@repo/apipnpm turbo test --filter=@repo/shared...pnpm vitest --workspace=vitest.workspace.tspnpm turbo test --graphpnpm turbo test --dry-runpnpm turbo test --force- name: Install QA Skills
run: npx @qaskills/cli add turborepo-monorepo-testing12 of 29 agents supported