Writing GEL Component Tests
Guides writing tests for components in packages/ui/src/components/. Test conventions are also summarised in packages/ui/.agents/AGENTS.md.
When to Use
Reference this when a maintainer asks to "write tests for my component", "add tests", "improve test coverage", or "ensure this is well tested". Tests should also be written/updated after functionality has been added or changed. This includes writing new tests, improving existing tests, and ensuring comprehensive coverage for any component in packages/ui.
Test Environment
- Runner: Vitest with
jsdomenvironment - Rendering:
@testing-library/react(render,screen,waitFor) - User Events:
@testing-library/user-event(userEvent.setup()) - Mocks:
vifromvitest - Matchers:
@testing-library/jest-dom(loaded viavitest.setup.ts) - Excluded from tests:
*.styles.tsfiles (excluded in vitest config)
Pre-mocked Globals
Already mocked in vitest.setup.ts — do NOT re-mock:
window.scrollTowindow.URL.createObjectURL/revokeObjectURLwindow.matchMediaResizeObserver
Test File Structure
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { ComponentName } from './component-name.component.js';
describe('ComponentName', () => {
const user = userEvent.setup();
it('renders the component', () => {
const { container } = render(<ComponentName />);
expect(container).toBeInTheDocument();
});
});
Import from ./component-name.component.js (NOT from index), sub-components from ./components/index.js, icons from ../icon/index.js. Always use .js extensions.
Test Categories
- Render Tests (Required)
it('renders the component', () => {
const { container } = render(<ComponentName />);
expect(container).toBeInTheDocument();
});
- Variant Rendering
it('renders as an anchor tag', () => {
render(
<Button tag="a" href="#">
Link
</Button>,
);
expect(screen.getByRole('link', { name: 'Link' })).toBeInTheDocument();
});
- User Interactions
Always use userEvent.setup() (NOT fireEvent):
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
render(<Button me</Button>);
act(() => {
user.click(screen.getByRole('button', { name: 'Click me' }));
});
await waitFor(() => {
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
- Visibility / State
it('shows content when expanded', async () => {
const { getByText } = render(
<Accordion>
<AccordionItem key="item1" id="item1" title="Title">
Hidden content
</AccordionItem>
</Accordion>,
);
user.click(getByText('Title'));
await waitFor(() => {
expect(getByText('Hidden content')).toBeVisible();
});
});
- Icon Rendering
it('renders an icon when passed', () => {
render(<Button iconBefore={ArrowRightIcon} />);
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
});
- Utility Functions
describe('ComponentName utils', () => {
it('maps correct values', () => {
expect(utilFunction('input')).toBe('expected');
});
it('handles responsive values', () => {
expect(utilFunction({ initial: 'small', md: 'large' })).toStrictEqual({ initial: 'xsmall', md: 'small' });
});
});
Querying Elements
Prefer accessible queries:
screen.getByRole('button', { name: 'Text' })— interactive elementsscreen.getByText('Text')— content assertionsscreen.getByLabelText('Label')— form elementscontainer.querySelector()— last resort only
Coverage Targets
branches: 80,
functions: 80,
lines: 80,
statements: 80,
Running Tests
pnpm vitest run src/components/{name} # Specific component
pnpm vitest run # All tests
pnpm vitest src/components/{name} # Watch mode
pnpm vitest run --coverage # With coverage
What NOT to Test
- Style files (
*.styles.ts) — excluded in vitest config - Tailwind class names — don't assert on specific CSS classes
- Internal implementation details — test behavior, not implementation
- Storybook stories — tested separately via Storybook