---
title: 测试文档
order: 4
category: pro
---

# PisellDataSourceContainer 测试文档

## 测试框架

- **测试框架**：Jest
- **测试工具**：@testing-library/react, @testing-library/react-hooks
- **Mock 工具**：jest.mock
- **覆盖率要求**：80% 以上

## 测试策略

### 1. 单元测试

针对单个 Hook、Provider、工具函数进行测试。

### 2. 集成测试

测试多个模块协同工作的场景。

### 3. 端到端测试

测试完整的用户交互流程。

## 核心模块测试

### 1. RefManagerProvider 测试

#### 1.1 Ref 管理测试

```
import { renderHook, act } from '@testing-library/react-hooks';
import { RefManagerProvider, useRefManagerProvider } from '../provider/RefManager';

describe('RefManager - Ref 管理', () => {
  it('应该正确注册和获取 ref', () => {
    const wrapper = ({ children }) => (
      <RefManagerProvider>{children}</RefManagerProvider>
    );

    const { result } = renderHook(() => useRefManagerProvider(), { wrapper });

    const mockRef = { current: 'test-ref' };

    act(() => {
      result.current.registerRef('testId', mockRef);
    });

    expect(result.current.getRef('testId')).toBe(mockRef);
  });

  it('应该正确注销 ref', () => {
    const wrapper = ({ children }) => (
      <RefManagerProvider>{children}</RefManagerProvider>
    );

    const { result } = renderHook(() => useRefManagerProvider(), { wrapper });

    const mockRef = { current: 'test-ref' };

    act(() => {
      result.current.registerRef('testId', mockRef);
      result.current.unregisterRef('testId');
    });

    expect(result.current.getRef('testId')).toBeUndefined();
  });

  it('空 id 应该返回 undefined 并打印警告', () => {
    const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
    const wrapper = ({ children }) => (
      <RefManagerProvider>{children}</RefManagerProvider>
    );

    const { result } = renderHook(() => useRefManagerProvider(), { wrapper });

    expect(result.current.getRef('')).toBeUndefined();
    expect(consoleSpy).toHaveBeenCalledWith('[RefManager] getRef: id 不能为空');

    consoleSpy.mockRestore();
  });
});
```

#### 1.2 SharedState 测试

```
describe('RefManager - SharedState 管理', () => {
  it('应该正确设置和获取共享状态', () => {
    const wrapper = ({ children }) => (
      <RefManagerProvider>{children}</RefManagerProvider>
    );

    const { result } = renderHook(() => useRefManagerProvider(), { wrapper });

    act(() => {
      result.current.setSharedState('testKey', 'testValue');
    });

    expect(result.current.getSharedState('testKey')).toBe('testValue');
  });

  it('应该正确通知订阅者', () => {
    const wrapper = ({ children }) => (
      <RefManagerProvider>{children}</RefManagerProvider>
    );

    const { result } = renderHook(() => useRefManagerProvider(), { wrapper });

    const callback = jest.fn();

    act(() => {
      result.current.subscribeSharedState('testKey', callback);
    });

    act(() => {
      result.current.setSharedState('testKey', 'newValue');
    });

    expect(callback).toHaveBeenCalledWith('newValue');
    expect(callback).toHaveBeenCalledTimes(1);
  });

  it('订阅时如果状态已存在应该立即触发回调', () => {
    const wrapper = ({ children }) => (
      <RefManagerProvider>{children}</RefManagerProvider>
    );

    const { result } = renderHook(() => useRefManagerProvider(), { wrapper });

    const callback = jest.fn();

    act(() => {
      result.current.setSharedState('testKey', 'existingValue');
    });

    act(() => {
      result.current.subscribeSharedState('testKey', callback);
    });

    expect(callback).toHaveBeenCalledWith('existingValue');
  });

  it('取消订阅后不应该收到通知', () => {
    const wrapper = ({ children }) => (
      <RefManagerProvider>{children}</RefManagerProvider>
    );

    const { result } = renderHook(() => useRefManagerProvider(), { wrapper });

    const callback = jest.fn();

    let unsubscribe: () => void;

    act(() => {
      unsubscribe = result.current.subscribeSharedState('testKey', callback);
    });

    act(() => {
      unsubscribe();
    });

    act(() => {
      result.current.setSharedState('testKey', 'newValue');
    });

    // 订阅时触发 1 次，取消订阅后不应该再触发
    expect(callback).toHaveBeenCalledTimes(0);
  });

  it('多个订阅者应该都收到通知', () => {
    const wrapper = ({ children }) => (
      <RefManagerProvider>{children}</RefManagerProvider>
    );

    const { result } = renderHook(() => useRefManagerProvider(), { wrapper });

    const callback1 = jest.fn();
    const callback2 = jest.fn();
    const callback3 = jest.fn();

    act(() => {
      result.current.subscribeSharedState('testKey', callback1);
      result.current.subscribeSharedState('testKey', callback2);
      result.current.subscribeSharedState('testKey', callback3);
    });

    act(() => {
      result.current.setSharedState('testKey', 'broadcast');
    });

    expect(callback1).toHaveBeenCalledWith('broadcast');
    expect(callback2).toHaveBeenCalledWith('broadcast');
    expect(callback3).toHaveBeenCalledWith('broadcast');
  });
});
```

