您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. # Component Test-Driven Development Loop
  2. ## Principle
  3. Start every UI change with a failing component test (`cy.mount`, Playwright component test, or RTL `render`). Follow the Red-Green-Refactor cycle: write a failing test (red), make it pass with minimal code (green), then improve the implementation (refactor). Ship only after the cycle completes. Keep component tests under 100 lines, isolated with fresh providers per test, and validate accessibility alongside functionality.
  4. ## Rationale
  5. Component TDD provides immediate feedback during development. Failing tests (red) clarify requirements before writing code. Minimal implementations (green) prevent over-engineering. Refactoring with passing tests ensures changes don't break functionality. Isolated tests with fresh providers prevent state bleed in parallel runs. Accessibility assertions catch usability issues early. Visual debugging (Cypress runner, Storybook, Playwright trace viewer) accelerates diagnosis when tests fail.
  6. ## Pattern Examples
  7. ### Example 1: Red-Green-Refactor Loop
  8. **Context**: When building a new component, start with a failing test that describes the desired behavior. Implement just enough to pass, then refactor for quality.
  9. **Implementation**:
  10. ```typescript
  11. // Step 1: RED - Write failing test
  12. // Button.cy.tsx (Cypress Component Test)
  13. import { Button } from './Button';
  14. describe('Button Component', () => {
  15. it('should render with label', () => {
  16. cy.mount(<Button label="Click Me" />);
  17. cy.contains('Click Me').should('be.visible');
  18. });
  19. it('should call onClick when clicked', () => {
  20. const onClickSpy = cy.stub().as('onClick');
  21. cy.mount(<Button label="Submit" onClick={onClickSpy} />);
  22. cy.get('button').click();
  23. cy.get('@onClick').should('have.been.calledOnce');
  24. });
  25. });
  26. // Run test: FAILS - Button component doesn't exist yet
  27. // Error: "Cannot find module './Button'"
  28. // Step 2: GREEN - Minimal implementation
  29. // Button.tsx
  30. type ButtonProps = {
  31. label: string;
  32. onClick?: () => void;
  33. };
  34. export const Button = ({ label, onClick }: ButtonProps) => {
  35. return <button onClick={onClick}>{label}</button>;
  36. };
  37. // Run test: PASSES - Component renders and handles clicks
  38. // Step 3: REFACTOR - Improve implementation
  39. // Add disabled state, loading state, variants
  40. type ButtonProps = {
  41. label: string;
  42. onClick?: () => void;
  43. disabled?: boolean;
  44. loading?: boolean;
  45. variant?: 'primary' | 'secondary' | 'danger';
  46. };
  47. export const Button = ({
  48. label,
  49. onClick,
  50. disabled = false,
  51. loading = false,
  52. variant = 'primary'
  53. }: ButtonProps) => {
  54. return (
  55. <button
  56. onClick={onClick}
  57. disabled={disabled || loading}
  58. className={`btn btn-${variant}`}
  59. data-testid="button"
  60. >
  61. {loading ? <Spinner /> : label}
  62. </button>
  63. );
  64. };
  65. // Step 4: Expand tests for new features
  66. describe('Button Component', () => {
  67. it('should render with label', () => {
  68. cy.mount(<Button label="Click Me" />);
  69. cy.contains('Click Me').should('be.visible');
  70. });
  71. it('should call onClick when clicked', () => {
  72. const onClickSpy = cy.stub().as('onClick');
  73. cy.mount(<Button label="Submit" onClick={onClickSpy} />);
  74. cy.get('button').click();
  75. cy.get('@onClick').should('have.been.calledOnce');
  76. });
  77. it('should be disabled when disabled prop is true', () => {
  78. cy.mount(<Button label="Submit" disabled={true} />);
  79. cy.get('button').should('be.disabled');
  80. });
  81. it('should show spinner when loading', () => {
  82. cy.mount(<Button label="Submit" loading={true} />);
  83. cy.get('[data-testid="spinner"]').should('be.visible');
  84. cy.get('button').should('be.disabled');
  85. });
  86. it('should apply variant styles', () => {
  87. cy.mount(<Button label="Delete" variant="danger" />);
  88. cy.get('button').should('have.class', 'btn-danger');
  89. });
  90. });
  91. // Run tests: ALL PASS - Refactored component still works
  92. // Playwright Component Test equivalent
  93. import { test, expect } from '@playwright/experimental-ct-react';
  94. import { Button } from './Button';
  95. test.describe('Button Component', () => {
  96. test('should call onClick when clicked', async ({ mount }) => {
  97. let clicked = false;
  98. const component = await mount(
  99. <Button label="Submit" onClick={() => { clicked = true; }} />
  100. );
  101. await component.getByRole('button').click();
  102. expect(clicked).toBe(true);
  103. });
  104. test('should be disabled when loading', async ({ mount }) => {
  105. const component = await mount(<Button label="Submit" loading={true} />);
  106. await expect(component.getByRole('button')).toBeDisabled();
  107. await expect(component.getByTestId('spinner')).toBeVisible();
  108. });
  109. });
  110. ```
  111. **Key Points**:
  112. - Red: Write failing test first - clarifies requirements before coding
  113. - Green: Implement minimal code to pass - prevents over-engineering
  114. - Refactor: Improve code quality while keeping tests green
  115. - Expand: Add tests for new features after refactoring
  116. - Cycle repeats: Each new feature starts with a failing test
  117. ### Example 2: Provider Isolation Pattern
  118. **Context**: When testing components that depend on context providers (React Query, Auth, Router), wrap them with required providers in each test to prevent state bleed between tests.
  119. **Implementation**:
  120. ```typescript
  121. // test-utils/AllTheProviders.tsx
  122. import { FC, ReactNode } from 'react';
  123. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  124. import { BrowserRouter } from 'react-router-dom';
  125. import { AuthProvider } from '../contexts/AuthContext';
  126. type Props = {
  127. children: ReactNode;
  128. initialAuth?: { user: User | null; token: string | null };
  129. };
  130. export const AllTheProviders: FC<Props> = ({ children, initialAuth }) => {
  131. // Create NEW QueryClient per test (prevent state bleed)
  132. const queryClient = new QueryClient({
  133. defaultOptions: {
  134. queries: { retry: false },
  135. mutations: { retry: false }
  136. }
  137. });
  138. return (
  139. <QueryClientProvider client={queryClient}>
  140. <BrowserRouter>
  141. <AuthProvider initialAuth={initialAuth}>
  142. {children}
  143. </AuthProvider>
  144. </BrowserRouter>
  145. </QueryClientProvider>
  146. );
  147. };
  148. // Cypress custom mount command
  149. // cypress/support/component.tsx
  150. import { mount } from 'cypress/react18';
  151. import { AllTheProviders } from '../../test-utils/AllTheProviders';
  152. Cypress.Commands.add('wrappedMount', (component, options = {}) => {
  153. const { initialAuth, ...mountOptions } = options;
  154. return mount(
  155. <AllTheProviders initialAuth={initialAuth}>
  156. {component}
  157. </AllTheProviders>,
  158. mountOptions
  159. );
  160. });
  161. // Usage in tests
  162. // UserProfile.cy.tsx
  163. import { UserProfile } from './UserProfile';
  164. describe('UserProfile Component', () => {
  165. it('should display user when authenticated', () => {
  166. const user = { id: 1, name: 'John Doe', email: 'john@example.com' };
  167. cy.wrappedMount(<UserProfile />, {
  168. initialAuth: { user, token: 'fake-token' }
  169. });
  170. cy.contains('John Doe').should('be.visible');
  171. cy.contains('john@example.com').should('be.visible');
  172. });
  173. it('should show login prompt when not authenticated', () => {
  174. cy.wrappedMount(<UserProfile />, {
  175. initialAuth: { user: null, token: null }
  176. });
  177. cy.contains('Please log in').should('be.visible');
  178. });
  179. });
  180. // Playwright Component Test with providers
  181. import { test, expect } from '@playwright/experimental-ct-react';
  182. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  183. import { UserProfile } from './UserProfile';
  184. import { AuthProvider } from '../contexts/AuthContext';
  185. test.describe('UserProfile Component', () => {
  186. test('should display user when authenticated', async ({ mount }) => {
  187. const user = { id: 1, name: 'John Doe', email: 'john@example.com' };
  188. const queryClient = new QueryClient();
  189. const component = await mount(
  190. <QueryClientProvider client={queryClient}>
  191. <AuthProvider initialAuth={{ user, token: 'fake-token' }}>
  192. <UserProfile />
  193. </AuthProvider>
  194. </QueryClientProvider>
  195. );
  196. await expect(component.getByText('John Doe')).toBeVisible();
  197. await expect(component.getByText('john@example.com')).toBeVisible();
  198. });
  199. });
  200. ```
  201. **Key Points**:
  202. - Create NEW providers per test (QueryClient, Router, Auth)
  203. - Prevents state pollution between tests
  204. - `initialAuth` prop allows testing different auth states
  205. - Custom mount command (`wrappedMount`) reduces boilerplate
  206. - Providers wrap component, not the entire test suite
  207. ### Example 3: Accessibility Assertions
  208. **Context**: When testing components, validate accessibility alongside functionality using axe-core, ARIA roles, labels, and keyboard navigation.
  209. **Implementation**:
  210. ```typescript
  211. // Cypress with axe-core
  212. // cypress/support/component.tsx
  213. import 'cypress-axe';
  214. // Form.cy.tsx
  215. import { Form } from './Form';
  216. describe('Form Component Accessibility', () => {
  217. beforeEach(() => {
  218. cy.wrappedMount(<Form />);
  219. cy.injectAxe(); // Inject axe-core
  220. });
  221. it('should have no accessibility violations', () => {
  222. cy.checkA11y(); // Run axe scan
  223. });
  224. it('should have proper ARIA labels', () => {
  225. cy.get('input[name="email"]').should('have.attr', 'aria-label', 'Email address');
  226. cy.get('input[name="password"]').should('have.attr', 'aria-label', 'Password');
  227. cy.get('button[type="submit"]').should('have.attr', 'aria-label', 'Submit form');
  228. });
  229. it('should support keyboard navigation', () => {
  230. // Tab through form fields
  231. cy.get('input[name="email"]').focus().type('test@example.com');
  232. cy.realPress('Tab'); // cypress-real-events plugin
  233. cy.focused().should('have.attr', 'name', 'password');
  234. cy.focused().type('password123');
  235. cy.realPress('Tab');
  236. cy.focused().should('have.attr', 'type', 'submit');
  237. cy.realPress('Enter'); // Submit via keyboard
  238. cy.contains('Form submitted').should('be.visible');
  239. });
  240. it('should announce errors to screen readers', () => {
  241. cy.get('button[type="submit"]').click(); // Submit without data
  242. // Error has role="alert" and aria-live="polite"
  243. cy.get('[role="alert"]')
  244. .should('be.visible')
  245. .and('have.attr', 'aria-live', 'polite')
  246. .and('contain', 'Email is required');
  247. });
  248. it('should have sufficient color contrast', () => {
  249. cy.checkA11y(null, {
  250. rules: {
  251. 'color-contrast': { enabled: true }
  252. }
  253. });
  254. });
  255. });
  256. // Playwright with axe-playwright
  257. import { test, expect } from '@playwright/experimental-ct-react';
  258. import AxeBuilder from '@axe-core/playwright';
  259. import { Form } from './Form';
  260. test.describe('Form Component Accessibility', () => {
  261. test('should have no accessibility violations', async ({ mount, page }) => {
  262. await mount(<Form />);
  263. const accessibilityScanResults = await new AxeBuilder({ page })
  264. .analyze();
  265. expect(accessibilityScanResults.violations).toEqual([]);
  266. });
  267. test('should support keyboard navigation', async ({ mount, page }) => {
  268. const component = await mount(<Form />);
  269. await component.getByLabel('Email address').fill('test@example.com');
  270. await page.keyboard.press('Tab');
  271. await expect(component.getByLabel('Password')).toBeFocused();
  272. await component.getByLabel('Password').fill('password123');
  273. await page.keyboard.press('Tab');
  274. await expect(component.getByRole('button', { name: 'Submit form' })).toBeFocused();
  275. await page.keyboard.press('Enter');
  276. await expect(component.getByText('Form submitted')).toBeVisible();
  277. });
  278. });
  279. ```
  280. **Key Points**:
  281. - Use `cy.checkA11y()` (Cypress) or `AxeBuilder` (Playwright) for automated accessibility scanning
  282. - Validate ARIA roles, labels, and live regions
  283. - Test keyboard navigation (Tab, Enter, Escape)
  284. - Ensure errors are announced to screen readers (`role="alert"`, `aria-live`)
  285. - Check color contrast meets WCAG standards
  286. ### Example 4: Visual Regression Test
  287. **Context**: When testing components, capture screenshots to detect unintended visual changes. Use Playwright visual comparison or Cypress snapshot plugins.
  288. **Implementation**:
  289. ```typescript
  290. // Playwright visual regression
  291. import { test, expect } from '@playwright/experimental-ct-react';
  292. import { Button } from './Button';
  293. test.describe('Button Visual Regression', () => {
  294. test('should match primary button snapshot', async ({ mount }) => {
  295. const component = await mount(<Button label="Primary" variant="primary" />);
  296. // Capture and compare screenshot
  297. await expect(component).toHaveScreenshot('button-primary.png');
  298. });
  299. test('should match secondary button snapshot', async ({ mount }) => {
  300. const component = await mount(<Button label="Secondary" variant="secondary" />);
  301. await expect(component).toHaveScreenshot('button-secondary.png');
  302. });
  303. test('should match disabled button snapshot', async ({ mount }) => {
  304. const component = await mount(<Button label="Disabled" disabled={true} />);
  305. await expect(component).toHaveScreenshot('button-disabled.png');
  306. });
  307. test('should match loading button snapshot', async ({ mount }) => {
  308. const component = await mount(<Button label="Loading" loading={true} />);
  309. await expect(component).toHaveScreenshot('button-loading.png');
  310. });
  311. });
  312. // Cypress visual regression with percy or snapshot plugins
  313. import { Button } from './Button';
  314. describe('Button Visual Regression', () => {
  315. it('should match primary button snapshot', () => {
  316. cy.wrappedMount(<Button label="Primary" variant="primary" />);
  317. // Option 1: Percy (cloud-based visual testing)
  318. cy.percySnapshot('Button - Primary');
  319. // Option 2: cypress-plugin-snapshots (local snapshots)
  320. cy.get('button').toMatchImageSnapshot({
  321. name: 'button-primary',
  322. threshold: 0.01 // 1% threshold for pixel differences
  323. });
  324. });
  325. it('should match hover state', () => {
  326. cy.wrappedMount(<Button label="Hover Me" />);
  327. cy.get('button').realHover(); // cypress-real-events
  328. cy.percySnapshot('Button - Hover State');
  329. });
  330. it('should match focus state', () => {
  331. cy.wrappedMount(<Button label="Focus Me" />);
  332. cy.get('button').focus();
  333. cy.percySnapshot('Button - Focus State');
  334. });
  335. });
  336. // Playwright configuration for visual regression
  337. // playwright.config.ts
  338. export default defineConfig({
  339. expect: {
  340. toHaveScreenshot: {
  341. maxDiffPixels: 100, // Allow 100 pixels difference
  342. threshold: 0.2 // 20% threshold
  343. }
  344. },
  345. use: {
  346. screenshot: 'only-on-failure'
  347. }
  348. });
  349. // Update snapshots when intentional changes are made
  350. // npx playwright test --update-snapshots
  351. ```
  352. **Key Points**:
  353. - Playwright: Use `toHaveScreenshot()` for built-in visual comparison
  354. - Cypress: Use Percy (cloud) or snapshot plugins (local) for visual testing
  355. - Capture different states: default, hover, focus, disabled, loading
  356. - Set threshold for acceptable pixel differences (avoid false positives)
  357. - Update snapshots when visual changes are intentional
  358. - Visual tests catch unintended CSS/layout regressions
  359. ## Integration Points
  360. - **Used in workflows**: `*atdd` (component test generation), `*automate` (component test expansion), `*framework` (component testing setup)
  361. - **Related fragments**:
  362. - `test-quality.md` - Keep component tests <100 lines, isolated, focused
  363. - `fixture-architecture.md` - Provider wrapping patterns, custom mount commands
  364. - `data-factories.md` - Factory functions for component props
  365. - `test-levels-framework.md` - When to use component tests vs E2E tests
  366. ## TDD Workflow Summary
  367. **Red-Green-Refactor Cycle**:
  368. 1. **Red**: Write failing test describing desired behavior
  369. 2. **Green**: Implement minimal code to make test pass
  370. 3. **Refactor**: Improve code quality, tests stay green
  371. 4. **Repeat**: Each new feature starts with failing test
  372. **Component Test Checklist**:
  373. - [ ] Test renders with required props
  374. - [ ] Test user interactions (click, type, submit)
  375. - [ ] Test different states (loading, error, disabled)
  376. - [ ] Test accessibility (ARIA, keyboard navigation)
  377. - [ ] Test visual regression (snapshots)
  378. - [ ] Isolate with fresh providers (no state bleed)
  379. - [ ] Keep tests <100 lines (split by intent)
  380. _Source: CCTDD repository, Murat component testing talks, Playwright/Cypress component testing docs._