- name
- essential-test-patterns
- description
- GROWI testing patterns with Vitest, React Testing Library, and vitest-mock-extended (type-safe mocking, avoid type assertions). Auto-invoked when writing or reviewing tests.
# GROWI Testing Patterns
GROWI uses **Vitest** for all testing (unit, integration, component). This skill covers universal testing patterns applicable across the monorepo.
## Test File Placement (Global Standard)
Place test files **in the same directory** as the source file:
```
src/components/Button/
├── Button.tsx
└── Button.spec.tsx # Component test
src/utils/
├── helper.ts
└── helper.spec.ts # Unit test
src/services/api/
├── pageService.ts
└── pageService.integ.ts # Integration test
```
## Test Types & Environments
| File Pattern | Type | Environment | Use Case |
|--------------|------|-------------|----------|
| `*.spec.{ts,js}` | Unit Test | Node.js | Pure functions, utilities, services |
| `*.integ.ts` | Integration Test | Node.js + DB | API routes, database operations |
| `*.spec.{tsx,jsx}` | Component Test | happy-dom | React components |
Vitest automatically selects the environment based on file extension and configuration.
## Vitest Configuration
### Global APIs (No Imports Needed)
All GROWI packages configure Vitest globals in `tsconfig.json`:
```json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
```
This enables auto-import of testing APIs:
```typescript
// No imports needed!
describe('MyComponent', () => {
it('should render', () => {
expect(true).toBe(true);
});
beforeEach(() => {
// Setup
});
afterEach(() => {
// Cleanup
});
});
```
**Available globals**: `describe`, `it`, `test`, `expect`, `beforeEach`, `afterEach`, `beforeAll`, `afterAll`, `vi`
## Type-Safe Mocking with vitest-mock-extended
### Basic Usage
`vitest-mock-extended` provides **fully type-safe mocks** with TypeScript autocomplete:
```typescript
import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended';
// Create type-safe mock
const mockRouter: DeepMockProxy<NextRouter> = mockDeep<NextRouter>();
// TypeScript autocomplete works!
mockRouter.asPath = '/test-path';
mockRouter.query = { id: '123' };
mockRouter.push.mockResolvedValue(true);
// Use in tests
expect(mockRouter.push).toHaveBeenCalledWith('/new-path');
```
### Complex Types with Optional Properties
```typescript
interface ComplexProps {
currentPageId?: string | null;
currentPathname?: string | null;
data?: Record<string, unknown>;
onSubmit?: (value: string) => void;
}
const mockProps: DeepMockProxy<ComplexProps> = mockDeep<ComplexProps>();
mockProps.currentPageId = 'page-123';
mockProps.data = { key: 'value' };
mockProps.onSubmit?.mockImplementation((value) => {
console.log(value);
});
```
### Why vitest-mock-extended?
- ✅ **Type safety**: Catches typos at compile time
- ✅ **Autocomplete**: IDE suggestions for all properties/methods
- ✅ **Deep mocking**: Automatically mocks nested objects
- ✅ **Vitest integration**: Works seamlessly with `vi.fn()`
### `mock<T>()` vs `mockDeep<T>()` — and `mock<T>({ ...overrides })`
```typescript
import { mock, mockDeep } from 'vitest-mock-extended';
// Auto-stub everything, override just the members the test touches.
// The override object is type-checked against Crowi (PartialDeep<Crowi>),
// and the return value IS a Crowi — no cast needed.
const crowi = mock<Crowi>({
searchService: { searchKeyword: vi.fn() },
});
// mockDeep<T>() recursively proxies nested access (foo.bar.baz.method()
// all return mocks lazily). Use when the code-under-test reaches deep into
// nested members you don't want to enumerate.
const configManager = mockDeep<IConfigManagerForApp>();
```
- `mock<T>(overrides?)` — shallow auto-stub + a typed partial override. The common
case for service/Crowi-style dependencies.
- `mockDeep<T>()` — recursive proxy for arbitrarily deep nested access.
### Avoid type assertions for mocks — `as any`, `as unknown as T`, `as T`
All three assertion forms **disable the type checker** for the mock, just in
different ways, so prefer `mock<T>()` over every one of them:
- `as unknown as Crowi` — the usual escape hatch; compiles even when the real type
drifts (a method is renamed or removed), leaving the mock silently wrong, and lets
you assign members that do not exist.
- `as Crowi` — same problem when it compiles; TypeScript only blocks it when the
object structurally clashes, which then pushes people to `as unknown as` or `as any`.
- `as any` — the broadest hole: erases the type entirely, so *nothing* about the
mock is checked and IDE autocomplete dies too.
`mock<T>({ ...overrides })` is both safer (the override is checked, so drift is a
compile error) and shorter (no need to hand-build the object).
```typescript
// ❌ WRONG — every one of these escapes type checking and survives API drift
const crowi = { searchService: { searchKeyword: vi.fn() } } as unknown as Crowi;
const crowi = { searchService: { searchKeyword: vi.fn() } } as Crowi;
const crowi = { searchService: { searchKeyword: vi.fn() } } as any;
// ✅ CORRECT — type-checked, auto-stubs the rest, returns a real Crowi
const crowi = mock<Crowi>({
searchService: { searchKeyword: vi.fn() },
});
```
Anti-pattern to watch for: hand-writing a `Pick<T, ...>` shim type plus a builder
function and *still* casting at the call site. That is more code than `mock<T>()`
and is not type-safe — the worst of both. Replace it with `mock<T>({ ... })`.
### Tolerance framework: when a type assertion is acceptable
The deciding question is the cost: **how many lines (and how much clarity) does
removing the assertion cost?**
| Tier | Situation | Rule |
|------|-----------|------|
| **1 — Avoid (cost ≤ 0)** | Mocking an interface/class. `mock<T>()` is 1 line, type-safe, *shorter* than the manual object. | **No assertion.** Use `mock<T>()`. No exceptions. |
| **2 — Localize (small cost)** | One field needs *real* behavior `mock<T>` can't give (e.g. a working `EventEmitter` whose listeners actually fire), and its type doesn't quite match. | Allowed, but **confine the cast to that one field**: `mock<Crowi>({ events: { page: realEmitter as unknown as PageEvent } })` — never cast the whole object. |
| **3 — Allow + comment (large cost)** | No type exists for the target (untyped JS module, untyped third-party lib). `mock<T>` can't be built. | A 1-line cast is fine — **writing a dozens-of-lines shim just to delete it is overkill**. Leave a `// WHY:` comment (e.g. `// PageEvent is a JS file typed as 'any' in Crowi`). |
| **4 — Forbidden** | A hand-built/`Pick<>` partial object that ends in a cast anyway. | Replace with `mock<T>()`. There is no reason to keep it. |
Rule of thumb: if deleting the cast costs **≤ 0 lines**, it's mandatory (Tier 1);
if it can be **localized to one field**, do that (Tier 2); if the **type itself is
missing** and avoidance would cost dozens of lines, a commented 1-line cast is fine
(Tier 3); a shim that still casts is never fine (Tier 4).
## React Testing Library Patterns
### Basic Component Test
```typescript
import { render } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('should render with text', () => {
const { getByText } = render(<Button>Click me</Button>);
expect(getByText('Click me')).toBeInTheDocument();
});
it('should call onClick when clicked', async () => {
const onClick = vi.fn();
const { getByRole } = render(<Button onClick={onClick}>Click</Button>);
const button = getByRole('button');
await userEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
});
```
### Testing with Jotai (Global Pattern)
When testing components that use Jotai atoms, wrap with `<Provider>`:
```typescript
import { render } from '@testing-library/react';
import { Provider } from 'jotai';
const renderWithJotai = (ui: React.ReactElement) => {
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider>{children}</Provider>
);
return render(ui, { wrapper: Wrapper });
};
describe('ComponentWithJotai', () => {
it('should render with atom state', () => {
const { getByText } = renderWithJotai(<MyComponent />);
expect(getByText('Hello')).toBeInTheDocument();
});
});
```
### Isolated Jotai Scope (For Testing)
To isolate atom state between tests:
```typescript
import { createScope } from 'jotai-scope';
describe('ComponentWithIsolatedState', () => {
it('test 1', () => {
const scope = createScope();
const { getByText } = renderWithJotai(<MyComponent />, scope);
// ...
});
it('test 2', () => {
const scope = createScope(); // Fresh scope
const { getByText } = renderWithJotai(<MyComponent />, scope);
// ...
});
});
```
## Async Testing Patterns (Global Standard)
### Using `act()` and `waitFor()`
When testing async state updates:
```typescript
import { waitFor, act } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
test('async hook', async () => {
const { result } = renderHook(() => useMyAsyncHook());
// Trigger async action
await act(async () => {
result.current.triggerAsyncAction();
});
// Wait for state update
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeDefined();
});
```
### Testing Async Functions
```typescript
it('should fetch data successfully', async () => {
const data = await fetchData();
expect(data).toEqual({ id: '123', name: 'Test' });
});
it('should handle errors', async () => {
await expect(fetchDataWithError()).rejects.toThrow('Error');
});
```
## Advanced Assertions
### Object Matching
```typescript
expect(mockFunction).toHaveBeenCalledWith(
expect.objectContaining({
pathname: '/expected-path',
data: expect.any(Object),
timestamp: expect.any(Number),
})
);
```
### Array Matching
```typescript
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: '123' }),
expect.objectContaining({ id: '456' }),
])
);
```
### Partial Matching
```typescript
expect(user).toMatchObject({
name: 'John',
email: 'john@example.com',
// Other properties are ignored
});
```
## Test Structure Best Practices
### AAA Pattern (Arrange-Act-Assert)
```typescript
describe('MyComponent', () => {
beforeEach(() => {
vi.clearAllMocks(); // Clear mocks before each test
});
describe('rendering', () => {
it('should render with default props', () => {
// Arrange: Setup test data
const props = { title: 'Test' };
// Act: Render component
const { getByText } = render(<MyComponent {...props} />);
Auf GitHub ansehen