@@ -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