| name | testing-patterns |
| description | Vitest testing patterns and templates for Node.js + TypeScript projects (Express backend, Vue 3 frontend). Provides comprehensive test writing guidance for unit, integration, component, and property-based tests. Use when generating or improving tests for the harness-visualizer. |
Testing Patterns
This skill provides comprehensive testing patterns for the harness-visualizer using Vitest as the test runner across all three workspaces (backend, frontend, shared).
The root vitest.config.ts orchestrates all three workspaces via test.projects. There is no per-workspace vitest.config.ts and no deprecated vitest.workspace.ts.
Test Organization
Layout
backend/
└── src/
├── scanner/
│ ├── scanner.ts
│ └── scanner.test.ts ← colocated unit + integration
├── api/
│ ├── safe-open.ts
│ └── safe-open.test.ts ← security branches: failing-on-purpose
└── __test-helpers/
└── with-tmp-root.ts ← shared fixture helper
frontend/
└── src/
├── components/
│ ├── HarnessGraph.vue
│ └── HarnessGraph.test.ts ← component tests via @vue/test-utils
├── stores/
│ ├── harness.ts
│ └── harness.test.ts
└── test/
└── setup.ts ← mocks socket.io-client, stubs v-network-graph
shared/
└── src/
├── schemas.ts
└── schemas.test.ts ← zod schema invariants
Naming Conventions
- Test files:
<source-file>.test.ts colocated next to the source.
describe('ClassOrFunction') at the top; nested describe('methodName') for grouped behaviors.
it('does something specific in a given state', ...) — full sentence, present tense.
Vitest Patterns
Basic Test Structure
import { describe, it, expect, beforeEach } from 'vitest';
import { Scanner } from './scanner.js';
describe('Scanner', () => {
let scanner: Scanner;
beforeEach(() => {
scanner = new Scanner();
});
describe('scan', () => {
it('returns entries for matching files', async () => {
const entries = await scanner.scan('/fixtures/simple', defaultPatterns);
expect(entries).toHaveLength(2);
expect(entries[0]?.layer).toBe('context');
});
it('rejects symlinks resolving outside the watch root', async () => {
await expect(scanner.scan('/fixtures/traversal', defaultPatterns))
.rejects.toThrow(/symlink-rejected/);
});
});
});
Backend Integration Test (real filesystem)
Backend tests use real filesystem in tmp dirs via the withTmpRoot helper. Do not mock fs — the scanner code paths are small enough that integration coverage beats mock fidelity.
import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { withTmpRoot } from '../__test-helpers/with-tmp-root.js';
import { scan } from './scanner.js';
import { defaultPatterns } from '../patterns/default-template.js';
describe('Scanner integration', () => {
it('discovers a SKILL.md file under .claude/skills/', async () => {
await withTmpRoot(async (root) => {
const skillDir = path.join(root, '.claude/skills/my-skill');
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
path.join(skillDir, 'SKILL.md'),
'---\nname: my-skill\ndescription: x\n---\nbody',
);
const { entries, diagnostics } = await scan(root, defaultPatterns);
expect(diagnostics).toEqual([]);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
layer: 'skill',
scope: 'project',
});
});
});
it('emits invalid-frontmatter diagnostic when zod parse fails', async () => {
await withTmpRoot(async (root) => {
const skillDir = path.join(root, '.claude/skills/broken');
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
path.join(skillDir, 'SKILL.md'),
'---\nname: 123\n---\n',
);
const { entries, diagnostics } = await scan(root, defaultPatterns);
expect(entries).toHaveLength(1);
expect(diagnostics).toContainEqual(
expect.objectContaining({ code: 'invalid-frontmatter', severity: 'error' }),
);
});
});
});
Security branch tests (failing-on-purpose)
Every security branch in AGENTS.md MUST have a failing-on-purpose test.
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { withTmpRoot } from '../__test-helpers/with-tmp-root.js';
import { createApp } from '../server.js';
describe('GET /api/file — security branches', () => {
it('rejects path-traversal segments', async () => {
await withTmpRoot(async (root) => {
const app = createApp({ watchRoots: [root] });
const res = await request(app).get('/api/file').query({ path: '../../etc/passwd' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/traversal|out-of-bounds/);
});
});
it('rejects symlinks that resolve outside the watch root', async () => {
await withTmpRoot(async (root) => {
const link = path.join(root, 'evil');
await fs.symlink('/etc/passwd', link);
const app = createApp({ watchRoots: [root] });
const res = await request(app).get('/api/file').query({ path: link });
expect(res.status).toBe(403);
});
});
it('rejects writes above the 1 MB size cap', async () => {
await withTmpRoot(async (root) => {
const target = path.join(root, 'big.md');
const app = createApp({ watchRoots: [root] });
const body = 'x'.repeat(1024 * 1024 + 1);
const res = await request(app).put('/api/file').send({ path: target, content: body });
expect(res.status).toBe(413);
});
});
it('rejects writes to disallowed extensions', async () => {
await withTmpRoot(async (root) => {
const target = path.join(root, 'evil.sh');
const app = createApp({ watchRoots: [root] });
const res = await request(app).put('/api/file').send({ path: target, content: '#!/bin/sh' });
expect(res.status).toBe(403);
});
});
});
Socket.IO handler tests
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createServer } from 'node:http';
import { AddressInfo } from 'node:net';
import { Server } from 'socket.io';
import { io as ioc, type Socket as ClientSocket } from 'socket.io-client';
import type { ServerToClientEvents, ClientToServerEvents } from '@harness-visualizer/shared';
import { registerHandlers } from './socket-handlers.js';
describe('Socket.IO: configure-watch', () => {
let httpServer: ReturnType<typeof createServer>;
let io: Server<ClientToServerEvents, ServerToClientEvents>;
let client: ClientSocket<ServerToClientEvents, ClientToServerEvents>;
beforeEach(async () => {
httpServer = createServer();
io = new Server(httpServer);
registerHandlers(io);
await new Promise<void>((r) => httpServer.listen(0, '127.0.0.1', r));
const { port } = httpServer.address() as AddressInfo;
client = ioc(`http://127.0.0.1:${port}`);
await new Promise<void>((r) => client.on('connect', r));
});
afterEach(() => {
client.disconnect();
io.close();
httpServer.close();
});
it('emits unknown-event diagnostic for unsupported event names', async () => {
const diagnosticReceived = new Promise<void>((resolve) => {
client.on('diagnostic', (d) => {
if (d.code === 'unknown-event') resolve();
});
});
client.emit('totally-fake-event', {});
await expect(diagnosticReceived).resolves.toBeUndefined();
});
});
Vue component tests (@vue/test-utils + jsdom)
The frontend Vitest project uses environment: 'jsdom'. frontend/src/test/setup.ts mocks socket.io-client and stubs v-network-graph.
import { describe, it, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import HarnessGraph from './HarnessGraph.vue';
import { useHarnessStore } from '../stores/harness.js';
describe('HarnessGraph.vue', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('renders a node for each entry in the store', async () => {
const store = useHarnessStore();
store.applyUpdate({
entries: [
{ id: 'a1', layer: 'skill', scope: 'project', path: 'a.md', tool: 'claude' },
{ id: 'b2', layer: 'context', scope: 'project', path: 'b.md', tool: 'claude' },
],
diagnostics: [],
});
const wrapper = mount(HarnessGraph);
expect(wrapper.findComponent({ name: 'VNetworkGraph' }).props('nodes')).toMatchObject({
a1: expect.objectContaining({ layer: 'skill' }),
b2: expect.objectContaining({ layer: 'context' }),
});
});
it('emits node-click when a node is selected', async () => {
const wrapper = mount(HarnessGraph);
wrapper.findComponent({ name: 'VNetworkGraph' }).vm.$emit('node:click', { node: 'a1' });
expect(wrapper.emitted('node-click')?.[0]).toEqual([{ id: 'a1' }]);
});
});
Pinia store tests
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useHarnessStore } from './harness.js';
describe('useHarnessStore', () => {
beforeEach(() => setActivePinia(createPinia()));
it('replaces entries on applyUpdate (no in-place mutation)', () => {
const store = useHarnessStore();
const original = store.entries;
store.applyUpdate({
entries: [{ id: 'x', layer: 'skill', scope: 'project', path: 'x.md', tool: 'claude' }],
diagnostics: [],
});
expect(store.entries).not.toBe(original);
expect(store.entries).toHaveLength(1);
});
});
Property-based testing
For pure functions (parsers, validators, id derivation), use fast-check (install via npm install -D fast-check).
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
import { deriveEntryId } from './ids.js';
describe('deriveEntryId — properties', () => {
it('is deterministic for the same (scope, path, layer)', () => {
fc.assert(
fc.property(fc.string(), fc.string(), fc.constantFrom('skill', 'context', 'mcp'), (scope, path, layer) => {
expect(deriveEntryId({ scope, path, layer })).toBe(deriveEntryId({ scope, path, layer }));
}),
);
});
it('produces exactly 8 hex characters', () => {
fc.assert(
fc.property(fc.string(), fc.string(), fc.constantFrom('skill', 'context'), (scope, path, layer) => {
expect(deriveEntryId({ scope, path, layer })).toMatch(/^[0-9a-f]{8}(?::\d+)?$/);
}),
);
});
});
Mocking external modules
import { describe, it, expect, vi } from 'vitest';
vi.mock('socket.io-client', () => ({
io: vi.fn(() => ({
on: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
})),
}));
import { io } from 'socket.io-client';
describe('socket wrapper', () => {
it('emits configure-watch on init', () => {
});
});
Test Quality Checklist
Good test characteristics
-
Specific assertions — test exact values, not truthiness
expect(result).toBeDefined();
expect(result.entries).toHaveLength(2);
expect(result.entries[0]?.layer).toBe('skill');
-
Single responsibility — one behavior per it
it('scans, validates, and emits diagnostics', () => { ... });
it('scans matching files', () => { ... });
it('validates frontmatter via zod', () => { ... });
it('emits diagnostic when frontmatter fails validation', () => { ... });
-
Descriptive names — explain the expected behavior
it('works', () => { ... });
it('returns 403 when path resolves outside any watch root', () => { ... });
-
Arrange-Act-Assert — clear structure
it('emits multi-layer entries when a file matches two patterns', async () => {
const root = await mkTmp();
await fs.writeFile(path.join(root, '.claude/settings.json'), '{}');
const { entries } = await scan(root, defaultPatterns);
expect(entries.filter((e) => e.layer === 'hook')).toHaveLength(1);
expect(entries.filter((e) => e.layer === 'mcp')).toHaveLength(1);
});
-
Test edge cases
describe('edge cases', () => {
it('handles an empty watch root', async () => { ... });
it('handles a symlink loop without recursing forever', async () => { ... });
it('handles a file that disappears mid-scan', async () => { ... });
});
Templates
Concrete starter templates live in templates/:
vitest-unit.ts — pure-logic unit test for a module in backend/src/
vitest-integration.ts — real-filesystem integration test using withTmpRoot
vitest-component.ts — Vue component test via @vue/test-utils
vitest-property.ts — fast-check property tests for invariants
Copy a template, replace placeholders ({ModuleName}, {ComponentName}, etc.), and trim sections that don't apply.
Coverage Guidelines
- Minimum line coverage target: 80% (configurable in
.state/config.json)
- Critical paths: 100% for
backend/src/api/safe-open.ts and backend/src/security/
- Focus areas:
- All security rejection branches (failing-on-purpose tests required)
- Scanner pattern matching, including multi-layer file emission
- Watcher reconfiguration paths (chokidar v5 footgun coverage)
- zod schema parse + diagnostic emission paths
- Pinia store reducers / setup-store actions
- Vue components: render + at least one user interaction per component