Skip to content

Commit 0e8677b

Browse files
committed
updated testing guidelines
1 parent 6d2b884 commit 0e8677b

File tree

1 file changed

+320
-11
lines changed

1 file changed

+320
-11
lines changed

.cursor/rules/TESTING_GUIDELINES.md

Lines changed: 320 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,305 @@ describe('UserService', () => {
350350
})
351351
```
352352

353+
### Error Scenario Testing
354+
355+
**CRITICAL:** All service and component tests MUST include error scenario coverage in addition to happy path tests.
356+
357+
#### Required Error Scenarios
358+
359+
For every service method, test:
360+
361+
1. **API/Network Errors** - Server failures, timeouts, network disconnects
362+
2. **Validation Errors** - Invalid input data (400 responses)
363+
3. **Authentication Errors** - Unauthorized access (401 responses)
364+
4. **Not Found Errors** - Missing resources (404 responses)
365+
5. **Conflict Errors** - Duplicate resources (409 responses)
366+
6. **Server Errors** - Internal failures (500 responses)
367+
368+
#### Service Error Testing Pattern
369+
370+
```typescript
371+
// ✅ Good - testing error scenarios
372+
import { Injector } from '@furystack/inject'
373+
import { usingAsync } from '@furystack/utils'
374+
import { RequestError } from '@furystack/rest-service'
375+
import { describe, expect, it, vi } from 'vitest'
376+
377+
describe('DashboardService', () => {
378+
describe('getDashboard', () => {
379+
it('should fetch a dashboard by id', async () => {
380+
// Happy path test
381+
const mockCall = vi.fn().mockResolvedValue({ result: mockDashboard })
382+
// ... test implementation
383+
})
384+
385+
it('should throw RequestError when API returns 404', async () => {
386+
const mockCall = vi.fn().mockRejectedValue(
387+
new RequestError('Dashboard not found', 404)
388+
)
389+
const injector = createTestInjector(mockCall)
390+
391+
await usingAsync(injector, async (i) => {
392+
const service = i.getInstance(DashboardService)
393+
394+
await expect(service.getDashboard('invalid-id')).rejects.toThrow('Dashboard not found')
395+
})
396+
})
397+
398+
it('should handle network errors', async () => {
399+
const mockCall = vi.fn().mockRejectedValue(new Error('Network error'))
400+
const injector = createTestInjector(mockCall)
401+
402+
await usingAsync(injector, async (i) => {
403+
const service = i.getInstance(DashboardService)
404+
405+
await expect(service.getDashboard('dashboard-1')).rejects.toThrow('Network error')
406+
})
407+
})
408+
409+
it('should handle server errors', async () => {
410+
const mockCall = vi.fn().mockRejectedValue(
411+
new RequestError('Internal server error', 500)
412+
)
413+
const injector = createTestInjector(mockCall)
414+
415+
await usingAsync(injector, async (i) => {
416+
const service = i.getInstance(DashboardService)
417+
418+
await expect(service.getDashboard('dashboard-1')).rejects.toThrow('Internal server error')
419+
})
420+
})
421+
})
422+
423+
describe('createDashboard', () => {
424+
it('should throw validation error for invalid data', async () => {
425+
const mockCall = vi.fn().mockRejectedValue(
426+
new RequestError('Invalid dashboard data', 400)
427+
)
428+
const injector = createTestInjector(mockCall)
429+
430+
await usingAsync(injector, async (i) => {
431+
const service = i.getInstance(DashboardService)
432+
433+
await expect(
434+
service.createDashboard({ name: '', owner: '', description: '', widgets: [] })
435+
).rejects.toThrow('Invalid dashboard data')
436+
})
437+
})
438+
439+
it('should throw conflict error when dashboard already exists', async () => {
440+
const mockCall = vi.fn().mockRejectedValue(
441+
new RequestError('Dashboard already exists', 409)
442+
)
443+
const injector = createTestInjector(mockCall)
444+
445+
await usingAsync(injector, async (i) => {
446+
const service = i.getInstance(DashboardService)
447+
448+
await expect(
449+
service.createDashboard({ name: 'Existing', owner: 'user', description: '', widgets: [] })
450+
).rejects.toThrow('Dashboard already exists')
451+
})
452+
})
453+
})
454+
})
455+
456+
// ❌ Avoid - only testing happy paths
457+
describe('DashboardService', () => {
458+
it('should fetch a dashboard by id', async () => {
459+
// Only happy path, missing error scenarios
460+
})
461+
})
462+
```
463+
464+
#### Cache Error State Testing
465+
466+
When testing services with Cache, verify error states are properly handled:
467+
468+
```typescript
469+
// ✅ Good - testing cache error states
470+
describe('MovieService', () => {
471+
it('should handle cache load errors', async () => {
472+
const mockCall = vi.fn().mockRejectedValue(new Error('API unavailable'))
473+
const injector = createTestInjector(mockCall)
474+
475+
await usingAsync(injector, async (i) => {
476+
const service = i.getInstance(MovieService)
477+
478+
// First call should fail
479+
await expect(service.getMovie('tt1234567')).rejects.toThrow('API unavailable')
480+
481+
// Verify cache doesn't store failed result
482+
mockCall.mockResolvedValue({ result: createMockMovie() })
483+
const movie = await service.getMovie('tt1234567')
484+
expect(movie).toBeDefined()
485+
})
486+
})
487+
})
488+
```
489+
490+
#### Cache Invalidation Testing
491+
492+
Verify that cache invalidation actually causes fresh data to be fetched:
493+
494+
```typescript
495+
// ✅ Good - verifying cache invalidation
496+
describe('DashboardService', () => {
497+
it('should fetch fresh data after cache invalidation', async () => {
498+
const originalDashboard = createMockDashboard('dashboard-1', 'Original')
499+
const updatedDashboard = createMockDashboard('dashboard-1', 'Updated')
500+
501+
const mockCall = vi
502+
.fn()
503+
.mockResolvedValueOnce({ result: originalDashboard })
504+
.mockResolvedValueOnce({ result: updatedDashboard })
505+
.mockResolvedValueOnce({ result: updatedDashboard })
506+
507+
const injector = createTestInjector(mockCall)
508+
509+
await usingAsync(injector, async (i) => {
510+
const service = i.getInstance(DashboardService)
511+
512+
// Load initial data (API call #1)
513+
const initial = await service.getDashboard('dashboard-1')
514+
expect(initial.name).toBe('Original')
515+
516+
// Update dashboard (API call #2, invalidates cache)
517+
await service.updateDashboard('dashboard-1', {
518+
name: 'Updated',
519+
owner: 'user',
520+
description: '',
521+
widgets: []
522+
})
523+
524+
// Fetch again should get fresh data (API call #3 due to invalidation)
525+
const fresh = await service.getDashboard('dashboard-1')
526+
expect(fresh.name).toBe('Updated')
527+
expect(mockCall).toHaveBeenCalledTimes(3)
528+
})
529+
})
530+
})
531+
532+
// ❌ Avoid - not verifying cache invalidation
533+
it('should update a dashboard', async () => {
534+
await service.updateDashboard('dashboard-1', updates)
535+
// Missing: verify that subsequent getDashboard calls fetch fresh data
536+
})
537+
```
538+
539+
#### Observable Error State Testing
540+
541+
Test that Observables properly reflect error states:
542+
543+
```typescript
544+
// ✅ Good - testing Observable error states
545+
describe('DataService', () => {
546+
it('should set error state when load fails', async () => {
547+
const mockCall = vi.fn().mockRejectedValue(new Error('Load failed'))
548+
const injector = createTestInjector(mockCall)
549+
550+
await usingAsync(injector, async (i) => {
551+
const service = i.getInstance(DataService)
552+
const observable = service.getDataAsObservable('data-1')
553+
554+
// Track state changes
555+
const states: string[] = []
556+
observable.subscribe((state) => states.push(state.status))
557+
558+
// Trigger load
559+
try {
560+
await service.getData('data-1')
561+
} catch (error) {
562+
// Expected to fail
563+
}
564+
565+
// Verify error state transition
566+
expect(states).toContain('error')
567+
const currentState = observable.getValue()
568+
expect(currentState.status).toBe('error')
569+
if (currentState.status === 'error') {
570+
expect(currentState.error).toBe('Load failed')
571+
}
572+
})
573+
})
574+
})
575+
```
576+
577+
#### Component Error Handling
578+
579+
Test that components handle and display errors appropriately:
580+
581+
```typescript
582+
// ✅ Good - testing component error handling
583+
describe('DashboardEditor', () => {
584+
it('should display error message when save fails', async () => {
585+
const mockService = {
586+
updateDashboard: vi.fn().mockRejectedValue(new Error('Save failed'))
587+
}
588+
589+
const injector = new Injector()
590+
injector.setExplicitInstance(mockService, DashboardService)
591+
592+
const rootElement = document.getElementById('root') as HTMLDivElement
593+
initializeShadeRoot({
594+
injector,
595+
rootElement,
596+
jsxElement: <DashboardEditor dashboardId="dashboard-1" />
597+
})
598+
599+
// Trigger save
600+
const saveButton = rootElement.querySelector('button[type="submit"]') as HTMLButtonElement
601+
saveButton.click()
602+
603+
// Wait for error to be displayed
604+
await new Promise(resolve => setTimeout(resolve, 100))
605+
606+
// Verify error display
607+
const errorMessage = rootElement.textContent
608+
expect(errorMessage).toContain('Save failed')
609+
})
610+
611+
it('should handle loading state during async operations', async () => {
612+
const mockService = {
613+
getDashboard: vi.fn(() => new Promise(resolve => setTimeout(() => resolve(mockDashboard), 100)))
614+
}
615+
616+
const injector = new Injector()
617+
injector.setExplicitInstance(mockService, DashboardService)
618+
619+
const rootElement = document.getElementById('root') as HTMLDivElement
620+
initializeShadeRoot({
621+
injector,
622+
rootElement,
623+
jsxElement: <DashboardEditor dashboardId="dashboard-1" />
624+
})
625+
626+
// Verify loading state is shown
627+
expect(rootElement.textContent).toContain('Loading')
628+
629+
// Wait for load to complete
630+
await new Promise(resolve => setTimeout(resolve, 150))
631+
632+
// Verify content is displayed
633+
expect(rootElement.textContent).not.toContain('Loading')
634+
})
635+
})
636+
```
637+
638+
#### Error Testing Checklist
639+
640+
For each service method, ensure you have tests for:
641+
642+
- [ ] Happy path (successful operation)
643+
- [ ] Network/API errors
644+
- [ ] Validation errors (400)
645+
- [ ] Authentication errors (401) if applicable
646+
- [ ] Not found errors (404)
647+
- [ ] Conflict errors (409) if applicable
648+
- [ ] Server errors (500)
649+
- [ ] Cache invalidation (for update/delete operations)
650+
- [ ] Observable error states (for methods returning observables)
651+
353652
### Testing Observable State
354653

355654
```typescript
@@ -605,16 +904,18 @@ vitest
605904

