docs/references/testing/frontend-testing.md
This is the normative testing guide for src/renderer/, packages/ui/, and tests/e2e/.
It applies to tests written by humans and AI agents.
The goal is not to maximize the number of tests or lines covered. The goal is to keep the smallest set of tests that gives strong confidence in user-visible behavior and stable public contracts.
This file is the single source of truth for frontend test quality and review decisions. Repository entry points should link here instead of copying these rules. More specific documents may describe commands, fixtures, or infrastructure, but must not redefine when a test is valuable, what it should assert, or how it should be reviewed.
If an older example conflicts with this guide, this guide wins. Existing tests demonstrate current implementation history, not automatically approved patterns.
Before writing a test, state the regression it is intended to catch.
A test is worth adding only when all of these are true:
If the regression cannot be stated concretely, do not add the test.
does not throw cases for impossible or unsupported inputs.When a no-test change is non-obvious, explain why in the PR instead of adding a token test.
| Behavior | Preferred test layer | What to assert |
|---|---|---|
| Pure transformation, parser, reducer, or state machine | Unit test | Inputs, outputs, transitions, and meaningful boundaries |
| Hook with state or external effects | Hook or small harness test | Returned contract and externally observable effects |
| Renderer component behavior | Component test | What a user can find, do, and observe |
Generic @cherrystudio/ui primitive/composite | packages/ui test using the real component | Accessibility, interaction, and documented visual contract |
| Critical cross-window or cross-process workflow | E2E test | A complete user outcome |
| Compile-time public type contract | Type test | Accepted and rejected usage, with no duplicate runtime test |
Do not repeat the same behavior at every layer. A component test should not re-test every branch of an already tested pure helper, and an E2E test should not enumerate every component prop.
Prefer assertions about:
Avoid assertions about:
Mock-call assertions are appropriate when the mock represents the external effect itself. They are not a substitute for an observable outcome when the mocked function is an internal collaborator.
CSS/class assertions are allowed only when the class is itself the contract, for example an Electron drag-region marker, a maintained UI semantic token, or a regression involving layout mechanics. Add a short comment naming that contract.
Use the same surface a user or assistive technology uses.
For Testing Library, prefer queries in this order:
getByRole / findByRole with an accessible name.getByLabelText.getByTestId only when no meaningful semantic selector exists.Use queryBy* for absence checks and findBy* for asynchronous appearance. Do not use
document.querySelector, DOM parent traversal, or CSS classes when a semantic query is available.
Use userEvent.setup() for normal user input such as clicking, typing, tabbing, and selecting.
Use fireEvent only for low-level browser events that userEvent does not model adequately, such
as a targeted scroll, resize, drag, or custom event.
For Playwright, use getByRole, getByLabel, and other user-facing locators before CSS selectors.
When a workflow needs an app-owned scope, start from a documented data-ui boundary and then use
an accessible locator within it. Because data-ui is a token set, match it with ~=, never = or
substring matching.
See E2E Testing Guide for Electron-specific setup.
When asserting translated accessible names or text, fix the test locale. Mock translations only when translation resolution itself is outside the subject's contract; do not replace meaningful labels with translation keys merely to make a query convenient.
Mock the narrowest external boundary necessary to make a test deterministic.
Good mock boundaries include:
Do not:
@cherrystudio/ui transition ruleRenderer tests currently have lightweight global stand-ins for @cherrystudio/ui. They may isolate
an unrelated UI leaf, but they do not prove the behavior of the real UI component.
@cherrystudio/ui mock for one feature test.packages/ui with the real component.Any non-trivial shared fake must have a parity test against the real implementation or be reduced to a call-recording boundary.
Reject or rewrite tests whose only claim is:
Also reject tests that would still pass if the relevant production behavior were deleted.
Snapshots are opt-in, not the default.
Use a snapshot only when:
Do not snapshot component trees, generated class lists, large Markdown output, or mocked component trees. Prefer explicit assertions for the behavior that matters.
Delete snapshots when their owning test is removed.
A bug-fix test should document:
Issue or PR numbers are useful when they explain a non-obvious boundary. Avoid copying the entire original incident into several layers of tests.
E2E tests are for critical workflows whose risk crosses renderer, preload, main process, persistence, or packaging boundaries. They are not the default place for component variants.
Use a Page Object only when multiple tests share a meaningful workflow or locator set. A one-off interaction can stay in the spec; do not create an abstraction solely to satisfy a template.
Keep E2E tests independent, set the locale when asserting localized accessible names, and wait for a user-observable condition rather than a fixed timeout.
New and materially changed tests must follow this guide. Pre-existing tests do not justify copying an obsolete pattern.
Before editing tests, an AI agent must:
Before finishing, the agent must answer:
If a newly created or materially expanded suite exceeds 300 lines, 15 cases, or five mocked internal modules, treat that as a review signal. Explain why the scope belongs together or split/consolidate it. These are review thresholds, not coverage targets.
it('copies the message and announces success', async () => {
const user = userEvent.setup()
render(<CopyButton textToCopy="hello" />)
await user.click(screen.getByRole('button', { name: 'Copy' }))
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello')
expect(toast.success).toHaveBeenCalled()
})
The clipboard and toast are external effects; their calls are the observable contract.
it('renders the icon and wrapper', () => {
const { container } = render(<CopyButton textToCopy="hello" />)
expect(container.querySelector('div')).toBeInTheDocument()
expect(container.querySelector('.copy-icon')).toBeInTheDocument()
})
This test depends on incidental DOM structure and does not protect the copy behavior.