第23章 単体テスト¶
本章では、フロントエンドの単体テスト実装について解説する。Vitest と Testing Library を使用したコンポーネントテスト、カスタムフックのテスト、MSW による API モックテストなど、品質を担保するためのテスト手法を構築していく。
23.1 テスト環境の構築¶
Vitest の設定¶
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
'src/api/generated/',
'src/api/model/',
'**/*.d.ts',
],
},
},
});
テストセットアップファイル¶
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll, vi } from 'vitest';
import { server } from './mocks/server';
// React Testing Library のクリーンアップ
afterEach(() => {
cleanup();
});
// MSW サーバーのセットアップ
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
// window.matchMedia のモック
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// ResizeObserver のモック
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
テストユーティリティ¶
// src/test/utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from '@/providers/AuthProvider';
import { MessageProvider } from '@/providers/MessageProvider';
import { AccountingPeriodProvider } from '@/providers/AccountingPeriodProvider';
// テスト用 QueryClient
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
// カスタムレンダラー
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
initialRoute?: string;
queryClient?: QueryClient;
}
export const renderWithProviders = (
ui: ReactElement,
options: CustomRenderOptions = {}
) => {
const {
initialRoute = '/',
queryClient = createTestQueryClient(),
...renderOptions
} = options;
window.history.pushState({}, 'Test page', initialRoute);
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<AccountingPeriodProvider>
<MessageProvider>
{children}
</MessageProvider>
</AccountingPeriodProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
queryClient,
};
};
// 再エクスポート
export * from '@testing-library/react';
export { renderWithProviders as render };
23.2 MSW による API モック¶
MSW サーバー設定¶
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
ハンドラー定義¶
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import type { Account, JournalEntry, Balance } from '@/api/model';
// テストデータ
export const mockAccounts: Account[] = [
{
id: '1',
accountCode: '111',
accountName: '現金',
bsPlType: 'B',
debitCreditType: '借',
elementType: '資産',
displayOrder: 1,
version: 1,
},
{
id: '2',
accountCode: '121',
accountName: '普通預金',
bsPlType: 'B',
debitCreditType: '借',
elementType: '資産',
displayOrder: 2,
version: 1,
},
{
id: '3',
accountCode: '211',
accountName: '買掛金',
bsPlType: 'B',
debitCreditType: '貸',
elementType: '負債',
displayOrder: 10,
version: 1,
},
{
id: '4',
accountCode: '411',
accountName: '売上高',
bsPlType: 'P',
debitCreditType: '貸',
elementType: '収益',
displayOrder: 100,
version: 1,
},
];
export const mockJournalEntries: JournalEntry[] = [
{
id: '1',
journalNumber: 1,
journalDate: '2024-04-01',
description: '売上計上',
status: 'APPROVED',
details: [
{
id: '1-1',
accountCode: '111',
accountName: '現金',
debitAmount: 10000,
creditAmount: 0,
},
{
id: '1-2',
accountCode: '411',
accountName: '売上高',
debitAmount: 0,
creditAmount: 10000,
},
],
createdAt: '2024-04-01T10:00:00Z',
createdBy: 'user1',
version: 1,
},
];
// ハンドラー
export const handlers = [
// 勘定科目一覧
http.get('/api/accounts', () => {
return HttpResponse.json(mockAccounts);
}),
// 勘定科目詳細
http.get('/api/accounts/:code', ({ params }) => {
const account = mockAccounts.find(a => a.accountCode === params.code);
if (!account) {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json(account);
}),
// 勘定科目登録
http.post('/api/accounts', async ({ request }) => {
const body = await request.json() as Partial<Account>;
const newAccount: Account = {
id: String(mockAccounts.length + 1),
accountCode: body.accountCode!,
accountName: body.accountName!,
bsPlType: body.bsPlType!,
debitCreditType: body.debitCreditType!,
elementType: body.elementType!,
displayOrder: mockAccounts.length + 1,
version: 1,
};
return HttpResponse.json(newAccount, { status: 201 });
}),
// 仕訳一覧
http.get('/api/journal-entries', ({ request }) => {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '0', 10);
const size = parseInt(url.searchParams.get('size') || '20', 10);
return HttpResponse.json({
items: mockJournalEntries.slice(page * size, (page + 1) * size),
totalCount: mockJournalEntries.length,
page,
size,
});
}),
// 仕訳登録
http.post('/api/journal-entries', async ({ request }) => {
const body = await request.json() as Partial<JournalEntry>;
const newEntry: JournalEntry = {
id: String(mockJournalEntries.length + 1),
journalNumber: mockJournalEntries.length + 1,
journalDate: body.journalDate!,
description: body.description!,
status: 'DRAFT',
details: body.details || [],
createdAt: new Date().toISOString(),
createdBy: 'test-user',
version: 1,
};
return HttpResponse.json(newEntry, { status: 201 });
}),
// 認証
http.post('/api/auth/login', async ({ request }) => {
const body = await request.json() as { username: string; password: string };
if (body.username === 'test' && body.password === 'password') {
return HttpResponse.json({
accessToken: 'mock-access-token',
user: {
id: '1',
username: 'test',
name: 'テストユーザー',
roles: ['USER'],
},
});
}
return new HttpResponse(null, { status: 401 });
}),
];
ハンドラーのオーバーライド¶
// src/test/mocks/handlers/override.ts
import { http, HttpResponse } from 'msw';
import { server } from '../server';
// エラーレスポンスをシミュレート
export const mockApiError = (
method: 'get' | 'post' | 'put' | 'delete',
path: string,
status: number,
message?: string
) => {
const handler = http[method](path, () => {
return new HttpResponse(
JSON.stringify({ message: message || 'Error' }),
{ status }
);
});
server.use(handler);
};
// 遅延レスポンスをシミュレート
export const mockApiDelay = (
method: 'get' | 'post' | 'put' | 'delete',
path: string,
delayMs: number
) => {
const handler = http[method](path, async () => {
await new Promise(resolve => setTimeout(resolve, delayMs));
return new HttpResponse(null, { status: 200 });
});
server.use(handler);
};
23.3 コンポーネントテスト¶
金額入力コンポーネントのテスト¶
// src/components/common/MoneyInput.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@/test/utils';
import userEvent from '@testing-library/user-event';
import { MoneyInput } from './MoneyInput';
describe('MoneyInput', () => {
it('初期値が正しく表示される', () => {
render(<MoneyInput value={1000} onChange={() => {}} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('1,000');
});
it('0の場合は空文字が表示される', () => {
render(<MoneyInput value={0} onChange={() => {}} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('');
});
it('数値入力時にonChangeが呼ばれる', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<MoneyInput value={0} onChange={handleChange} />);
const input = screen.getByRole('textbox');
await user.type(input, '1234');
expect(handleChange).toHaveBeenCalledWith(1234);
});
it('3桁区切りで表示される', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<MoneyInput value={0} onChange={handleChange} />);
const input = screen.getByRole('textbox');
await user.type(input, '1234567');
// フォーカスを外すとフォーマットされる
await user.tab();
expect(handleChange).toHaveBeenCalledWith(1234567);
});
it('負数が入力できる', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<MoneyInput value={0} onChange={handleChange} allowNegative />);
const input = screen.getByRole('textbox');
await user.type(input, '-1000');
expect(handleChange).toHaveBeenCalledWith(-1000);
});
it('負数が許可されていない場合、マイナスは無視される', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<MoneyInput value={0} onChange={handleChange} />);
const input = screen.getByRole('textbox');
await user.type(input, '-1000');
// マイナス記号は無視される
expect(handleChange).toHaveBeenCalledWith(1000);
});
it('文字列は入力できない', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<MoneyInput value={0} onChange={handleChange} />);
const input = screen.getByRole('textbox');
await user.type(input, 'abc');
// onChangeは呼ばれない
expect(handleChange).not.toHaveBeenCalled();
});
it('disabledの場合は入力できない', () => {
render(<MoneyInput value={1000} onChange={() => {}} disabled />);
const input = screen.getByRole('textbox');
expect(input).toBeDisabled();
});
it('最大値を超える値は制限される', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(
<MoneyInput value={0} onChange={handleChange} max={100000} />
);
const input = screen.getByRole('textbox');
await user.type(input, '999999');
// 最大値に制限される
expect(handleChange).toHaveBeenLastCalledWith(100000);
});
});
勘定科目選択コンポーネントのテスト¶
// src/components/common/AccountSelector.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@/test/utils';
import userEvent from '@testing-library/user-event';
import { AccountSelector } from './AccountSelector';
import { mockAccounts } from '@/test/mocks/handlers';
describe('AccountSelector', () => {
const defaultProps = {
value: '',
onChange: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('勘定科目一覧が読み込まれる', async () => {
render(<AccountSelector {...defaultProps} />);
// ローディング状態
expect(screen.getByText('読み込み中...')).toBeInTheDocument();
// データ読み込み完了
await waitFor(() => {
expect(screen.queryByText('読み込み中...')).not.toBeInTheDocument();
});
// セレクトボックスに選択肢が表示される
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
});
it('勘定科目を選択するとonChangeが呼ばれる', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<AccountSelector {...defaultProps} onChange={handleChange} />);
await waitFor(() => {
expect(screen.queryByText('読み込み中...')).not.toBeInTheDocument();
});
const select = screen.getByRole('combobox');
await user.selectOptions(select, '111');
expect(handleChange).toHaveBeenCalledWith('111');
});
it('初期値が正しく選択される', async () => {
render(<AccountSelector {...defaultProps} value="111" />);
await waitFor(() => {
const select = screen.getByRole('combobox');
expect(select).toHaveValue('111');
});
});
it('B/Sでフィルタできる', async () => {
render(
<AccountSelector {...defaultProps} filter={{ bsPlType: 'B' }} />
);
await waitFor(() => {
expect(screen.queryByText('読み込み中...')).not.toBeInTheDocument();
});
// P/L科目(売上高)は表示されない
const options = screen.getAllByRole('option');
const optionTexts = options.map(o => o.textContent);
expect(optionTexts.some(t => t?.includes('現金'))).toBe(true);
expect(optionTexts.some(t => t?.includes('売上高'))).toBe(false);
});
it('要素区分でフィルタできる', async () => {
render(
<AccountSelector {...defaultProps} filter={{ elementType: '資産' }} />
);
await waitFor(() => {
expect(screen.queryByText('読み込み中...')).not.toBeInTheDocument();
});
const options = screen.getAllByRole('option');
const optionTexts = options.map(o => o.textContent);
expect(optionTexts.some(t => t?.includes('現金'))).toBe(true);
expect(optionTexts.some(t => t?.includes('買掛金'))).toBe(false); // 負債
});
it('requiredの場合、未選択でバリデーションエラー', async () => {
render(<AccountSelector {...defaultProps} required />);
await waitFor(() => {
expect(screen.queryByText('読み込み中...')).not.toBeInTheDocument();
});
const select = screen.getByRole('combobox');
expect(select).toBeRequired();
});
});
仕訳入力フォームのテスト¶
// src/components/journal/JournalEntryForm.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, within } from '@/test/utils';
import userEvent from '@testing-library/user-event';
import { JournalEntryForm } from './JournalEntryForm';
describe('JournalEntryForm', () => {
const defaultProps = {
onSubmit: vi.fn(),
onCancel: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('フォームが正しくレンダリングされる', async () => {
render(<JournalEntryForm {...defaultProps} />);
expect(screen.getByLabelText('仕訳日付')).toBeInTheDocument();
expect(screen.getByLabelText('摘要')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '明細追加' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'キャンセル' })).toBeInTheDocument();
});
it('明細行を追加できる', async () => {
const user = userEvent.setup();
render(<JournalEntryForm {...defaultProps} />);
const addButton = screen.getByRole('button', { name: '明細追加' });
await user.click(addButton);
// 明細行が追加される
const detailRows = screen.getAllByTestId('journal-detail-row');
expect(detailRows).toHaveLength(1);
});
it('貸借バランスが計算される', async () => {
const user = userEvent.setup();
render(<JournalEntryForm {...defaultProps} />);
// 明細行を2つ追加
const addButton = screen.getByRole('button', { name: '明細追加' });
await user.click(addButton);
await user.click(addButton);
// 借方に10000円を入力
const debitInputs = screen.getAllByTestId('debit-amount');
await user.type(debitInputs[0], '10000');
// 貸方に10000円を入力
const creditInputs = screen.getAllByTestId('credit-amount');
await user.type(creditInputs[1], '10000');
// バランスが取れている
await waitFor(() => {
expect(screen.getByTestId('balance-status')).toHaveTextContent('バランス');
});
});
it('貸借不一致の場合はエラー表示', async () => {
const user = userEvent.setup();
render(<JournalEntryForm {...defaultProps} />);
const addButton = screen.getByRole('button', { name: '明細追加' });
await user.click(addButton);
await user.click(addButton);
const debitInputs = screen.getAllByTestId('debit-amount');
await user.type(debitInputs[0], '10000');
const creditInputs = screen.getAllByTestId('credit-amount');
await user.type(creditInputs[1], '5000');
await waitFor(() => {
expect(screen.getByTestId('balance-status')).toHaveTextContent('差額: 5,000');
});
});
it('貸借不一致の場合は保存ボタンが無効', async () => {
const user = userEvent.setup();
render(<JournalEntryForm {...defaultProps} />);
const addButton = screen.getByRole('button', { name: '明細追加' });
await user.click(addButton);
await user.click(addButton);
const debitInputs = screen.getAllByTestId('debit-amount');
await user.type(debitInputs[0], '10000');
const saveButton = screen.getByRole('button', { name: '保存' });
expect(saveButton).toBeDisabled();
});
it('正常な入力でonSubmitが呼ばれる', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<JournalEntryForm {...defaultProps} onSubmit={handleSubmit} />);
// 日付入力
const dateInput = screen.getByLabelText('仕訳日付');
await user.clear(dateInput);
await user.type(dateInput, '2024-04-01');
// 摘要入力
const descInput = screen.getByLabelText('摘要');
await user.type(descInput, 'テスト仕訳');
// 明細追加
const addButton = screen.getByRole('button', { name: '明細追加' });
await user.click(addButton);
await user.click(addButton);
// 勘定科目と金額を入力
await waitFor(() => {
expect(screen.queryByText('読み込み中...')).not.toBeInTheDocument();
});
const accountSelects = screen.getAllByTestId('account-select');
await user.selectOptions(accountSelects[0], '111');
await user.selectOptions(accountSelects[1], '411');
const debitInputs = screen.getAllByTestId('debit-amount');
await user.type(debitInputs[0], '10000');
const creditInputs = screen.getAllByTestId('credit-amount');
await user.type(creditInputs[1], '10000');
// 保存
const saveButton = screen.getByRole('button', { name: '保存' });
await user.click(saveButton);
expect(handleSubmit).toHaveBeenCalledWith(
expect.objectContaining({
journalDate: '2024-04-01',
description: 'テスト仕訳',
details: expect.arrayContaining([
expect.objectContaining({
accountCode: '111',
debitAmount: 10000,
creditAmount: 0,
}),
expect.objectContaining({
accountCode: '411',
debitAmount: 0,
creditAmount: 10000,
}),
]),
})
);
});
it('キャンセルボタンでonCancelが呼ばれる', async () => {
const user = userEvent.setup();
const handleCancel = vi.fn();
render(<JournalEntryForm {...defaultProps} onCancel={handleCancel} />);
const cancelButton = screen.getByRole('button', { name: 'キャンセル' });
await user.click(cancelButton);
expect(handleCancel).toHaveBeenCalled();
});
});
23.4 カスタムフックのテスト¶
useBalanceValidation のテスト¶
// src/hooks/useBalanceValidation.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useBalanceValidation } from './useBalanceValidation';
describe('useBalanceValidation', () => {
it('空の明細では isValid が false', () => {
const { result } = renderHook(() => useBalanceValidation([]));
expect(result.current.isValid).toBe(false);
expect(result.current.totalDebit).toBe(0);
expect(result.current.totalCredit).toBe(0);
expect(result.current.difference).toBe(0);
});
it('貸借一致の場合は isValid が true', () => {
const details = [
{ debitAmount: 10000, creditAmount: 0 },
{ debitAmount: 0, creditAmount: 10000 },
];
const { result } = renderHook(() => useBalanceValidation(details));
expect(result.current.isValid).toBe(true);
expect(result.current.totalDebit).toBe(10000);
expect(result.current.totalCredit).toBe(10000);
expect(result.current.difference).toBe(0);
});
it('貸借不一致の場合は isValid が false', () => {
const details = [
{ debitAmount: 10000, creditAmount: 0 },
{ debitAmount: 0, creditAmount: 5000 },
];
const { result } = renderHook(() => useBalanceValidation(details));
expect(result.current.isValid).toBe(false);
expect(result.current.totalDebit).toBe(10000);
expect(result.current.totalCredit).toBe(5000);
expect(result.current.difference).toBe(5000);
});
it('複数行の合計が計算される', () => {
const details = [
{ debitAmount: 5000, creditAmount: 0 },
{ debitAmount: 3000, creditAmount: 0 },
{ debitAmount: 2000, creditAmount: 0 },
{ debitAmount: 0, creditAmount: 10000 },
];
const { result } = renderHook(() => useBalanceValidation(details));
expect(result.current.isValid).toBe(true);
expect(result.current.totalDebit).toBe(10000);
expect(result.current.totalCredit).toBe(10000);
});
it('小数点以下の金額も正確に計算される', () => {
const details = [
{ debitAmount: 100.01, creditAmount: 0 },
{ debitAmount: 200.02, creditAmount: 0 },
{ debitAmount: 0, creditAmount: 300.03 },
];
const { result } = renderHook(() => useBalanceValidation(details));
expect(result.current.isValid).toBe(true);
expect(result.current.totalDebit).toBeCloseTo(300.03);
expect(result.current.totalCredit).toBeCloseTo(300.03);
});
it('明細が更新されると再計算される', () => {
const initialDetails = [
{ debitAmount: 10000, creditAmount: 0 },
{ debitAmount: 0, creditAmount: 5000 },
];
const { result, rerender } = renderHook(
({ details }) => useBalanceValidation(details),
{ initialProps: { details: initialDetails } }
);
expect(result.current.isValid).toBe(false);
const updatedDetails = [
{ debitAmount: 10000, creditAmount: 0 },
{ debitAmount: 0, creditAmount: 10000 },
];
rerender({ details: updatedDetails });
expect(result.current.isValid).toBe(true);
});
});
useTaxCalculation のテスト¶
// src/hooks/useTaxCalculation.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTaxCalculation } from './useTaxCalculation';
describe('useTaxCalculation', () => {
it('税込金額から税抜金額と消費税額を計算する', () => {
const { result } = renderHook(() => useTaxCalculation());
act(() => {
result.current.calculateFromTaxIncluded(11000, 0.1);
});
expect(result.current.taxExcluded).toBe(10000);
expect(result.current.taxAmount).toBe(1000);
expect(result.current.taxIncluded).toBe(11000);
});
it('税抜金額から税込金額と消費税額を計算する', () => {
const { result } = renderHook(() => useTaxCalculation());
act(() => {
result.current.calculateFromTaxExcluded(10000, 0.1);
});
expect(result.current.taxExcluded).toBe(10000);
expect(result.current.taxAmount).toBe(1000);
expect(result.current.taxIncluded).toBe(11000);
});
it('8%の軽減税率で計算できる', () => {
const { result } = renderHook(() => useTaxCalculation());
act(() => {
result.current.calculateFromTaxExcluded(10000, 0.08);
});
expect(result.current.taxAmount).toBe(800);
expect(result.current.taxIncluded).toBe(10800);
});
it('端数は切り捨て', () => {
const { result } = renderHook(() => useTaxCalculation());
act(() => {
result.current.calculateFromTaxExcluded(999, 0.1);
});
// 999 * 0.1 = 99.9 → 切り捨てで 99
expect(result.current.taxAmount).toBe(99);
expect(result.current.taxIncluded).toBe(1098);
});
it('税込からの逆算でも端数処理が正しい', () => {
const { result } = renderHook(() => useTaxCalculation());
act(() => {
result.current.calculateFromTaxIncluded(1098, 0.1);
});
// 1098 / 1.1 = 998.18... → 切り捨てで 998
// 税額 = 1098 - 998 = 100
expect(result.current.taxExcluded).toBe(998);
expect(result.current.taxAmount).toBe(100);
});
});
useJournalSearch のテスト¶
// src/hooks/useJournalSearch.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { useJournalSearch } from './useJournalSearch';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
};
describe('useJournalSearch', () => {
beforeEach(() => {
// URLをリセット
window.history.pushState({}, '', '/journal');
});
it('初期状態で仕訳一覧を取得する', async () => {
const { result } = renderHook(() => useJournalSearch(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.journals).toBeDefined();
expect(result.current.journals.length).toBeGreaterThan(0);
});
it('検索条件を更新するとURLが変更される', async () => {
const { result } = renderHook(() => useJournalSearch(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
result.current.updateSearchParams({
startDate: '2024-04-01',
endDate: '2024-04-30',
});
expect(window.location.search).toContain('startDate=2024-04-01');
expect(window.location.search).toContain('endDate=2024-04-30');
});
it('URLパラメータから検索条件を復元する', async () => {
window.history.pushState({}, '', '/journal?startDate=2024-04-01&keyword=test');
const { result } = renderHook(() => useJournalSearch(), {
wrapper: createWrapper(),
});
expect(result.current.searchParams.startDate).toBe('2024-04-01');
expect(result.current.searchParams.keyword).toBe('test');
});
it('ページを変更するとデータが再取得される', async () => {
const { result } = renderHook(() => useJournalSearch(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
result.current.goToPage(1);
expect(result.current.currentPage).toBe(1);
});
it('検索をクリアすると初期状態に戻る', async () => {
window.history.pushState({}, '', '/journal?startDate=2024-04-01');
const { result } = renderHook(() => useJournalSearch(), {
wrapper: createWrapper(),
});
result.current.clearSearch();
expect(result.current.searchParams.startDate).toBeUndefined();
expect(window.location.search).toBe('');
});
});
23.5 金額計算のテスト¶
decimal.js を使用した精度テスト¶
// src/utils/money.test.ts
import { describe, it, expect } from 'vitest';
import {
add,
subtract,
multiply,
divide,
formatMoney,
parseMoney,
roundMoney,
} from './money';
describe('Money Utils', () => {
describe('add', () => {
it('整数の加算', () => {
expect(add(100, 200)).toBe(300);
});
it('小数の加算(浮動小数点誤差なし)', () => {
// JavaScript: 0.1 + 0.2 = 0.30000000000000004
expect(add(0.1, 0.2)).toBe(0.3);
});
it('大きな金額の加算', () => {
expect(add(99999999, 1)).toBe(100000000);
});
});
describe('subtract', () => {
it('整数の減算', () => {
expect(subtract(300, 100)).toBe(200);
});
it('小数の減算(浮動小数点誤差なし)', () => {
expect(subtract(0.3, 0.1)).toBe(0.2);
});
it('結果がマイナスになる減算', () => {
expect(subtract(100, 200)).toBe(-100);
});
});
describe('multiply', () => {
it('整数の乗算', () => {
expect(multiply(100, 3)).toBe(300);
});
it('消費税計算(10%)', () => {
expect(multiply(10000, 0.1)).toBe(1000);
});
it('消費税計算(8%)', () => {
expect(multiply(10000, 0.08)).toBe(800);
});
it('端数が出る乗算', () => {
// 999 * 0.1 = 99.9
expect(multiply(999, 0.1)).toBe(99.9);
});
});
describe('divide', () => {
it('整数の除算', () => {
expect(divide(300, 3)).toBe(100);
});
it('税込から税抜への計算', () => {
// 11000 / 1.1 = 10000
expect(divide(11000, 1.1)).toBe(10000);
});
it('割り切れない除算', () => {
// 10000 / 3 = 3333.333...
expect(divide(10000, 3)).toBeCloseTo(3333.33, 2);
});
it('ゼロ除算でエラー', () => {
expect(() => divide(100, 0)).toThrow();
});
});
describe('roundMoney', () => {
it('切り捨て', () => {
expect(roundMoney(99.9, 'floor')).toBe(99);
});
it('切り上げ', () => {
expect(roundMoney(99.1, 'ceil')).toBe(100);
});
it('四捨五入', () => {
expect(roundMoney(99.4, 'round')).toBe(99);
expect(roundMoney(99.5, 'round')).toBe(100);
});
});
describe('formatMoney', () => {
it('3桁区切り', () => {
expect(formatMoney(1000000)).toBe('1,000,000');
});
it('負数', () => {
expect(formatMoney(-1000)).toBe('-1,000');
});
it('ゼロ', () => {
expect(formatMoney(0)).toBe('0');
});
it('小数', () => {
expect(formatMoney(1000.5)).toBe('1,000.5');
});
it('単位付き', () => {
expect(formatMoney(1000, { suffix: '円' })).toBe('1,000円');
});
});
describe('parseMoney', () => {
it('カンマ区切りを解析', () => {
expect(parseMoney('1,000,000')).toBe(1000000);
});
it('円マーク付きを解析', () => {
expect(parseMoney('¥1,000')).toBe(1000);
});
it('負数を解析', () => {
expect(parseMoney('-1,000')).toBe(-1000);
});
it('空文字は0', () => {
expect(parseMoney('')).toBe(0);
});
it('無効な文字列はNaN', () => {
expect(parseMoney('abc')).toBeNaN();
});
});
});
23.6 API 連携テスト¶
API エラーハンドリングのテスト¶
// src/containers/AccountContainer.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@/test/utils';
import userEvent from '@testing-library/user-event';
import { AccountContainer } from './AccountContainer';
import { mockApiError } from '@/test/mocks/handlers/override';
describe('AccountContainer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('勘定科目一覧を表示する', async () => {
render(<AccountContainer />);
await waitFor(() => {
expect(screen.getByText('現金')).toBeInTheDocument();
expect(screen.getByText('普通預金')).toBeInTheDocument();
});
});
it('API エラー時にエラーメッセージを表示する', async () => {
mockApiError('get', '/api/accounts', 500, 'Internal Server Error');
render(<AccountContainer />);
await waitFor(() => {
expect(screen.getByText(/エラーが発生しました/)).toBeInTheDocument();
});
});
it('認証エラー時にログイン画面にリダイレクトされる', async () => {
mockApiError('get', '/api/accounts', 401, 'Unauthorized');
render(<AccountContainer />);
await waitFor(() => {
// ログイン画面へのリダイレクトをテスト
expect(window.location.pathname).toBe('/login');
});
});
it('新規作成が成功するとメッセージが表示される', async () => {
const user = userEvent.setup();
render(<AccountContainer />);
await waitFor(() => {
expect(screen.getByText('現金')).toBeInTheDocument();
});
// 新規作成ボタンをクリック
const createButton = screen.getByRole('button', { name: '新規作成' });
await user.click(createButton);
// フォームに入力
await user.type(screen.getByLabelText('科目コード'), '999');
await user.type(screen.getByLabelText('科目名'), 'テスト科目');
// 保存
const saveButton = screen.getByRole('button', { name: '保存' });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/登録しました/)).toBeInTheDocument();
});
});
});
23.7 スナップショットテスト¶
// src/components/statement/BalanceSheetView.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@/test/utils';
import { BalanceSheetView } from './BalanceSheetView';
describe('BalanceSheetView', () => {
const mockData = {
periodEnd: '2024-03-31',
assets: [
{
id: '1',
code: '1',
name: '資産の部',
amount: 1000000,
children: [
{ id: '11', code: '11', name: '流動資産', amount: 600000, children: [] },
{ id: '12', code: '12', name: '固定資産', amount: 400000, children: [] },
],
},
],
liabilities: [
{
id: '2',
code: '2',
name: '負債の部',
amount: 300000,
children: [],
},
],
equity: [
{
id: '3',
code: '3',
name: '純資産の部',
amount: 700000,
children: [],
},
],
totalAssets: 1000000,
totalLiabilities: 300000,
totalEquity: 700000,
};
it('勘定式でスナップショットと一致する', () => {
const { container } = render(
<BalanceSheetView
data={mockData}
layout="account"
isLoading={false}
/>
);
expect(container).toMatchSnapshot();
});
it('報告式でスナップショットと一致する', () => {
const { container } = render(
<BalanceSheetView
data={mockData}
layout="report"
isLoading={false}
/>
);
expect(container).toMatchSnapshot();
});
});
23.8 テスト実行設定¶
package.json スクリプト¶
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest watch"
}
}
カバレッジ設定¶
// vite.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
'src/api/generated/',
'src/api/model/',
'**/*.d.ts',
'**/*.test.{ts,tsx}',
],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
},
});
23.9 まとめ¶
本章では、フロントエンドの単体テスト実装について解説した。主なポイントは以下の通りである:
- Vitest と Testing Library: モダンで高速なテスト環境
- MSW によるモック: 実際の API を使わずに型安全なテスト
- コンポーネントテスト: ユーザー操作をシミュレート
- カスタムフックテスト: renderHook による状態管理テスト
- 金額計算テスト: decimal.js による精度保証の検証
- スナップショットテスト: UI の予期しない変更を検出
これらのテストにより、リファクタリングや機能追加時の回帰を防ぎ、コードの品質を維持できる。次章では、E2E テストについて解説する。