@fpkit/acss 1.0.0-beta.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -0
- package/docs/README.md +325 -0
- package/docs/guides/accessibility.md +764 -0
- package/docs/guides/architecture.md +705 -0
- package/docs/guides/composition.md +688 -0
- package/docs/guides/css-variables.md +522 -0
- package/docs/guides/storybook.md +828 -0
- package/docs/guides/testing.md +817 -0
- package/docs/testing/focus-indicator-testing.md +437 -0
- package/libs/{chunk-7XPFW7CB.js → chunk-43TK2ICH.js} +2 -2
- package/libs/chunk-5PJYLVFY.cjs +17 -0
- package/libs/chunk-5PJYLVFY.cjs.map +1 -0
- package/libs/chunk-E4OSROCA.cjs +17 -0
- package/libs/chunk-E4OSROCA.cjs.map +1 -0
- package/libs/chunk-KVKQLRJG.js +10 -0
- package/libs/chunk-KVKQLRJG.js.map +1 -0
- package/libs/{chunk-QVW6W76L.cjs → chunk-MGPWZRBX.cjs} +3 -3
- package/libs/chunk-NNTBIHSD.js +8 -0
- package/libs/chunk-NNTBIHSD.js.map +1 -0
- package/libs/{chunk-X3JCTEPD.js → chunk-QKHPHMG2.js} +2 -2
- package/libs/{chunk-T4T6GWYQ.cjs → chunk-R7NLLZU2.cjs} +3 -3
- package/libs/{chunk-X5LGFCWG.js → chunk-UJAQVHWC.js} +3 -3
- package/libs/{chunk-DKTHCQ5P.cjs → chunk-X5RKCLDC.cjs} +3 -3
- package/libs/components/breadcrumbs/breadcrumb.cjs +5 -5
- package/libs/components/breadcrumbs/breadcrumb.d.cts +1 -1
- package/libs/components/breadcrumbs/breadcrumb.d.ts +1 -1
- package/libs/components/breadcrumbs/breadcrumb.js +2 -2
- package/libs/components/button.cjs +3 -3
- package/libs/components/button.d.cts +1 -1
- package/libs/components/button.d.ts +1 -1
- package/libs/components/button.js +1 -1
- package/libs/components/buttons/button.css +1 -1
- package/libs/components/buttons/button.css.map +1 -1
- package/libs/components/buttons/button.min.css +2 -2
- package/libs/components/dialog/dialog.cjs +4 -4
- package/libs/components/dialog/dialog.js +2 -2
- package/libs/components/icons/icon.d.cts +32 -32
- package/libs/components/icons/icon.d.ts +32 -32
- package/libs/components/link/link.cjs +11 -3
- package/libs/components/link/link.d.cts +131 -3
- package/libs/components/link/link.d.ts +131 -3
- package/libs/components/link/link.js +1 -1
- package/libs/components/list/list.css +1 -1
- package/libs/components/list/list.min.css +1 -1
- package/libs/components/modal.cjs +3 -3
- package/libs/components/modal.js +2 -2
- package/libs/hooks.cjs +3 -3
- package/libs/hooks.d.cts +1 -1
- package/libs/hooks.d.ts +1 -1
- package/libs/hooks.js +2 -2
- package/libs/index.cjs +12 -12
- package/libs/index.css +1 -1
- package/libs/index.css.map +1 -1
- package/libs/index.d.cts +237 -2
- package/libs/index.d.ts +237 -2
- package/libs/index.js +5 -5
- package/package.json +4 -3
- package/src/components/README.mdx +1 -1
- package/src/components/breadcrumbs/breadcrumb.test.tsx +1 -2
- package/src/components/buttons/README.mdx +19 -9
- package/src/components/buttons/button.scss +5 -0
- package/src/components/buttons/button.stories.tsx +8 -5
- package/src/components/buttons/button.tsx +19 -15
- package/src/components/cards/card.stories.tsx +1 -1
- package/src/components/details/details.stories.tsx +1 -1
- package/src/components/form/form.stories.tsx +1 -1
- package/src/components/form/input.stories.tsx +1 -1
- package/src/components/form/select.stories.tsx +1 -1
- package/src/components/heading/README.mdx +292 -0
- package/src/components/icons/icon.stories.tsx +1 -1
- package/src/components/link/link.stories.tsx +205 -8
- package/src/components/link/link.test.tsx +1 -1
- package/src/components/link/link.tsx +22 -0
- package/src/components/link/link.types.ts +11 -3
- package/src/components/list/list.scss +1 -1
- package/src/components/nav/nav.stories.tsx +1 -1
- package/src/components/ui.stories.tsx +53 -19
- package/src/docs/accessibility.mdx +484 -0
- package/src/docs/composition.mdx +549 -0
- package/src/docs/css-variables.mdx +380 -0
- package/src/docs/fpkit-developer.mdx +623 -0
- package/src/introduction.mdx +356 -0
- package/src/styles/buttons/button.css +4 -0
- package/src/styles/buttons/button.css.map +1 -1
- package/src/styles/index.css +9 -3
- package/src/styles/index.css.map +1 -1
- package/src/styles/list/list.css +1 -1
- package/src/styles/utilities/_disabled.scss +5 -4
- package/libs/chunk-33PNJ4LO.cjs +0 -15
- package/libs/chunk-33PNJ4LO.cjs.map +0 -1
- package/libs/chunk-GT77BX4L.cjs +0 -17
- package/libs/chunk-GT77BX4L.cjs.map +0 -1
- package/libs/chunk-OVWLQYMK.js +0 -10
- package/libs/chunk-OVWLQYMK.js.map +0 -1
- package/libs/chunk-UEPAWMDF.js +0 -8
- package/libs/chunk-UEPAWMDF.js.map +0 -1
- package/libs/link-5192f411.d.ts +0 -323
- /package/libs/{chunk-7XPFW7CB.js.map → chunk-43TK2ICH.js.map} +0 -0
- /package/libs/{chunk-QVW6W76L.cjs.map → chunk-MGPWZRBX.cjs.map} +0 -0
- /package/libs/{chunk-X3JCTEPD.js.map → chunk-QKHPHMG2.js.map} +0 -0
- /package/libs/{chunk-T4T6GWYQ.cjs.map → chunk-R7NLLZU2.cjs.map} +0 -0
- /package/libs/{chunk-X5LGFCWG.js.map → chunk-UJAQVHWC.js.map} +0 -0
- /package/libs/{chunk-DKTHCQ5P.cjs.map → chunk-X5RKCLDC.cjs.map} +0 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
# Testing Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This guide shows how to test applications and custom components built with @fpkit/acss using **Vitest** and **React Testing Library**. fpkit components are already tested, so focus your tests on your custom logic, compositions, and integrations.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
### Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Vitest Configuration
|
|
18
|
+
|
|
19
|
+
```javascript
|
|
20
|
+
// vitest.config.ts
|
|
21
|
+
import { defineConfig } from 'vitest/config'
|
|
22
|
+
import react from '@vitejs/plugin-react'
|
|
23
|
+
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
plugins: [react()],
|
|
26
|
+
test: {
|
|
27
|
+
environment: 'jsdom',
|
|
28
|
+
globals: true,
|
|
29
|
+
setupFiles: './src/test/setup.ts',
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Setup File
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// src/test/setup.ts
|
|
38
|
+
import { expect, afterEach } from 'vitest'
|
|
39
|
+
import { cleanup } from '@testing-library/react'
|
|
40
|
+
import * as matchers from '@testing-library/jest-dom/matchers'
|
|
41
|
+
|
|
42
|
+
expect.extend(matchers)
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
cleanup()
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Package.json Scripts
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"scripts": {
|
|
54
|
+
"test": "vitest",
|
|
55
|
+
"test:ui": "vitest --ui",
|
|
56
|
+
"test:coverage": "vitest --coverage"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Basic Testing Patterns
|
|
64
|
+
|
|
65
|
+
### Rendering Tests
|
|
66
|
+
|
|
67
|
+
Test that your composed components render correctly:
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { render, screen } from '@testing-library/react'
|
|
71
|
+
import { describe, it, expect } from 'vitest'
|
|
72
|
+
import { Button } from '@fpkit/acss'
|
|
73
|
+
|
|
74
|
+
describe('Custom Button Usage', () => {
|
|
75
|
+
it('renders button with custom content', () => {
|
|
76
|
+
render(<Button>Click me</Button>)
|
|
77
|
+
expect(screen.getByText('Click me')).toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('renders button with custom className', () => {
|
|
81
|
+
render(<Button className="custom-btn">Click me</Button>)
|
|
82
|
+
const button = screen.getByRole('button')
|
|
83
|
+
expect(button).toHaveClass('custom-btn')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Testing Composed Components
|
|
89
|
+
|
|
90
|
+
Focus tests on how your components integrate with fpkit:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
import { Badge, Button } from '@fpkit/acss'
|
|
94
|
+
|
|
95
|
+
// Your composed component
|
|
96
|
+
const StatusButton = ({ status, children }) => (
|
|
97
|
+
<Button>
|
|
98
|
+
{children}
|
|
99
|
+
<Badge variant={status}>{status}</Badge>
|
|
100
|
+
</Button>
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// Your tests
|
|
104
|
+
describe('StatusButton', () => {
|
|
105
|
+
it('renders button with status badge', () => {
|
|
106
|
+
render(<StatusButton status="active">Server</StatusButton>)
|
|
107
|
+
|
|
108
|
+
// Test composition
|
|
109
|
+
expect(screen.getByRole('button')).toBeInTheDocument()
|
|
110
|
+
expect(screen.getByText('Server')).toBeInTheDocument()
|
|
111
|
+
expect(screen.getByText('active')).toBeInTheDocument()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('passes props to underlying button', () => {
|
|
115
|
+
const handleClick = vi.fn()
|
|
116
|
+
render(
|
|
117
|
+
<StatusButton status="inactive" onClick={handleClick}>
|
|
118
|
+
Server
|
|
119
|
+
</StatusButton>
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const button = screen.getByRole('button')
|
|
123
|
+
await userEvent.click(button)
|
|
124
|
+
|
|
125
|
+
expect(handleClick).toHaveBeenCalledTimes(1)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Query Best Practices
|
|
133
|
+
|
|
134
|
+
### Query Priority
|
|
135
|
+
|
|
136
|
+
Use queries in this order:
|
|
137
|
+
|
|
138
|
+
1. **getByRole** (best) - Most accessible query
|
|
139
|
+
2. **getByLabelText** - For form controls
|
|
140
|
+
3. **getByText** - For non-interactive content
|
|
141
|
+
4. **getByTestId** (last resort) - When no other query works
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// ✅ Good - query by role
|
|
145
|
+
const button = screen.getByRole('button', { name: 'Submit' })
|
|
146
|
+
|
|
147
|
+
// ✅ Good - query by label
|
|
148
|
+
const input = screen.getByLabelText('Email')
|
|
149
|
+
|
|
150
|
+
// ✅ Good - query by text
|
|
151
|
+
const heading = screen.getByText('Welcome')
|
|
152
|
+
|
|
153
|
+
// ⚠️ Use sparingly - test ID
|
|
154
|
+
const custom = screen.getByTestId('custom-element')
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Common Queries
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
// Buttons
|
|
161
|
+
screen.getByRole('button')
|
|
162
|
+
screen.getByRole('button', { name: 'Submit' })
|
|
163
|
+
|
|
164
|
+
// Links
|
|
165
|
+
screen.getByRole('link', { name: 'Home' })
|
|
166
|
+
|
|
167
|
+
// Headings
|
|
168
|
+
screen.getByRole('heading', { level: 1 })
|
|
169
|
+
screen.getByRole('heading', { name: 'Title' })
|
|
170
|
+
|
|
171
|
+
// Form controls
|
|
172
|
+
screen.getByLabelText('Email')
|
|
173
|
+
screen.getByPlaceholderText('Enter email...')
|
|
174
|
+
screen.getByRole('textbox')
|
|
175
|
+
|
|
176
|
+
// Text content
|
|
177
|
+
screen.getByText('Hello')
|
|
178
|
+
screen.getByText(/hello/i) // Case-insensitive
|
|
179
|
+
|
|
180
|
+
// Test IDs
|
|
181
|
+
screen.getByTestId('custom-id')
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Multiple Elements
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// Get all matching elements
|
|
188
|
+
const buttons = screen.getAllByRole('button')
|
|
189
|
+
expect(buttons).toHaveLength(3)
|
|
190
|
+
|
|
191
|
+
// Query (returns null if not found)
|
|
192
|
+
const button = screen.queryByRole('button')
|
|
193
|
+
expect(button).not.toBeInTheDocument()
|
|
194
|
+
|
|
195
|
+
// Find (async, waits for element)
|
|
196
|
+
const button = await screen.findByRole('button')
|
|
197
|
+
expect(button).toBeInTheDocument()
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Event Testing
|
|
203
|
+
|
|
204
|
+
### User Interactions
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
import { userEvent } from '@testing-library/user-event'
|
|
208
|
+
|
|
209
|
+
describe('User Interactions', () => {
|
|
210
|
+
it('calls onClick handler when clicked', async () => {
|
|
211
|
+
const handleClick = vi.fn()
|
|
212
|
+
render(<Button onClick={handleClick}>Click me</Button>)
|
|
213
|
+
|
|
214
|
+
const button = screen.getByRole('button')
|
|
215
|
+
await userEvent.click(button)
|
|
216
|
+
|
|
217
|
+
expect(handleClick).toHaveBeenCalledTimes(1)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('handles keyboard activation', async () => {
|
|
221
|
+
const handleClick = vi.fn()
|
|
222
|
+
render(<Button onClick={handleClick}>Click me</Button>)
|
|
223
|
+
|
|
224
|
+
const button = screen.getByRole('button')
|
|
225
|
+
button.focus()
|
|
226
|
+
await userEvent.keyboard('{Enter}')
|
|
227
|
+
|
|
228
|
+
expect(handleClick).toHaveBeenCalled()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('handles hover events', async () => {
|
|
232
|
+
const handleHover = vi.fn()
|
|
233
|
+
render(<Button onMouseEnter={handleHover}>Hover me</Button>)
|
|
234
|
+
|
|
235
|
+
const button = screen.getByRole('button')
|
|
236
|
+
await userEvent.hover(button)
|
|
237
|
+
|
|
238
|
+
expect(handleHover).toHaveBeenCalled()
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Form Interactions
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
describe('Form Interactions', () => {
|
|
247
|
+
it('handles text input', async () => {
|
|
248
|
+
const handleChange = vi.fn()
|
|
249
|
+
render(<Input onChange={handleChange} />)
|
|
250
|
+
|
|
251
|
+
const input = screen.getByRole('textbox')
|
|
252
|
+
await userEvent.type(input, 'Hello')
|
|
253
|
+
|
|
254
|
+
expect(input).toHaveValue('Hello')
|
|
255
|
+
expect(handleChange).toHaveBeenCalledTimes(5) // Once per character
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('handles form submission', async () => {
|
|
259
|
+
const handleSubmit = vi.fn((e) => e.preventDefault())
|
|
260
|
+
render(
|
|
261
|
+
<form onSubmit={handleSubmit}>
|
|
262
|
+
<Input name="email" />
|
|
263
|
+
<Button type="submit">Submit</Button>
|
|
264
|
+
</form>
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const input = screen.getByRole('textbox')
|
|
268
|
+
const submitBtn = screen.getByRole('button')
|
|
269
|
+
|
|
270
|
+
await userEvent.type(input, 'test@example.com')
|
|
271
|
+
await userEvent.click(submitBtn)
|
|
272
|
+
|
|
273
|
+
expect(handleSubmit).toHaveBeenCalled()
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Testing Component States
|
|
281
|
+
|
|
282
|
+
### Disabled State
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
describe('Disabled State', () => {
|
|
286
|
+
it('has aria-disabled attribute when disabled', () => {
|
|
287
|
+
render(<Button disabled>Click me</Button>)
|
|
288
|
+
const button = screen.getByRole('button')
|
|
289
|
+
expect(button).toHaveAttribute('aria-disabled', 'true')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('does not call onClick when disabled', async () => {
|
|
293
|
+
const handleClick = vi.fn()
|
|
294
|
+
render(<Button disabled onClick={handleClick}>Click me</Button>)
|
|
295
|
+
|
|
296
|
+
const button = screen.getByRole('button')
|
|
297
|
+
await userEvent.click(button)
|
|
298
|
+
|
|
299
|
+
// fpkit buttons with aria-disabled will call onClick
|
|
300
|
+
// but you can prevent it in your wrapper
|
|
301
|
+
// Test your specific implementation
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Loading State
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
const LoadingButton = ({ loading, onClick, children }) => {
|
|
310
|
+
return (
|
|
311
|
+
<Button disabled={loading} onClick={onClick}>
|
|
312
|
+
{loading ? 'Loading...' : children}
|
|
313
|
+
</Button>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
describe('LoadingButton', () => {
|
|
318
|
+
it('shows loading text when loading', () => {
|
|
319
|
+
render(<LoadingButton loading>Submit</LoadingButton>)
|
|
320
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
|
321
|
+
expect(screen.queryByText('Submit')).not.toBeInTheDocument()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('disables button while loading', () => {
|
|
325
|
+
render(<LoadingButton loading>Submit</LoadingButton>)
|
|
326
|
+
const button = screen.getByRole('button')
|
|
327
|
+
expect(button).toHaveAttribute('aria-disabled', 'true')
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Conditional Rendering
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
describe('Conditional Rendering', () => {
|
|
336
|
+
it('shows error message when error exists', () => {
|
|
337
|
+
const { rerender } = render(<Input />)
|
|
338
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
|
339
|
+
|
|
340
|
+
rerender(<Input error="Invalid input" />)
|
|
341
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Invalid input')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('renders different content based on prop', () => {
|
|
345
|
+
const { rerender } = render(
|
|
346
|
+
<Notification inline>Message</Notification>
|
|
347
|
+
)
|
|
348
|
+
expect(screen.getByRole('alert')).toBeInTheDocument()
|
|
349
|
+
|
|
350
|
+
rerender(<Notification isOpen>Message</Notification>)
|
|
351
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Accessibility Testing
|
|
359
|
+
|
|
360
|
+
### ARIA Attributes
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
describe('Accessibility', () => {
|
|
364
|
+
it('has correct ARIA label', () => {
|
|
365
|
+
render(
|
|
366
|
+
<Button aria-label="Close dialog">
|
|
367
|
+
<Icon name="close" />
|
|
368
|
+
</Button>
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
const button = screen.getByRole('button', { name: 'Close dialog' })
|
|
372
|
+
expect(button).toBeInTheDocument()
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('has correct ARIA description', () => {
|
|
376
|
+
render(
|
|
377
|
+
<>
|
|
378
|
+
<Button aria-describedby="hint">Submit</Button>
|
|
379
|
+
<div id="hint">This will save your changes</div>
|
|
380
|
+
</>
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
const button = screen.getByRole('button')
|
|
384
|
+
expect(button).toHaveAttribute('aria-describedby', 'hint')
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Keyboard Navigation
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
describe('Keyboard Navigation', () => {
|
|
393
|
+
it('is focusable with Tab key', async () => {
|
|
394
|
+
render(<Button>Click me</Button>)
|
|
395
|
+
|
|
396
|
+
const button = screen.getByRole('button')
|
|
397
|
+
expect(button).not.toHaveFocus()
|
|
398
|
+
|
|
399
|
+
await userEvent.tab()
|
|
400
|
+
expect(button).toHaveFocus()
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('navigates through multiple buttons', async () => {
|
|
404
|
+
render(
|
|
405
|
+
<>
|
|
406
|
+
<Button>First</Button>
|
|
407
|
+
<Button>Second</Button>
|
|
408
|
+
<Button>Third</Button>
|
|
409
|
+
</>
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
const [first, second, third] = screen.getAllByRole('button')
|
|
413
|
+
|
|
414
|
+
await userEvent.tab()
|
|
415
|
+
expect(first).toHaveFocus()
|
|
416
|
+
|
|
417
|
+
await userEvent.tab()
|
|
418
|
+
expect(second).toHaveFocus()
|
|
419
|
+
|
|
420
|
+
await userEvent.tab()
|
|
421
|
+
expect(third).toHaveFocus()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('closes dialog on Escape key', async () => {
|
|
425
|
+
const handleClose = vi.fn()
|
|
426
|
+
render(
|
|
427
|
+
<Dialog isOpen onClose={handleClose}>
|
|
428
|
+
Content
|
|
429
|
+
</Dialog>
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
await userEvent.keyboard('{Escape}')
|
|
433
|
+
expect(handleClose).toHaveBeenCalled()
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Automated Accessibility Testing
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
npm install -D jest-axe
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
import { axe, toHaveNoViolations } from 'jest-axe'
|
|
446
|
+
|
|
447
|
+
expect.extend(toHaveNoViolations)
|
|
448
|
+
|
|
449
|
+
describe('Accessibility Violations', () => {
|
|
450
|
+
it('should not have accessibility violations', async () => {
|
|
451
|
+
const { container } = render(
|
|
452
|
+
<div>
|
|
453
|
+
<Button>Click me</Button>
|
|
454
|
+
<Link href="/page">Navigate</Link>
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
const results = await axe(container)
|
|
459
|
+
expect(results).toHaveNoViolations()
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('composed component has no violations', async () => {
|
|
463
|
+
const { container } = render(
|
|
464
|
+
<StatusButton status="active">Server</StatusButton>
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
const results = await axe(container)
|
|
468
|
+
expect(results).toHaveNoViolations()
|
|
469
|
+
})
|
|
470
|
+
})
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
## Async Testing
|
|
476
|
+
|
|
477
|
+
### Waiting for Elements
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
describe('Async Rendering', () => {
|
|
481
|
+
it('shows success message after action', async () => {
|
|
482
|
+
const SuccessComponent = () => {
|
|
483
|
+
const [success, setSuccess] = useState(false)
|
|
484
|
+
return (
|
|
485
|
+
<>
|
|
486
|
+
<Button onClick={() => setTimeout(() => setSuccess(true), 100)}>
|
|
487
|
+
Submit
|
|
488
|
+
</Button>
|
|
489
|
+
{success && <div role="alert">Success!</div>}
|
|
490
|
+
</>
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
render(<SuccessComponent />)
|
|
495
|
+
|
|
496
|
+
const button = screen.getByRole('button')
|
|
497
|
+
await userEvent.click(button)
|
|
498
|
+
|
|
499
|
+
// Wait for success message to appear
|
|
500
|
+
const alert = await screen.findByRole('alert')
|
|
501
|
+
expect(alert).toHaveTextContent('Success!')
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### Testing Loading States
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import { waitFor } from '@testing-library/react'
|
|
510
|
+
|
|
511
|
+
describe('Loading States', () => {
|
|
512
|
+
it('shows loading then content', async () => {
|
|
513
|
+
const DataComponent = () => {
|
|
514
|
+
const [loading, setLoading] = useState(true)
|
|
515
|
+
const [data, setData] = useState(null)
|
|
516
|
+
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
setTimeout(() => {
|
|
519
|
+
setData('Loaded data')
|
|
520
|
+
setLoading(false)
|
|
521
|
+
}, 100)
|
|
522
|
+
}, [])
|
|
523
|
+
|
|
524
|
+
if (loading) return <div>Loading...</div>
|
|
525
|
+
return <div>{data}</div>
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
render(<DataComponent />)
|
|
529
|
+
|
|
530
|
+
// Initially shows loading
|
|
531
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
|
532
|
+
|
|
533
|
+
// Wait for loading to disappear
|
|
534
|
+
await waitFor(() => {
|
|
535
|
+
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
// Data is displayed
|
|
539
|
+
expect(screen.getByText('Loaded data')).toBeInTheDocument()
|
|
540
|
+
})
|
|
541
|
+
})
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
---
|
|
545
|
+
|
|
546
|
+
## Testing Compound Components
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
import { Card } from '@fpkit/acss'
|
|
550
|
+
|
|
551
|
+
describe('Card Component Usage', () => {
|
|
552
|
+
it('renders card with all sub-components', () => {
|
|
553
|
+
render(
|
|
554
|
+
<Card>
|
|
555
|
+
<Card.Header>
|
|
556
|
+
<Card.Title>Title</Card.Title>
|
|
557
|
+
</Card.Header>
|
|
558
|
+
<Card.Content>Content</Card.Content>
|
|
559
|
+
<Card.Footer>
|
|
560
|
+
<Button>Action</Button>
|
|
561
|
+
</Card.Footer>
|
|
562
|
+
</Card>
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
expect(screen.getByText('Title')).toBeInTheDocument()
|
|
566
|
+
expect(screen.getByText('Content')).toBeInTheDocument()
|
|
567
|
+
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument()
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('renders card without optional sections', () => {
|
|
571
|
+
render(
|
|
572
|
+
<Card>
|
|
573
|
+
<Card.Content>Just content</Card.Content>
|
|
574
|
+
</Card>
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
expect(screen.getByText('Just content')).toBeInTheDocument()
|
|
578
|
+
// Header and footer should not exist
|
|
579
|
+
})
|
|
580
|
+
})
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
## Mock Functions
|
|
586
|
+
|
|
587
|
+
### Creating and Using Mocks
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
import { vi } from 'vitest'
|
|
591
|
+
|
|
592
|
+
describe('Mocking', () => {
|
|
593
|
+
it('mocks onClick handler', async () => {
|
|
594
|
+
const handleClick = vi.fn()
|
|
595
|
+
render(<Button onClick={handleClick}>Click me</Button>)
|
|
596
|
+
|
|
597
|
+
await userEvent.click(screen.getByRole('button'))
|
|
598
|
+
|
|
599
|
+
expect(handleClick).toHaveBeenCalledTimes(1)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('mocks with return value', () => {
|
|
603
|
+
const mockFn = vi.fn(() => 'mocked value')
|
|
604
|
+
const result = mockFn()
|
|
605
|
+
|
|
606
|
+
expect(result).toBe('mocked value')
|
|
607
|
+
expect(mockFn).toHaveBeenCalled()
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('mocks with arguments', async () => {
|
|
611
|
+
const handleChange = vi.fn()
|
|
612
|
+
render(<Input onChange={handleChange} />)
|
|
613
|
+
|
|
614
|
+
const input = screen.getByRole('textbox')
|
|
615
|
+
await userEvent.type(input, 'test')
|
|
616
|
+
|
|
617
|
+
expect(handleChange).toHaveBeenCalledWith(
|
|
618
|
+
expect.objectContaining({
|
|
619
|
+
target: expect.objectContaining({ value: 't' })
|
|
620
|
+
})
|
|
621
|
+
)
|
|
622
|
+
})
|
|
623
|
+
})
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
## Test Organization
|
|
629
|
+
|
|
630
|
+
### Describe Blocks
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
describe('CustomComponent', () => {
|
|
634
|
+
describe('rendering', () => {
|
|
635
|
+
it('renders with default props', () => {
|
|
636
|
+
// Test
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
it('renders with custom props', () => {
|
|
640
|
+
// Test
|
|
641
|
+
})
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
describe('interactions', () => {
|
|
645
|
+
it('handles click events', async () => {
|
|
646
|
+
// Test
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('handles keyboard events', async () => {
|
|
650
|
+
// Test
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
describe('accessibility', () => {
|
|
655
|
+
it('has proper ARIA attributes', () => {
|
|
656
|
+
// Test
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it('is keyboard navigable', async () => {
|
|
660
|
+
// Test
|
|
661
|
+
})
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
describe('states', () => {
|
|
665
|
+
it('shows loading state', () => {
|
|
666
|
+
// Test
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('shows error state', () => {
|
|
670
|
+
// Test
|
|
671
|
+
})
|
|
672
|
+
})
|
|
673
|
+
})
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### Test Naming
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
// ✅ Good - descriptive and clear
|
|
680
|
+
it('calls onClick handler when button is clicked')
|
|
681
|
+
it('shows error message when validation fails')
|
|
682
|
+
it('disables submit button while form is submitting')
|
|
683
|
+
it('renders badge with correct variant')
|
|
684
|
+
|
|
685
|
+
// ❌ Bad - vague or redundant
|
|
686
|
+
it('works correctly')
|
|
687
|
+
it('test button')
|
|
688
|
+
it('should show message') // "should" is redundant
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
---
|
|
692
|
+
|
|
693
|
+
## Common Testing Patterns
|
|
694
|
+
|
|
695
|
+
### Testing Custom Props
|
|
696
|
+
|
|
697
|
+
```typescript
|
|
698
|
+
const CustomButton = ({ loading, error, ...props }) => (
|
|
699
|
+
<Button
|
|
700
|
+
{...props}
|
|
701
|
+
disabled={loading}
|
|
702
|
+
style={{ '--btn-bg': error ? 'red' : undefined }}
|
|
703
|
+
>
|
|
704
|
+
{loading ? 'Loading...' : props.children}
|
|
705
|
+
</Button>
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
describe('CustomButton Props', () => {
|
|
709
|
+
it('shows loading state', () => {
|
|
710
|
+
render(<CustomButton loading>Submit</CustomButton>)
|
|
711
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
it('applies error styling', () => {
|
|
715
|
+
render(<CustomButton error>Submit</CustomButton>)
|
|
716
|
+
const button = screen.getByRole('button')
|
|
717
|
+
expect(button).toHaveStyle({ '--btn-bg': 'red' })
|
|
718
|
+
})
|
|
719
|
+
})
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
### Testing Composition
|
|
723
|
+
|
|
724
|
+
```typescript
|
|
725
|
+
const ActionCard = ({ title, onAction }) => (
|
|
726
|
+
<Card>
|
|
727
|
+
<Card.Title>{title}</Card.Title>
|
|
728
|
+
<Card.Footer>
|
|
729
|
+
<Button onClick={onAction}>Perform Action</Button>
|
|
730
|
+
</Card.Footer>
|
|
731
|
+
</Card>
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
describe('ActionCard Composition', () => {
|
|
735
|
+
it('renders composed structure', () => {
|
|
736
|
+
render(<ActionCard title="Test Card" onAction={vi.fn()} />)
|
|
737
|
+
|
|
738
|
+
expect(screen.getByText('Test Card')).toBeInTheDocument()
|
|
739
|
+
expect(screen.getByRole('button')).toBeInTheDocument()
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it('calls action handler', async () => {
|
|
743
|
+
const handleAction = vi.fn()
|
|
744
|
+
render(<ActionCard title="Test" onAction={handleAction} />)
|
|
745
|
+
|
|
746
|
+
await userEvent.click(screen.getByRole('button'))
|
|
747
|
+
expect(handleAction).toHaveBeenCalled()
|
|
748
|
+
})
|
|
749
|
+
})
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
---
|
|
753
|
+
|
|
754
|
+
## Best Practices
|
|
755
|
+
|
|
756
|
+
### ✅ Do
|
|
757
|
+
|
|
758
|
+
- **Test behavior, not implementation** - Focus on what users experience
|
|
759
|
+
- **Use accessible queries** - `getByRole`, `getByLabelText`
|
|
760
|
+
- **Test integration** - How components work together
|
|
761
|
+
- **Test user interactions** - Clicks, typing, keyboard navigation
|
|
762
|
+
- **Test accessibility** - ARIA attributes, keyboard support
|
|
763
|
+
- **Use meaningful test names** - Describe what's being tested
|
|
764
|
+
- **Keep tests focused** - One concept per test
|
|
765
|
+
|
|
766
|
+
### ❌ Don't
|
|
767
|
+
|
|
768
|
+
- **Don't test fpkit internals** - fpkit components are already tested
|
|
769
|
+
- **Don't test styling details** - Unless critical to functionality
|
|
770
|
+
- **Don't use implementation details** - Avoid querying by class names
|
|
771
|
+
- **Don't test third-party libraries** - Trust they're tested
|
|
772
|
+
- **Don't write redundant tests** - If fpkit tests it, you don't need to
|
|
773
|
+
|
|
774
|
+
---
|
|
775
|
+
|
|
776
|
+
## Running Tests
|
|
777
|
+
|
|
778
|
+
```bash
|
|
779
|
+
# Run all tests
|
|
780
|
+
npm test
|
|
781
|
+
|
|
782
|
+
# Run tests in watch mode
|
|
783
|
+
npm test -- --watch
|
|
784
|
+
|
|
785
|
+
# Run with coverage
|
|
786
|
+
npm run test:coverage
|
|
787
|
+
|
|
788
|
+
# Run with UI
|
|
789
|
+
npm run test:ui
|
|
790
|
+
|
|
791
|
+
# Run specific test file
|
|
792
|
+
npm test -- button.test.tsx
|
|
793
|
+
|
|
794
|
+
# Update snapshots
|
|
795
|
+
npm test -- -u
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
800
|
+
## Additional Resources
|
|
801
|
+
|
|
802
|
+
- **[Vitest Documentation](https://vitest.dev/)** - Test runner
|
|
803
|
+
- **[Testing Library Docs](https://testing-library.com/docs/react-testing-library/intro/)** - Query and interaction APIs
|
|
804
|
+
- **[jest-axe](https://github.com/nickcolley/jest-axe)** - Automated accessibility testing
|
|
805
|
+
- **[Common Testing Mistakes](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library)** - Best practices
|
|
806
|
+
|
|
807
|
+
---
|
|
808
|
+
|
|
809
|
+
## Related Guides
|
|
810
|
+
|
|
811
|
+
- **[Accessibility Guide](./accessibility.md)** - Accessibility patterns to test
|
|
812
|
+
- **[Composition Guide](./composition.md)** - Patterns to test in compositions
|
|
813
|
+
- **[Architecture Guide](./architecture.md)** - Component patterns and structure
|
|
814
|
+
|
|
815
|
+
---
|
|
816
|
+
|
|
817
|
+
**Remember**: Focus your tests on **your application logic and composed components**. fpkit components are thoroughly tested, so trust their functionality and test how you use them together.
|