606905
**Key Principles:**
607906

608-
1. **Minimal mocking** - Only mock what's necessary
609-
2. **Type-safe mocks** - Always type mock callbacks and data
610-
3. **Arrange-Act-Assert** - Follow clear test structure
611-
4. **Semantic locators** - Use accessible queries in E2E tests
612-
5. **Helper functions** - Encapsulate common workflows
613-
6. **Test behavior** - Not implementation details
614-
7. **Co-locate tests** - Keep tests near the code they test
615-
8. **Descriptive names** - Make test names clear and specific
616-
9. **Hoisted mocks** - Define mocks at the top of test files
617-
10. **Independent tests** - Each test should run in isolation
907+
1. **Error scenarios first** - Test both happy paths AND error cases (CRITICAL)
908+
2. **Minimal mocking** - Only mock what's necessary
909+
3. **Type-safe mocks** - Always type mock callbacks and data
910+
4. **Arrange-Act-Assert** - Follow clear test structure
911+
5. **Semantic locators** - Use accessible queries in E2E tests
912+
6. **Helper functions** - Encapsulate common workflows
913+
7. **Test behavior** - Not implementation details
914+
8. **Co-locate tests** - Keep tests near the code they test
915+
9. **Descriptive names** - Make test names clear and specific
916+
10. **Hoisted mocks** - Define mocks at the top of test files
917+
11. **Independent tests** - Each test should run in isolation
918+
12. **Verify side effects** - Test cache invalidation, WebSocket listeners, cleanup
618919

619920
**Testing Checklist:**
620921

@@ -624,7 +925,15 @@ vitest
624925
- [ ] Helper functions for common workflows
625926
- [ ] Tests are independent and parallelizable
626927
- [ ] Observable state changes are tested
627-
- [ ] Error scenarios are covered
928+
- [ ] **Error scenarios are covered (CRITICAL)**
929+
- [ ] API/Network errors tested
930+
- [ ] Validation errors tested (400)
931+
- [ ] Authentication errors tested (401) if applicable
932+
- [ ] Not found errors tested (404)
933+
- [ ] Conflict errors tested (409) if applicable
934+
- [ ] Server errors tested (500)
935+
- [ ] Cache invalidation verified
936+
- [ ] Observable error states tested
628937
- [ ] No brittle CSS selectors
629938

630939
**Tools:**

0 commit comments

Comments
 (0)