### 2. useHandleAction 测试

#### 2.1 onSearch 方法测试

```
import { renderHook, act } from '@testing-library/react-hooks';
import useHandleAction from '../hooks/useHandleAction';

// Mock useDataSource
jest.mock('../../dataSourceComponents/hooks/useDataSource', () => ({
  __esModule: true,
  default: () => ({
    list: {
      run: jest.fn(),
      params: [{ keyword: 'old' }],
      data: { list: [], count: 0 },
      loading: false,
    },
    get: { run: jest.fn() },
    create: { run: jest.fn() },
    update: { run: jest.fn() },
    destroy: { run: jest.fn() },
    getCurrentRecord: jest.fn(),
  }),
}));

describe('useHandleAction - onSearch', () => {
  it('应该正确合并参数并调用 run', () => {
    const mockRun = jest.fn();
    jest
      .spyOn(require('../../dataSourceComponents/hooks/useDataSource'), 'default')
      .mockReturnValue({
        list: {
          run: mockRun,
          params: [{ keyword: 'old' }],
        },
        get: {},
        create: {},
        update: {},
        destroy: {},
        getCurrentRecord: jest.fn(),
      });

    const { result } = renderHook(() =>
      useHandleAction({ list: { status: 'active' } }, true)
    );

    act(() => {
      result.current.list.onSearch({ keyword: 'new' });
    });

    expect(mockRun).toHaveBeenCalledWith(
      expect.objectContaining({
        keyword: 'new',
        status: 'active',
        pageNumber: 1,
        pageSize: 10,
      })
    );
  });

  it('启用分页时应该重置到第一页', () => {
    const mockRun = jest.fn();
    jest
      .spyOn(require('../../dataSourceComponents/hooks/useDataSource'), 'default')
      .mockReturnValue({
        list: {
          run: mockRun,
          params: [{ pageNumber: 3 }],
        },
        get: {},
        create: {},
        update: {},
        destroy: {},
        getCurrentRecord: jest.fn(),
      });

    const { result } = renderHook(() =>
      useHandleAction({}, { pageNumber: 1, pageSize: 20 })
    );

    act(() => {
      result.current.list.onSearch({ keyword: 'test' });
    });

    expect(mockRun).toHaveBeenCalledWith(
      expect.objectContaining({
        pageNumber: 1,
        pageSize: 20,
      })
    );
  });

  it('未启用分页时不应该添加分页参数', () => {
    const mockRun = jest.fn();
    jest
      .spyOn(require('../../dataSourceComponents/hooks/useDataSource'), 'default')
      .mockReturnValue({
        list: {
          run: mockRun,
          params: [],
        },
        get: {},
        create: {},
        update: {},
        destroy: {},
        getCurrentRecord: jest.fn(),
      });

    const { result } = renderHook(() => useHandleAction({}, false));

    act(() => {
      result.current.list.onSearch({ keyword: 'test' });
    });

    expect(mockRun).toHaveBeenCalledWith(
      expect.not.objectContaining({
        pageNumber: expect.anything(),
        pageSize: expect.anything(),
      })
    );
  });
});
```

