Skip to main content

essential-test-patterns

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.

Source facts

Repository
growilabs/growi
Last source activity
June 4, 2026 at 07:26
Detected SKILL.md language
English
Stars
1,471
Forks
247

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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} />);
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub