Testing Guide
Coherent.js components are plain JavaScript objects rendered to strings, which makes them unusually easy to test: call the component function, render it, and assert on the HTML.
Setup
Install Vitest (projects scaffolded with
coherent create already include it):
pnpm add -D vitestAdd a test script to package.json:
{
"scripts": {
"test": "vitest run"
}
}No special configuration is needed — the published packages resolve out of the box under Vitest's default settings.
Testing with render
The simplest tests use render from @coherent.js/core directly:
import { describe, it, expect } from 'vitest';
import { render } from '@coherent.js/core';
import { HomePage } from '../src/components/HomePage.js';
describe('HomePage', () => {
it('renders the title', () => {
const html = render(HomePage({ title: 'Hello' }));
expect(html).toContain('Hello');
});
it('escapes user content', () => {
const html = render({ div: { text: '<script>alert(1)</script>' } });
expect(html).not.toContain('<script>alert(1)</script>');
});
});Testing with @coherent.js/tooling/testing
The tooling package ships a richer test harness. renderComponent returns a
result object with the rendered HTML plus query helpers:
import { describe, it, expect } from 'vitest';
import { renderComponent } from '@coherent.js/tooling/testing';
import { HomePage } from '../src/components/HomePage.js';
describe('HomePage', () => {
it('renders without errors', () => {
const { html } = renderComponent(HomePage({}));
expect(html).toBeTypeOf('string');
expect(html).toContain('Welcome');
});
});Custom matchers
extendExpect(expect) adds matchers that read a renderComponent() result.
It does not replace Vitest's built-in matchers (toMatchSnapshot,
toHaveBeenCalled*...):
import { describe, it, expect } from 'vitest';
import { renderComponent, extendExpect } from '@coherent.js/tooling/testing';
extendExpect(expect);
const Button = ({ label }) => ({
button: { className: ['btn', 'btn-primary'], 'data-testid': 'save', text: label }
});
describe('Button', () => {
it('renders its label and classes', () => {
const result = renderComponent(Button({ label: 'Save' }));
expect(result).toHaveText('Save'); // text content, entities decoded
expect(result).toHaveClass('btn'); // whole class tokens only
expect(result).not.toHaveClass('btn-prim');
expect(result.getByTestId('save')).toBeTruthy();
expect(result).toBeValidHTML();
});
it('matches the snapshot', () => {
expect(renderComponent(Button({ label: 'Save' })).toSnapshot()).toMatchSnapshot();
});
});toHaveAttribute and toHaveTagName look at the component's root element.
Mocks from createMock() / createSpy() work with the built-in
toHaveBeenCalled* matchers.
Reusable smoke tests
A pattern that scales well — one helper asserting any component renders:
import { describe, it, expect } from 'vitest';
import { renderComponent } from '@coherent.js/tooling/testing';
export function testComponent(Component, props = {}) {
describe(Component.name || 'Component', () => {
it('renders without errors', () => {
const { html } = renderComponent(Component(props));
expect(html).toBeTypeOf('string');
expect(html.length).toBeGreaterThan(0);
});
});
}What to test
- Output, not structure: assert on the rendered HTML string, not the component object shape — refactors then don't break tests.
- Props drive branches: render once per interesting prop combination.
- Escaping: if you interpolate user data, assert it arrives escaped.