#### 2.2 onReset 方法测试

```
describe('useHandleAction - onReset', () => {
  it('应该重置到初始参数', () => {
    const mockRun = jest.fn();
    jest
      .spyOn(require('../../dataSourceComponents/hooks/useDataSource'), 'default')
      .mockReturnValue({
        list: {
          run: mockRun,
          params: [{ keyword: 'current', status: 'active' }],
        },
        get: {},
        create: {},
        update: {},
        destroy: {},
        getCurrentRecord: jest.fn(),
      });

    const { result } = renderHook(() =>
      useHandleAction({ list: { category: 'default' } }, true)
    );

    act(() => {
      result.current.list.onReset();
    });

    expect(mockRun).toHaveBeenCalledWith({
      category: 'default',
      pageNumber: 1,
      pageSize: 10,
    });
  });

  it('应该重置分页状态', () => {
    const mockRun = jest.fn();
    jest
      .spyOn(require('../../dataSourceComponents/hooks/useDataSource'), 'default')
      .mockReturnValue({
        list: {
          run: mockRun,
          params: [],
        },
        get: {},
        create: {},
        update: {},
        destroy: {},
        getCurrentRecord: jest.fn(),
      });

    const { result } = renderHook(() =>
      useHandleAction({}, { pageNumber: 5, pageSize: 50 })
    );

    act(() => {
      result.current.list.onReset();
    });

    expect(result.current.list.pagination).toEqual({
      pageNumber: 5,
      pageSize: 50,
    });

    expect(mockRun).toHaveBeenCalledWith({
      pageNumber: 5,
      pageSize: 50,
    });
  });
});
```

#### 2.3 onPageChange 方法测试

```
describe('useHandleAction - onPageChange', () => {
  it('应该更新分页并保留查询参数', () => {
    const mockRun = jest.fn();
    jest
      .spyOn(require('../../dataSourceComponents/hooks/useDataSource'), 'default')
      .mockReturnValue({
        list: {
          run: mockRun,
          params: [{ keyword: 'test', status: 'active' }],
        },
        get: {},
        create: {},
        update: {},
        destroy: {},
        getCurrentRecord: jest.fn(),
      });

    const { result } = renderHook(() => useHandleAction({}, true));

    act(() => {
      result.current.list.onPageChange(2, 20);
    });

    expect(mockRun).toHaveBeenCalledWith({
      keyword: 'test',
      status: 'active',
      pageNumber: 2,
      pageSize: 20,
    });

    expect(result.current.list.pagination).toEqual({
      pageNumber: 2,
      pageSize: 20,
    });
  });

  it('未启用分页时应该打印警告', () => {
    const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();

    const { result } = renderHook(() => useHandleAction({}, false));

    act(() => {
      result.current.list.onPageChange(2, 20);
    });

    expect(consoleSpy).toHaveBeenCalledWith('分页功能未启用');

    consoleSpy.mockRestore();
  });

  it('非法参数应该被忽略', () => {
    const mockRun = jest.fn();
    jest
      .spyOn(require('../../dataSourceComponents/hooks/useDataSource'), 'default')
      .mockReturnValue({
        list: {
          run: mockRun,
          params: [],
        },
        get: {},
        create: {},
        update: {},
        destroy: {},
        getCurrentRecord: jest.fn(),
      });

    const { result } = renderHook(() => useHandleAction({}, true));

    act(() => {
      result.current.list.onPageChange(0, 20);
    });

    expect(mockRun).not.toHaveBeenCalled();

    act(() => {
      result.current.list.onPageChange(1, -5);
    });

    expect(mockRun).not.toHaveBeenCalled();
  });
});
```

### 3. withContainer HOC 测试

