From fastlane-ui-design
Test-Driven Development for Angular components in Fastlane — explicit Red/Green/Refactor cycles using Jest 29 + jest-preset-angular + @testing-library/angular for unit/component, jest-axe for component-level a11y, Playwright + @axe-core/playwright + playwright-lighthouse for committed E2E. Use when implementing or modifying any Angular component, directive, pipe, or service that has user-facing behavior.
How this skill is triggered — by the user, by Claude, or both
Slash command
/fastlane-ui-design:frontend-tddThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Write the test before writing any implementation code. The test should:
Write the test before writing any implementation code. The test should:
getByRole as the first-choice query — it forces you to write semantic HTMLExamples of a good first failing test:
// Red: assert the primary action exists and is labeled
it('renders a save button', async () => {
await render(MyComponent, { providers: [provideAnimations()] });
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
// Red: assert a11y compliance from the start
it('is accessible (WCAG AA)', async () => {
const { container } = await render(MyComponent, { providers: [provideAnimations()] });
expect(await axe(container)).toHaveNoViolations();
});
// Red: assert the component responds to user interaction
it('emits actionTriggered when save is clicked', async () => {
const user = userEvent.setup();
let emitted = false;
await render(MyComponent, {
on: { actionTriggered: () => (emitted = true) },
providers: [provideAnimations()],
});
await user.click(screen.getByRole('button', { name: /save/i }));
expect(emitted).toBe(true);
});
Run Jest and confirm the test fails. A TypeScript error ("Cannot find module") means you have an import problem — fix it first. A test assertion failure ("Unable to find role='button'") is the correct Red state.
Write the minimum Angular code that makes the failing test pass. Nothing extra.
@Component({
selector: 'app-my',
standalone: true,
imports: [MatButtonModule],
template: `<button mat-raised-button color="primary" (click)="actionTriggered.emit()">Save</button>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MyComponent {
readonly actionTriggered = output<void>();
}
Run Jest. All tests should be green. If a new test passes but a previous one fails, you introduced a regression — fix it before proceeding.
All tests stay green throughout refactoring. Use this phase to:
var(--*) tokenBehaviorSubject to a signal() if RxJS was overkill@if/@for control-flow syntax instead of *ngIf/*ngForTests must remain green. If a refactor breaks a test, either the refactor changed behavior (undo it) or the test was testing implementation (fix the test to test behavior instead).
Fastlane is a heavy-frontend product. Use the heavy-frontend ratio:
| Layer | Ratio | Tools | Speed |
|---|---|---|---|
| Unit (logic, signals, pipes) | 60% | Jest 29 + jest-preset-angular | <50ms |
| Component integration (rendered + interaction + a11y) | 25% | Jest + @testing-library/angular + jest-axe | 100–500ms |
| E2E (visual regression + cross-browser + Lighthouse) | 15% | Playwright + @axe-core/playwright + playwright-lighthouse | 5–30s |
Critical path components (shared, library): 100% coverage Core feature components: 90% coverage Page-level components: 80% coverage Overall: 75–85%
The canonical test scaffold for Fastlane Angular components:
import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'jest-axe';
import { provideAnimations } from '@angular/platform-browser/animations';
import { MyComponent } from './my.component';
expect.extend(toHaveNoViolations);
describe('MyComponent', () => {
const setup = async (props = {}) =>
render(MyComponent, {
componentProperties: props,
providers: [provideAnimations()],
});
it('renders the primary action labelled correctly', async () => {
await setup();
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('is accessible (WCAG AA)', async () => {
const { container } = await setup();
expect(await axe(container)).toHaveNoViolations();
});
it('calls the output when the primary action is triggered', async () => {
const user = userEvent.setup();
let emitted = false;
await render(MyComponent, {
on: { actionTriggered: () => (emitted = true) },
providers: [provideAnimations()],
});
await user.click(screen.getByRole('button', { name: /save/i }));
expect(emitted).toBe(true);
});
});
Always reach for the most semantic query first. A test that uses getByTestId is testing the least amount of your component.
getByRole — use role + accessible name (e.g., getByRole('button', { name: /save/i }))getByLabelText — for form inputsgetByPlaceholderText — fallback for inputs without visible labelgetByText — for visible text contentgetByTestId — last resort only; requires data-testid attributeAngular Material wraps controls. The underlying <button> and <input> elements remain queryable by role even inside Material composites.
// Wait for async render
const button = await screen.findByRole('button', { name: /loaded/i });
// Wait for element to disappear
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
// Assert after async state change
await waitFor(() => {
expect(screen.getByText('Success')).toBeInTheDocument();
});
// Verify a Pangea token resolves (computed on the rendered element)
const el = screen.getByRole('region');
const primary = getComputedStyle(el).getPropertyValue('--color-primary').trim();
expect(primary).toBeTruthy(); // Token resolves to a non-empty value
// Never assert exact hex values — the spec is the source of truth
// Success button must use accent, not primary
const saveBtn = screen.getByRole('button', { name: /save/i });
expect(saveBtn.closest('[color]')).toHaveAttribute('color', 'accent'); // not 'primary'
// Destructive button must use danger
const deleteBtn = screen.getByRole('button', { name: /delete/i });
expect(deleteBtn.closest('[color]')).toHaveAttribute('color', 'danger');
it('applies dark mode tokens when body.dark-theme is set', async () => {
const { container } = await render(MyComponent, { providers: [provideAnimations()] });
document.body.classList.add('dark-theme');
// Trigger change detection if needed
// Assert visible change or token value shift
document.body.classList.remove('dark-theme'); // cleanup
});
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('is WCAG AA compliant', async () => {
const { container } = await render(MyComponent, { providers: [provideAnimations()] });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },
});
expect(results).toHaveNoViolations();
});
Run this assertion on every component. It catches contrast failures, missing ARIA, and focus management issues that manual review misses.
// BAD: Hard wait
await new Promise(r => setTimeout(r, 500));
// GOOD: Wait for condition
await screen.findByRole('button', { name: /loaded/i }); // Auto-waits
// BEST: Structure test so it doesn't need to wait at all
Disable Angular animations in unit tests:
providers: [provideNoopAnimations()] // use instead of provideAnimations() for synchronous tests
Mock Date.now and crypto.randomUUID for deterministic snapshots:
jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
Angular Material uses shadow DOM for some internals. In Playwright E2E tests, use >> piercing:
const internalInput = page.locator('mat-form-field >> input');
Encapsulate traversal in helper functions:
const getMatInput = (label: string) =>
page.locator(`mat-form-field:has(mat-label:text("${label}")) >> input`);
Never duplicate selector strings across tests.
For Playwright E2E artifact scaffolding, see the browser-ui-testing skill. This skill focuses on the TDD cycle (Red/Green/Refactor); the E2E tooling recipes live in browser-ui-testing.
Execute stages fastest-to-slowest, fail fast:
--grep @a11y) — block on failure--grep @lighthouse) — advisory; run on schedule or pre-merge for key routescomponent.mySignal() or component.privateMethod — use the rendered DOM and user interactions.expect(el).toHaveClass('is-loading') couples the test to internal naming. Instead: expect(screen.getByRole('progressbar')).toBeInTheDocument().expect(el).toHaveStyle('color: #2563eb') breaks when Pangea tokens change. Assert token resolution or visual behavior, not hex values.setTimeout in tests: use findBy* queries or waitFor.expect(await axe(container)).toHaveNoViolations().browser-ui-testing — Playwright + MCP tooling recipespangea-design-system — complete Pangea token vocabularyaccessibility-compliance — WCAG criteria and ARIA patternssubagent-quality-loop — when the implementer follows TDD as part of the quality loopnpx claudepluginhub pat-richardson/fastlane-ui-design --plugin fastlane-ui-designCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.