.agents/skills/astro-developer/testing.md
Comprehensive guide to writing and debugging tests in the Astro monorepo.
Prefer unit tests over integration tests. The codebase is being refactored to be more unit-testable.
Guidelines:
When writing new code:
Every test fixture MUST have a unique outDir. This is the #1 cause of mysterious test failures.
// BAD - Will cause cache pollution
await loadFixture({
root: './fixtures/my-test/',
// No outDir specified - uses default, shared with other tests
});
// GOOD - Isolated output
await loadFixture({
root: './fixtures/my-test/',
outDir: './dist/my-test/', // Unique per test
});
Why: Build artifacts are cached and shared via ESM between test runs. Without unique outDir, tests contaminate each other.
Reference: /CONTRIBUTING.md:203-214
Location: packages/astro/test/*.test.js
Runner: node:test via astro-scripts test
When to use (default for most code):
Run:
# All tests in package
pnpm -C packages/astro exec astro-scripts test "test/**/*.test.js"
# Single test file
pnpm -C packages/astro exec astro-scripts test "test/actions.test.js"
# Filter by pattern
pnpm -C packages/astro exec astro-scripts test "test/**/*.test.js" --match "CSS"
# Multiple files
pnpm -C packages/astro exec astro-scripts test "test/{actions,css,middleware}.test.js"
Flags:
--match / -m → Filter tests by name pattern (regex)--only / -o → Run only tests marked with .only--parallel / -p → Run tests in parallel (default: sequential)--timeout / -t → Set timeout in milliseconds--watch / -w → Watch modeWriting unit-testable code:
// BAD - tightly coupled, hard to test
export function processConfig(configPath) {
const fs = require('node:fs');
const config = JSON.parse(fs.readFileSync(configPath));
const result = transformConfig(config);
fs.writeFileSync(configPath, JSON.stringify(result));
}
// GOOD - pure function, easy to test
export function transformConfig(config) {
// Pure business logic, no side effects
return {
...config,
transformed: true,
};
}
// Infrastructure layer (test with integration test if needed)
export function processConfigFile(configPath) {
const fs = require('node:fs');
const config = JSON.parse(fs.readFileSync(configPath));
const result = transformConfig(config); // Unit-tested function
fs.writeFileSync(configPath, JSON.stringify(result));
}
When to use (only when unit tests are insufficient):
Location: packages/astro/test/*.test.js (same location, different purpose)
Pattern: Uses loadFixture() and builds full Astro projects
⚠️ Avoid when possible: Integration tests are slower and harder to debug. Extract business logic into unit-testable functions.
Location: packages/astro/e2e/*.test.js
When to use:
When NOT to use:
astro build output (use unit tests)Run:
# All E2E tests
pnpm run test:e2e
# Filter by pattern
pnpm run test:e2e:match "Tailwind CSS"
Location: packages/integrations/*/test/
Pattern: Each integration has its own test suite
Run:
# Single integration
pnpm -C packages/integrations/react run test
# All integrations
pnpm run test:integrations
Goal: Make business logic testable without infrastructure dependencies.
Separate business logic from infrastructure
Use pure functions
Inject dependencies
Avoid tight coupling
// BAD - business logic mixed with infrastructure
export async function processMarkdown(filePath: string) {
const fs = await import('node:fs/promises');
const content = await fs.readFile(filePath, 'utf-8');
// Business logic buried in infrastructure
const withFrontmatter = content.split('---')[2];
const processed = withFrontmatter.replace(/TODO:/g, 'NOTE:');
await fs.writeFile(filePath, processed);
}
// GOOD - business logic extracted (unit testable)
export function transformMarkdownContent(content: string): string {
const withFrontmatter = content.split('---')[2];
return withFrontmatter.replace(/TODO:/g, 'NOTE:');
}
// Infrastructure layer (integration test if needed)
export async function processMarkdownFile(filePath: string) {
const fs = await import('node:fs/promises');
const content = await fs.readFile(filePath, 'utf-8');
const processed = transformMarkdownContent(content);
await fs.writeFile(filePath, processed);
}
// Unit test (fast, no file I/O)
it('transforms markdown content', () => {
const input = '---\ntitle: Test\n---\nTODO: Fix this';
const result = transformMarkdownContent(input);
assert.equal(result, 'NOTE: Fix this');
});
// BAD - hardcoded dependency
export function buildRoutes(config: AstroConfig) {
const pages = scanFilesystem('./src/pages'); // Hardcoded
return pages.map((page) => createRoute(page, config));
}
// GOOD - inject dependency (unit testable)
export function buildRoutes(pages: string[], config: AstroConfig): Route[] {
return pages.map((page) => createRoute(page, config));
}
// Unit test (no filesystem access)
it('builds routes from pages', () => {
const pages = ['index.astro', 'about.astro'];
const config = { base: '/' };
const routes = buildRoutes(pages, config);
assert.equal(routes.length, 2);
});
// BAD - tight coupling to concrete type
export function validateConfig(viteConfig: ViteUserConfig) {
// Requires full Vite config object
}
// GOOD - accept minimal interface
interface ConfigLike {
build?: { outDir?: string };
server?: { port?: number };
}
export function validateConfig(config: ConfigLike) {
// Only needs what it uses, easy to mock
}
// Unit test (simple mock)
it('validates config', () => {
const config = { build: { outDir: './dist' } };
const result = validateConfig(config);
assert.ok(result);
});
// BAD - mutation, side effects
export function updateManifest(manifest: Manifest, route: Route) {
manifest.routes.push(route);
manifest.version++;
}
// GOOD - pure function returning new data
export function addRouteToManifest(manifest: Manifest, route: Route): Manifest {
return {
...manifest,
routes: [...manifest.routes, route],
version: manifest.version + 1,
};
}
// Unit test (predictable, no side effects)
it('adds route to manifest', () => {
const manifest = { routes: [], version: 1 };
const route = { path: '/test' };
const result = addRouteToManifest(manifest, route);
assert.equal(result.routes.length, 1);
assert.equal(result.version, 2);
assert.equal(manifest.routes.length, 0); // Original unchanged
});
Use integration tests only when unit tests are insufficient:
Even then, extract as much business logic as possible into unit-testable functions.
Location: packages/astro/test/test-utils.js
Load a test fixture with configuration.
import { loadFixture } from './test-utils.js';
const fixture = await loadFixture({
root: './fixtures/my-test/',
outDir: './dist/my-test/', // REQUIRED
adapter: testAdapter(),
integrations: [react()],
});
Returns: Fixture object with methods
Build the fixture (runs astro build).
await fixture.build();
Start dev server.
const devServer = await fixture.startDevServer();
// Use server
await devServer.stop();
Start preview server (serves build output).
await fixture.build();
const previewServer = await fixture.preview();
// Use server
await previewServer.stop();
Fetch from dev/preview server.
const res = await fixture.fetch('/about');
const html = await res.text();
Read file from build output.
const html = await fixture.readFile('/index.html');
Check if file exists in build output.
const exists = await fixture.pathExists('/index.html');
Test adapter for SSR testing.
import testAdapter from './test-adapter.js';
const fixture = await loadFixture({
adapter: testAdapter(),
});
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { loadFixture } from './test-utils.js';
describe('Feature Name', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/feature-name/',
outDir: './dist/feature-name/', // Unique!
});
});
describe('dev', () => {
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('should work in dev', async () => {
const res = await fixture.fetch('/');
assert.equal(res.status, 200);
});
});
describe('build', () => {
before(async () => {
await fixture.build();
});
it('should work in build', async () => {
const html = await fixture.readFile('/index.html');
assert.match(html, /expected content/);
});
});
});
// Run only this test
it.only('focused test', async () => {
// ...
});
// Run only this describe block
describe.only('focused suite', () => {
// All tests here will run
});
Run with:
node --test --test-only test/my-test.test.js
Warning:
describe blocks must also have .only--test-only flag must come before file pathtest/fixtures/my-test/
├── package.json # REQUIRED
├── astro.config.mjs # Optional
└── src/
└── pages/
└── index.astro
REQUIRED: Use workspace dependencies
{
"name": "@test/my-test",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/react": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
}
}
Pattern:
"astro": "workspace:*" → Links to local astro package"@astrojs/*": "workspace:*" → Links to local integrations"react": "catalog:" → Uses version from catalog in root package.jsonimport { defineConfig } from 'astro/config';
import react from '@astrojs/react';
export default defineConfig({
integrations: [react()],
outDir: './dist', // Can be overridden by test
});
// Pattern: Use test name in outDir
describe('Actions', () => {
const fixture = await loadFixture({
root: './fixtures/actions/',
outDir: './dist/actions/', // Matches test name
});
});
describe('Actions with adapter', () => {
const fixture = await loadFixture({
root: './fixtures/actions/',
outDir: './dist/actions-adapter/', // Different from above
});
});
If tests still fail with unique outDir:
# Manual cleanup
rm -rf test/fixtures/my-test/.astro
rm -rf test/fixtures/my-test/dist
# Sequential execution (default)
pnpm -C packages/astro exec astro-scripts test "test/**/*.test.js"
# Parallel (faster but can cause issues)
pnpm -C packages/astro exec astro-scripts test "test/**/*.test.js" --parallel
describe('Vite Plugin', () => {
it('should transform .astro files', async () => {
const fixture = await loadFixture({
root: './fixtures/astro-components/',
outDir: './dist/astro-components/',
});
await fixture.build();
const html = await fixture.readFile('/index.html');
assert.match(html, /<h1>.*<\/h1>/);
});
});
describe('Virtual Modules', () => {
it('should load virtual:astro:middleware', async () => {
const fixture = await loadFixture({
root: './fixtures/middleware/',
outDir: './dist/middleware/',
});
const devServer = await fixture.startDevServer();
const res = await fixture.fetch('/');
assert.equal(res.headers.get('x-middleware'), 'true');
await devServer.stop();
});
});
import testAdapter from './test-adapter.js';
describe('SSR', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/ssr/',
outDir: './dist/ssr/',
output: 'server',
adapter: testAdapter(),
});
await fixture.build();
});
it('should render dynamically', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
assert.match(html, /dynamic content/);
});
});
describe('Content Collections', () => {
it('should generate types', async () => {
const fixture = await loadFixture({
root: './fixtures/content-collections/',
outDir: './dist/content-collections/',
});
await fixture.build();
// Check data store
const dataStore = await fixture.readFile('../.astro/data-store.json');
const parsed = JSON.parse(dataStore);
assert.ok(parsed.collections.blog);
// Check types
const types = await fixture.readFile('../.astro/types.d.ts');
assert.match(types, /declare module 'astro:content'/);
});
});
describe('Error Handling', () => {
it('should throw on invalid config', async () => {
await assert.rejects(async () => {
await loadFixture({
root: './fixtures/invalid-config/',
outDir: './dist/invalid-config/',
});
}, /Expected configuration error/);
});
});
Pattern: Separate pure logic from side effects for testability
// create-key.ts
import { logger } from '../utils.js';
export async function createKey() {
const key = await crypto.subtle.generateKey(/* ... */);
logger.info(`Key: ${key}`);
return key;
}
Issues:
// create-key.ts
import type { Logger, KeyGenerator } from './types.js';
interface Options {
logger: Logger;
keyGenerator: KeyGenerator;
}
export async function createKey({ logger, keyGenerator }: Options) {
const key = await keyGenerator.generate();
logger.info(`Key: ${key}`);
return key;
}
// test/create-key.test.js
import { SpyLogger } from './test-utils.js';
import { FakeKeyGenerator } from './test-utils.js';
it('logs the generated key', async () => {
const logger = new SpyLogger();
const keyGenerator = new FakeKeyGenerator('test-key');
await createKey({ logger, keyGenerator });
assert.equal(logger.logs[0].message, 'Key: test-key');
});
// test/test-utils.js
export class SpyLogger {
logs = [];
info(message) {
this.logs.push({ level: 'info', message });
}
error(message) {
this.logs.push({ level: 'error', message });
}
}
export class FakeKeyGenerator {
constructor(key) {
this.key = key;
}
async generate() {
return this.key;
}
}
# Single test file
node --test test/my-test.test.js
# With focused test
node --test --test-only test/my-test.test.js
// Add logging
before(async () => {
console.log('Loading fixture:', fixturePath);
fixture = await loadFixture({
root: fixturePath,
outDir: './dist/unique/',
});
console.log('Fixture loaded');
});
// After build, inspect files
await fixture.build();
const files = await fs.readdir(fixture.config.outDir);
console.log('Built files:', files);
# Clean fixture manually
rm -rf test/fixtures/my-test/.astro
rm -rf test/fixtures/my-test/dist
rm -rf test/fixtures/my-test/node_modules/.vite
// Check if another test uses same outDir
grep -r "outDir.*dist/my-test" test/**/*.test.js
Symptom: Tests pass locally but timeout in CI
Fix: Add --parallel to see which file times out
// package.json
{
"test": "astro-scripts test --parallel \"test/**/*.test.js\""
}
After identifying problematic file, remove --parallel and fix the test.
# Fast iteration
pnpm -C packages/astro exec astro-scripts test "test/my-feature.test.js" --watch
# Filter by pattern
pnpm -C packages/astro exec astro-scripts test -m "should handle errors"
# All tests (slow)
pnpm run test
# Just astro package
pnpm run test:astro
# Just integrations
pnpm run test:integrations
# E2E tests
pnpm run test:e2e
# Run in CI mode (no cache)
pnpm run build:ci:no-cache
pnpm run test
Cause: Test takes too long (default: 30s)
Fix:
# Increase timeout
pnpm -C packages/astro exec astro-scripts test --timeout 60000 "test/slow.test.js"
Cause: Previous dev server not stopped
Fix:
// Always stop servers in after() hook
after(async () => {
await devServer?.stop();
});
Cause: File path wrong or build didn't complete
Fix:
await fixture.build() completedCause: Shared outDir between tests
Fix: Ensure unique outDir per test (see top of this guide)
describe('Feature', () => {
describe('dev mode', () => {
// Dev-specific tests
});
describe('build mode', () => {
// Build-specific tests
});
describe('SSR mode', () => {
// SSR-specific tests
});
});
describe('Feature', () => {
let fixture;
// Shared setup
before(async () => {
fixture = await loadFixture({
root: './fixtures/shared/',
outDir: './dist/shared/',
});
await fixture.build();
});
// Multiple tests use same fixture
it('test 1', async () => {
const html = await fixture.readFile('/page1.html');
// ...
});
it('test 2', async () => {
const html = await fixture.readFile('/page2.html');
// ...
});
});
Before considering a feature complete:
--parallel (if applicable)packages/astro/test/fixtures/packages/astro/test/test-utils.js