```
import { render, screen } from '@testing-library/react';
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import withContainer from '../utils/withContainer';
import { PisellDataSourceContainer } from '../PisellDataSourceContainer';

describe('withContainer HOC', () => {
  it('应该正确注入 context', () => {
    const TestComponent = ({ serverActions }) => {
      return <div>{serverActions ? 'Has Context' : 'No Context'}</div>;
    };

    const WrappedComponent = withContainer(TestComponent, 'testComponent');

    render(
      <PisellDataSourceContainer dataSource="test">
        <WrappedComponent />
      </PisellDataSourceContainer>
    );

    expect(screen.getByText('Has Context')).toBeInTheDocument();
  });

  it('应该正确注入子组件属性', () => {
    const TestComponent = ({ title }) => {
      return <div>{title}</div>;
    };

    const WrappedComponent = withContainer(TestComponent, 'testComponent');

    render(
      <PisellDataSourceContainer
        dataSource="test"
        childComponentProps={{
          testComponent: {
            title: 'Test Title',
          },
        }}
      >
        <WrappedComponent />
      </PisellDataSourceContainer>
    );

    expect(screen.getByText('Test Title')).toBeInTheDocument();
  });

  it('应该支持 refId 自动注册', () => {
    interface TestRef {
      getValue: () => string;
    }

    const TestComponent = forwardRef<TestRef, any>((props, ref) => {
      useImperativeHandle(ref, () => ({
        getValue: () => 'test-value',
      }));

      return <div>Test Component</div>;
    });

    const WrappedComponent = withContainer(TestComponent, 'testComponent');

    const ParentComponent = () => {
      const containerRef = useRef<any>();

      const handleClick = () => {
        const ref = containerRef.current?.refManager?.getRef<TestRef>('testRef');
        console.log(ref?.getValue());
      };

      return (
        <PisellDataSourceContainer ref={containerRef} dataSource="test">
          <WrappedComponent refId="testRef" />
          <button onClick={handleClick}>Get Value</button>
        </PisellDataSourceContainer>
      );
    };

    render(<ParentComponent />);

    // 测试 ref 注册逻辑
    expect(screen.getByText('Test Component')).toBeInTheDocument();
  });

  it('应该合并自定义 props', () => {
    const TestComponent = ({ customProp, serverActions }) => {
      return (
        <div>
          {customProp} - {serverActions ? 'Context' : 'No Context'}
        </div>
      );
    };

    const WrappedComponent = withContainer(TestComponent, 'testComponent');

    render(
      <PisellDataSourceContainer dataSource="test">
        <WrappedComponent customProp="Custom Value" />
      </PisellDataSourceContainer>
    );

    expect(screen.getByText('Custom Value - Context')).toBeInTheDocument();
  });
});
```

### 4. useContainerContext 测试

```
import { renderHook } from '@testing-library/react-hooks';
import { PisellDataSourceContainer } from '../PisellDataSourceContainer';
import useContainerContext from '../hooks/useContainerContext';

describe('useContainerContext', () => {
  it('应该返回基础 context', () => {
    const wrapper = ({ children }) => (
      <PisellDataSourceContainer dataSource="test">
        {children}
      </PisellDataSourceContainer>
    );

    const { result } = renderHook(() => useContainerContext(), { wrapper });

    expect(result.current.serverActions).toBeDefined();
    expect(result.current.refManager).toBeDefined();
    expect(result.current.form).toBeDefined();
    expect(result.current.onValuesChange).toBeDefined();
  });

  it('应该正确注入子组件属性', () => {
    const wrapper = ({ children }) => (
      <PisellDataSourceContainer
        dataSource="test"
        childComponentProps={{
          tableComponent: {
            columns: [{ title: 'Name', dataIndex: 'name' }],
          },
        }}
      >
        {children}
      </PisellDataSourceContainer>
    );

    const { result } = renderHook(() => useContainerContext('tableComponent'), {
      wrapper,
    });

    expect(result.current.columns).toBeDefined();
    expect(result.current.columns).toHaveLength(1);
    expect(result.current.columns[0].title).toBe('Name');
  });

  it('onValuesChange 应该触发 onSearch', () => {
    const wrapper = ({ children }) => (
      <PisellDataSourceContainer dataSource="test" autoRun={{ list: false }}>
        {children}
      </PisellDataSourceContainer>
    );

    const { result } = renderHook(() => useContainerContext(), { wrapper });

    const mockOnSearch = jest.spyOn(
      result.current.serverActions.list,
      'onSearch'
    );

    act(() => {
      result.current.onValuesChange({ keyword: 'test' }, {});
    });

    expect(mockOnSearch).toHaveBeenCalledWith({ keyword: 'test' });
  });
});
```

