Compare commits

...

2 Commits

Author SHA1 Message Date
arvinxx c3c7826362 update 2025-10-14 21:08:39 +08:00
arvinxx 206105502a 📝 docs(test): add Zustand store action testing best practices guide
Added comprehensive testing guide for Zustand store actions based on generateAIChat refactoring experience.

## New Guide Content

**File**: `.cursor/rules/testing-guide/zustand-store-action-test.mdc`

### Key Topics Covered

1. **Test Layering Principles** 🎯
   - Each test layer should only spy on direct dependencies
   - Avoid cross-layer spying
   - Clear separation of concerns

2. **Mock Strategy** 🎭
   - Per-test mocking (recommended)
   - Avoid global mocks causing implicit coupling
   - Only spy common services in beforeEach

3. **File Organization** 📁
   - `fixtures.ts`: Test constants and mock data factories
   - `helpers.ts`: Reusable test utility functions
   - `[action].test.ts`: Actual test files

4. **Template Code** 📝
   - Basic action test template
   - Internal methods testing
   - Streaming/async flow testing
   - Toggle/loading state testing

5. **Common Issues & Solutions** ⚠️
   - React state update warnings
   - Cross-layer spy problems
   - Mock type mismatches
   - Global mock pollution

6. **Real Case Study** 🎓
   - generateAIChat refactoring experience
   - Before/after comparisons
   - Coverage improvement: 54.44% → 82.03%

### Benefits

 Clear test architecture guidelines
 Ready-to-use template code
 Common pitfalls prevention
 Real-world examples

