name: testing-patterns-with-vitest description: GROWI testing patterns with Vitest, React Testing Library, and vitest-mock-extended. Auto-invoked for all GROWI development work.
GROWI uses Vitest for all testing (unit, integration, component). This skill covers universal testing patterns applicable across the monorepo.
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
| 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.
All GROWI packages configure Vitest globals in tsconfig.json:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
This enables auto-import of testing APIs:
// 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
vitest-mock-extended provides fully type-safe mocks with TypeScript autocomplete:
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');
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);
});
vi.fn()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);
});
});
When testing components that use Jotai atoms, wrap with <Provider>:
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();
});
});
To isolate atom state between tests:
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);
// ...
});
});
act() and waitFor()When testing async state updates:
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();
});
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');
});
expect(mockFunction).toHaveBeenCalledWith(
expect.objectContaining({
pathname: '/expected-path',
data: expect.any(Object),
timestamp: expect.any(Number),
})
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: '123' }),
expect.objectContaining({ id: '456' }),
])
);
expect(user).toMatchObject({
name: 'John',
email: 'john@example.com',
// Other properties are ignored
});
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} />);
// Assert: Verify output
expect(getByText('Test')).toBeInTheDocument();
});
});
describe('user interactions', () => {
it('should submit form on button click', async () => {
// Arrange
const onSubmit = vi.fn();
const { getByRole, getByLabelText } = render(
<MyForm onSubmit={onSubmit} />
);
// Act
await userEvent.type(getByLabelText('Name'), 'John');
await userEvent.click(getByRole('button', { name: 'Submit' }));
// Assert
expect(onSubmit).toHaveBeenCalledWith({ name: 'John' });
});
});
});
describe for Organizationdescribe('PageService', () => {
describe('createPage', () => {
it('should create a page successfully', async () => {
// ...
});
it('should throw error if path is invalid', async () => {
// ...
});
});
describe('updatePage', () => {
it('should update page content', async () => {
// ...
});
});
});
vi.mock('swr', () => ({
default: vi.fn(() => ({
data: mockData,
error: null,
isLoading: false,
mutate: vi.fn(),
})),
}));
// Mock entire module
vi.mock('~/services/PageService', () => ({
PageService: {
findById: vi.fn().mockResolvedValue({ id: '123', title: 'Test' }),
create: vi.fn().mockResolvedValue({ id: '456', title: 'New' }),
},
}));
// Use in test
import { PageService } from '~/services/PageService';
it('should call PageService.findById', async () => {
await myFunction();
expect(PageService.findById).toHaveBeenCalledWith('123');
});
import { myFunction } from '~/utils/myUtils';
vi.mock('~/utils/myUtils', () => ({
myFunction: vi.fn().mockReturnValue('mocked'),
otherFunction: vi.fn(), // Mock other exports
}));
Integration tests (*.integ.ts) can access in-memory databases:
describe('PageService Integration', () => {
beforeEach(async () => {
// Setup: Seed test data
await Page.create({ path: '/test', body: 'content' });
});
afterEach(async () => {
// Cleanup: Clear database
await Page.deleteMany({});
});
it('should create a page', async () => {
const page = await PageService.create({
path: '/new-page',
body: 'content',
});
expect(page._id).toBeDefined();
expect(page.path).toBe('/new-page');
});
});
Before committing tests, ensure:
beforeEach(() => vi.clearAllMocks())async/await and waitFor() for async operationsvitest-mock-extended for type-safe mocks# Run all tests for a package
turbo run test --filter @growi/app
# Run specific test file
cd {package_dir} && pnpm vitest run src/components/Button/Button.spec.tsx
vitest-mock-extended for TypeScript supportact() and waitFor() for async state updates<Provider> for atom statedescribe and AAA patternThese patterns apply to all GROWI packages with React/TypeScript code.