## 集成测试

### 完整流程测试

```
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { PisellDataSourceContainer } from '../PisellDataSourceContainer';
import useContainerContext from '../hooks/useContainerContext';

describe('PisellDataSourceContainer - 集成测试', () => {
  it('完整的查询流程', async () => {
    const ListComponent = () => {
      const { serverActions } = useContainerContext();
      const { data, loading } = serverActions.list;

      return (
        <div>
          <button onClick={() => serverActions.list.onSearch({ keyword: 'test' })}>
            搜索
          </button>
          {loading && <div>Loading...</div>}
          {data?.list?.map((item) => (
            <div key={item.id}>{item.name}</div>
          ))}
        </div>
      );
    };

    render(
      <PisellDataSourceContainer dataSource="users" autoRun={{ list: false }}>
        <ListComponent />
      </PisellDataSourceContainer>
    );

    const button = screen.getByText('搜索');
    fireEvent.click(button);

    await waitFor(() => {
      expect(screen.getByText('Loading...')).toBeInTheDocument();
    });
  });

  it('分页切换流程', async () => {
    const PaginationComponent = () => {
      const { serverActions } = useContainerContext();

      return (
        <div>
          <button
            onClick={() => serverActions.list.onPageChange(2, 20)}
          >
            下一页
          </button>
          <div>
            当前页: {serverActions.list.pagination?.pageNumber}
          </div>
        </div>
      );
    };

    render(
      <PisellDataSourceContainer
        dataSource="users"
        autoRun={{ list: false }}
        pagination={{ pageNumber: 1, pageSize: 20 }}
      >
        <PaginationComponent />
      </PisellDataSourceContainer>
    );

    const button = screen.getByText('下一页');
    fireEvent.click(button);

    await waitFor(() => {
      expect(screen.getByText('当前页: 2')).toBeInTheDocument();
    });
  });

  it('跨组件状态共享', () => {
    const ComponentA = () => {
      const { refManager } = useContainerContext();

      return (
        <button
          onClick={() => refManager.setSharedState('selectedIds', ['1', '2'])}
        >
          设置选中
        </button>
      );
    };

    const ComponentB = () => {
      const { refManager } = useContainerContext();
      const [ids, setIds] = React.useState<string[]>([]);

      React.useEffect(() => {
        return refManager.subscribeSharedState('selectedIds', setIds);
      }, [refManager]);

      return <div>选中数量: {ids.length}</div>;
    };

    render(
      <PisellDataSourceContainer dataSource="users">
        <ComponentA />
        <ComponentB />
      </PisellDataSourceContainer>
    );

    const button = screen.getByText('设置选中');
    fireEvent.click(button);

    expect(screen.getByText('选中数量: 2')).toBeInTheDocument();
  });
});
```

## 运行测试

### 运行所有测试

```bash
npm test
```

### 运行特定文件的测试

```bash
npm test PisellDataSourceContainer.test.tsx
```

### 生成覆盖率报告

```bash
npm test -- --coverage
```

### Watch 模式

```bash
npm test -- --watch
```

## 测试覆盖率要求

| 类型       | 要求  |
| ---------- | ----- |
| 语句覆盖率 | ≥ 80% |
| 分支覆盖率 | ≥ 80% |
| 函数覆盖率 | ≥ 80% |
| 行覆盖率   | ≥ 80% |

## 测试清单

开发新功能时，请确保包含以下测试：

### RefManager 测试

- [ ] 注册 ref
- [ ] 获取 ref
- [ ] 注销 ref
- [ ] 设置共享状态
- [ ] 获取共享状态
- [ ] 订阅共享状态
- [ ] 取消订阅
- [ ] 多个订阅者同时接收通知
- [ ] 边界情况（空 id、空 key 等）

### useHandleAction 测试