Updated `rules-index.mdc` to include the new guide.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 20:53:52 +08:00
2 changed files with 411 additions and 0 deletions
+1
View File
@@ -39,5 +39,6 @@ All following rules are saved under `.cursor/rules/` directory:
## Testing
- `testing-guide/testing-guide.mdc` Comprehensive testing guide for Vitest
- `testing-guide/zustand-store-action-test.mdc` Zustand store action testing best practices
- `testing-guide/electron-ipc-test.mdc` Electron IPC interface testing strategy
- `testing-guide/db-model-test.mdc` Database Model testing guide
@@ -0,0 +1,410 @@
---
globs: src/store/**/__tests__/*.test.ts
alwaysApply: false
---
# 🏪 Zustand Store Action Testing Guide
Testing guide for Zustand store actions under `src/store`. This guide is based on lessons learned from the `generateAIChat` refactoring practice.
## Core Principles
### 1. Test Layering Principle 🎯
**Each layer should only test direct dependencies, never spy across layers**
```
❌ Bad Example - Cross-layer spying
describe('internal_coreProcessMessage', () => {
it('test', async () => {
// ❌ Skipping internal_fetchAIChatMessage, directly spying on lower-level service
const streamSpy = vi.spyOn(chatService, 'createAssistantMessageStream');
});
});
✅ Good Example - Spy on direct dependencies
describe('internal_coreProcessMessage', () => {
it('test', async () => {
// ✅ Only spy on directly called methods
const fetchSpy = vi.spyOn(result.current, 'internal_fetchAIChatMessage')
.mockResolvedValue({ isFunctionCall: false, content: 'response' });
});
});
```
### 2. Mocking Strategy 🎭
#### Per-Test Mocking (Recommended)
```typescript
// ✅ Spy on-demand in each test
describe('myAction', () => {
it('should do something', async () => {
// Only spy when needed in this specific test
const serviceSpy = vi.spyOn(someService, 'method').mockResolvedValue(result);
// Test logic...
serviceSpy.mockRestore(); // Optional: cleanup
});
});
```
#### Avoid Global Mocks
```typescript
// ❌ Avoid globally spying on everything in beforeEach
beforeEach(() => {
spyOnEverything(); // Creates implicit coupling between tests
});
// ✅ Only spy on base services that almost all tests need
beforeEach(() => {
spyOnMessageService(); // Most tests need this
// Other services should be spied on-demand within tests
});
```
## Action Test Templates 📝
### Basic Action Test
```typescript
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useStore } from '../store';
// Mock zustand
vi.mock('zustand/traditional');
// Test constants
const TEST_IDS = {
DATA_ID: 'test-data-id',
} as const;
// Mock data factory
const createMockData = (overrides = {}) => ({
id: TEST_IDS.DATA_ID,
status: 'initial',
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
// Setup common mocks that most tests need
act(() => {
useStore.setState({
refreshData: vi.fn(),
internalMethod: vi.fn(),
});
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('myAction', () => {
describe('validation', () => {
it('should return early when conditions not met', async () => {
act(() => {
useStore.setState({ requiredData: undefined });
});
const { result } = renderHook(() => useStore());
await act(async () => {
await result.current.myAction();
});
expect(result.current.internalMethod).not.toHaveBeenCalled();
});
});
describe('main flow', () => {
it('should process data correctly', async () => {
const { result } = renderHook(() => useStore());
const mockData = createMockData();
await act(async () => {
await result.current.myAction(mockData);
});
expect(result.current.internalMethod).toHaveBeenCalledWith(
expect.objectContaining({
id: TEST_IDS.DATA_ID,
status: 'processed',
}),
);
});
});
describe('error handling', () => {
it('should handle errors gracefully', async () => {
const { result } = renderHook(() => useStore());
vi.spyOn(result.current, 'internalMethod').mockRejectedValue(
new Error('Test error'),
);
await act(async () => {
await result.current.myAction();
});
expect(result.current.errorState).toBeDefined();
});
});
});
```
### Testing Internal Methods
```typescript
// Save the real implementation for testing
const realInternalMethod = useStore.getState().internal_method;
describe('internal_method', () => {
it('should call correct dependencies', async () => {
// Restore the real implementation
act(() => {
useStore.setState({ internal_method: realInternalMethod });
});
const { result } = renderHook(() => useStore());
// ✅ Spy on direct dependencies
const dependencySpy = vi
.spyOn(result.current, 'internal_dependency')
.mockResolvedValue(expectedResult);
await act(async () => {
await result.current.internal_method(input);
});
expect(dependencySpy).toHaveBeenCalledWith(
expect.objectContaining({ /* expected params */ }),
);
});
});
```
### Testing Streaming/Async Flows
```typescript
describe('streamingAction', () => {
it('should handle streaming chunks', async () => {
const { result } = renderHook(() => useStore());
const dispatchSpy = vi.spyOn(result.current, 'internal_dispatch');
// Mock streaming service
const streamSpy = vi
.spyOn(streamService, 'stream')
.mockImplementation(async ({ onChunk, onFinish }) => {
await onChunk?.({ type: 'data', content: 'chunk1' });
await onChunk?.({ type: 'data', content: 'chunk2' });
await onFinish?.('complete');
});
await act(async () => {
await result.current.streamingAction();
});
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'update',
value: expect.objectContaining({ content: 'chunk1' }),
}),
);
streamSpy.mockRestore();
});
});
```
### Testing Toggle/Loading States
```typescript
describe('internal_toggleLoading', () => {
it('should enable loading state with abort controller', () => {
const { result } = renderHook(() => useStore());
act(() => {
result.current.internal_toggleLoading(true, TEST_IDS.ITEM_ID, 'action');
});
const state = useStore.getState();
expect(state.loadingIds).toEqual([TEST_IDS.ITEM_ID]);
expect(state.abortController).toBeInstanceOf(AbortController);
});
it('should disable loading state and clear abort controller', () => {
const { result } = renderHook(() => useStore());
act(() => {
result.current.internal_toggleLoading(true, TEST_IDS.ITEM_ID, 'start');
result.current.internal_toggleLoading(false, undefined, 'stop');
});
const state = useStore.getState();
expect(state.loadingIds).toEqual([]);
expect(state.abortController).toBeUndefined();
});
});
```
## Common Issues and Solutions ⚠️
### Issue 1: React State Update Warning
```typescript
// ❌ Wrong: setState without wrapping
useStore.setState({ data: newData });
// ✅ Correct: Wrap all setState with act()
act(() => {
useStore.setState({ data: newData });
});
```
### Issue 2: Cross-Layer Spying
```typescript
// ❌ Wrong: Spy on lower-level services across layers
describe('highLevelAction', () => {
const lowLevelServiceSpy = vi.spyOn(lowLevelService, 'method');
});
// ✅ Correct: Spy on direct dependencies
describe('highLevelAction', () => {
const directDependencySpy = vi.spyOn(result.current, 'directMethod');
});
```
### Issue 3: Mock Type Mismatch
```typescript
// ❌ Wrong: Return type doesn't match
vi.spyOn(service, 'method').mockResolvedValue('string');
// But method returns Response
// ✅ Correct: Return correct type
vi.spyOn(service, 'method').mockResolvedValue(new Response('string'));
```
### Issue 4: Global Mock Pollution
```typescript
// ❌ Wrong: Spy on all services in beforeEach
beforeEach(() => {
spyOnServiceA();
spyOnServiceB();
spyOnServiceC(); // Creates coupling between tests
});
// ✅ Correct: Spy on-demand
beforeEach(() => {
spyOnCommonService(); // Only spy on common services
});
describe('specific test', () => {
it('test', () => {
const specificSpy = vi.spyOn(specificService, 'method'); // Spy on-demand
});
});
```
## Test Coverage Goals 📊
### Coverage Requirements
- **Minimum target**: 70%
- **Recommended target**: 85%+
- **Excellent target**: 90%+
### Check Coverage
```bash
# Run coverage for a single test file
bunx vitest run --coverage 'src/store/[domain]/__tests__/[action].test.ts'
# View coverage for a specific file
bunx vitest run --coverage --silent='passed-only' 'src/store/[domain]/__tests__/[action].test.ts' | grep "[action].ts"
```
### Priority Test Scenarios
1. ✅ **Main Flow**: Normal business flow
2. ✅ **Edge Cases**: Empty data, undefined values, boundary values
3. ✅ **Error Handling**: Exception scenarios, failure retries
4. ✅ **State Management**: Loading, Toggle, Abort
5. ⚠️ **Corner Cases**: Optional, but don't write meaningless tests just for coverage
## Real-World Case: generateAIChat Refactoring 🎓
### Problems Before Refactoring
```typescript
// ❌ Problem 1: Cross-layer spying
describe('internal_coreProcessMessage', () => {
const streamSpy = vi.spyOn(chatService, 'createAssistantMessageStream');
// Skipped the internal_fetchAIChatMessage layer
});
// ❌ Problem 2: Mocking wrong objects
describe('internal_fetchAIChatMessage', () => {
vi.stubGlobal('fetch', ...); // But doesn't actually call fetch
});
// ❌ Problem 3: Global spy pollution
beforeEach(() => {
spyOnChatService(); // All tests now have this spy
});
```
### Solutions After Refactoring
```typescript
// ✅ Solution 1: Spy on direct dependencies
describe('internal_coreProcessMessage', () => {
const fetchSpy = vi
.spyOn(result.current, 'internal_fetchAIChatMessage')
.mockResolvedValue({ isFunctionCall: false, content: 'response' });
});
// ✅ Solution 2: Mock correct service
describe('internal_fetchAIChatMessage', () => {
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
await onMessageHandle?.({ type: 'text', text: 'response' });
await onFinish?.('response', {});
});
});
// ✅ Solution 3: Spy on-demand
beforeEach(() => {
spyOnMessageService(); // Only spy on common services
// Spy on chatService on-demand in tests
});
```
### Refactoring Results
- 📈 Coverage improvement: 54.44% → 82.03% (+27.59%)
- ✅ Test pass rate: 52/52 (100%)
- 🎯 Type errors: 6 → 0
- 📝 Clearer tests: Explicit test layering
## Best Practices Checklist ✅
Check before testing:
- [ ] Following test layering principle?
- [ ] Mock objects match actual calls?
- [ ] Avoiding global spy pollution?
- [ ] All setState wrapped with act()?
- [ ] Tests sufficiently atomic?
- [ ] Test descriptions clear?
- [ ] Coverage meets target?