- [ ] onSearch 正确合并参数
- [ ] onSearch 重置分页
- [ ] onReset 恢复初始状态
- [ ] onPageChange 保留查询参数
- [ ] onPageChange 更新分页状态
- [ ] 分页启用/禁用逻辑
- [ ] 参数边界检查

### withContainer 测试

- [ ] 正确注入 context
- [ ] 正确注入子组件属性
- [ ] 支持 refId 自动注册
- [ ] 支持 ref 转发
- [ ] 合并自定义 props
- [ ] 组件卸载时注销 ref

### useContainerContext 测试

- [ ] 返回基础 context
- [ ] 按需注入子组件属性
- [ ] onValuesChange 触发 onSearch
- [ ] 性能优化（useMemo）

### 集成测试

- [ ] 完整查询流程
- [ ] 分页切换流程
- [ ] 重置流程
- [ ] 跨组件状态共享
- [ ] 表单联动查询
- [ ] 错误处理

## 测试最佳实践

### 1. 测试隔离

```
// Good: 每个测试独立
it('test 1', () => {
  const { result } = renderHook(() => useHandleAction({}, true));
  // ...
});

it('test 2', () => {
  const { result } = renderHook(() => useHandleAction({}, true));
  // ...
});

// Bad: 测试之间有依赖
let result;
it('test 1', () => {
  result = renderHook(() => useHandleAction({}, true)).result;
});
it('test 2', () => {
  // 依赖 test 1 的 result
});
```

### 2. Mock 使用

```
// Good: 清晰的 Mock
jest.mock('../../dataSourceComponents/hooks/useDataSource', () => ({
  __esModule: true,
  default: () => ({
    list: {
      run: jest.fn(),
      params: [],
      data: { list: [], count: 0 },
    },
  }),
}));

// Bad: 过度 Mock
jest.mock('entire-module');
```

### 3. 断言清晰

```
// Good: 明确的断言
expect(result.current.list.pagination).toEqual({
  pageNumber: 2,
  pageSize: 20,
});

// Bad: 模糊的断言
expect(result.current.list).toBeTruthy();
```

### 4. 异步处理

```
// Good: 使用 waitFor
await waitFor(() => {
  expect(screen.getByText('Loading...')).toBeInTheDocument();
});

// Bad: 直接断言异步结果
expect(screen.getByText('Loading...')).toBeInTheDocument(); // 可能失败
```

### 5. 清理副作用

```
// Good: 清理 Mock
afterEach(() => {
  jest.clearAllMocks();
});

// Good: 恢复 spy
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
// ... test
consoleSpy.mockRestore();
```

## 常见测试问题

### 1. Hook 无法在组件外使用

**问题**：直接调用 Hook 导致错误

**解决**：

```
// 错误
const result = useContainerContext();

// 正确
const { result } = renderHook(() => useContainerContext(), { wrapper });
```

### 2. Context 未提供

**问题**：测试时没有提供必要的 Provider

**解决**：

```
const wrapper = ({ children }) => (
  <PisellDataSourceContainer dataSource="test">
    {children}
  </PisellDataSourceContainer>
);

const { result } = renderHook(() => useContainerContext(), { wrapper });
```

### 3. 异步更新未等待

**问题**：断言在状态更新前执行

**解决**：

```
// 使用 waitFor
await waitFor(() => {
  expect(result.current.data).toBeDefined();
});

// 使用 act
act(() => {
  result.current.onSearch();
});
```

### 4. Mock 未清理

**问题**：Mock 影响其他测试

**解决**：

```
afterEach(() => {
  jest.clearAllMocks();
  jest.restoreAllMocks();
});
```

## 持续集成

### GitHub Actions 配置

```yaml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Install dependencies
        run: npm install
      - name: Run tests
        run: npm test -- --coverage
      - name: Upload coverage
        uses: codecov/codecov-action@v2
```

## 总结

良好的测试覆盖可以：

1. 确保代码质量
2. 防止回归问题
3. 提升重构信心
4. 作为文档使用

编写测试时记住：

- 测试行为，不测试实现
- 保持测试简单明了
- 及时更新测试
- 追求有意义的覆盖率
