はじめに
Reactの書籍は多く出版されていますが、コンポーネントの分割やロジック分離を意識し、UTも実装するとの情報はまだまだ少ないと感じております。
この記事ではReactのチュートリアル:三目並べをTypeScriptで書き換え、コンポーネントとロジックの分離を意識した構成(Container/Presentationalパターン)にし、UTを実装するとの内容となります。
公式はチュートリアル形式ですが、本記事では、完成形からの簡単な説明とUTの実装について記載させていただきます。
利用したReactのバージョンは18.3.1となります。
プロジェクトの全ファイルはgithubに登録しております。
Reactのチュートリアル:三目並べの説明
チュートリアル:三目並べの冒頭の説明の引用となりますが、概要は以下のようになります。
このチュートリアルでは、小さな三目並べゲーム (tic-tac-toe) を作成します。このチュートリアルを読むにあたり、React に関する事前知識は一切必要ありません。このチュートリアルで学ぶ技法は React アプリを構築する際の基礎となるものであり、マスターすることで React についての深い理解が得られます。
画面イメージ
三目並べのコード説明
ディレクトリ構成とファイルリスト
三目並べの処理が完成した時点のファイルリストは以下となります。
プロジェクトルート
└── src/
├── components/
│ ├── board.tsx
│ ├── board-row.tsx
│ ├── game.tsx
│ ├── navigation-menu.tsx
│ ├── navigation-menu-item.tsx
│ └── square.tsx
│──hooks/
│ └── use-game-state.ts
│──types/
│ └── game-type.ts
├── App.tsx
├── index.tsx
├── styles.css
公式との相違点の概要
コンポーネント構成
公式とな異なり、コンポーネントを細かく分割しております。
状態管理
公式版はGameコンポーネント内からuseStateを呼び出しています。
export default function Game() {
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
今回の記事では、コンポーネントとロジックをできるだけ分離したいので、カスタムフックとしてuse-game-state.ts内で単一のstate(GameState)として管理するようにしています。単一の状態で管理した方が分かりやすいからですが、currentMoveだけの更新処理もあるので、違うだろと思われても仕方ないですが、オブジェクトのコピー処理のコストの小ささを考えると妥当と思います。
export const useGameState = () => {
const [state, setState] = useState<GameState>({
history: [
{
squares: Array(9).fill(null),
},
],
currentMove: 0,
})
GameStateなどの共通的な型はgame-type.tsで定義しております。
export type SquareState = 'O' | 'X' | null
export type Step = {
readonly squares: SquareState[]
}
export type GameState = {
readonly history: Step[]
readonly currentMove: number
}
Gameコンポーネント
Gameコンポーネント内でuseGameStateを呼び出し、BoardコンポーネントとNavigationMenuコンポーネントを呼び出しています。
import Board from "../components/board";
import NavigationMenu from "./navigation-menu";
import {useGameState} from "../hooks/use-game-state";
const Game = () => {
const {state, currentStatus, currentSquares, handleClick, handleJumpTo} = useGameState();
return (
<div className='game'>
<Board squares={currentSquares} currentStatus={currentStatus} handleClick={handleClick}/>
<NavigationMenu history={state.history} handleJumpTo={handleJumpTo}></NavigationMenu>
</div>
)
};
export default Game;
テストを含めたコードの説明
ディレクトリ構成とファイルリスト
テストを含んだファイルリストは以下となります。
プロジェクトルート
└── src/
├── components/
│ ├── __tests__/
│ │ ├── board.test.tsx
│ │ ├── board-row.test.tsx
│ │ ├── game.test.tsx
│ │ ├── navigation-menu.test.tsx
│ │ └── navigation-menu-item.test.tsx
│ ├── board.tsx
│ ├── board-row.tsx
│ ├── game.tsx
│ ├── navigation-menu.tsx
│ ├── navigation-menu-item.tsx
│ └── square.tsx
│──hooks/
│ ├── __tests__/
│ │ └── use-game-state.test.ts
│ └── use-game-state.ts
│──types/
│ └── game-type.ts
├── App.tsx
├── index.tsx
├── styles.css
Gameコンポーネントとテスト
useGameStateの処理以外はBoardとNavigationMenuの表示だけなので、2つのコンポーネントに渡したpropsで期待どおりに描画されるかとの検証を行います。
useGameStateと2つのコンポーネントはモック化すると方針でテストを実装します。
game.tsx
import Board from "../components/board";
import NavigationMenu from "./navigation-menu";
import {useGameState} from "../hooks/use-game-state";
const Game = () => {
const {state, currentStatus, currentSquares, handleClick, handleJumpTo} = useGameState();
return (
<div className='game'>
<Board squares={currentSquares} currentStatus={currentStatus} handleClick={handleClick}/>
<NavigationMenu history={state.history} handleJumpTo={handleJumpTo}></NavigationMenu>
</div>
)
};
export default Game;
game.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Game from '../game';
import {NavigationMenuProp} from "../navigation-menu";
import {BoardProps} from "../board";
import * as board from "../board";
import * as navigationMenu from "../navigation-menu";
import {useGameState} from "../../hooks/use-game-state";
jest.mock('../../hooks/use-game-state');
jest.mock('../board');
jest.mock('../navigation-menu');
describe('Game Component', () => {
let bordSpy: jest.SpyInstance;
let navigationMenuSpy: jest.SpyInstance;
const dummyState = {
history: [{ squares: Array(9).fill(null) }],
currentMove: 0
};
const mockUseGameState = useGameState as jest.MockedFunction<typeof useGameState>;
const mockHandleClick = jest.fn();
const mockHandleJumpTo = jest.fn();
const mockCurrentStatus = "Next player: X";
beforeEach(() => {
mockUseGameState.mockReturnValue({
state: dummyState,
currentStatus: mockCurrentStatus,
currentSquares: dummyState.history[dummyState.currentMove].squares,
handleClick: mockHandleClick,
handleJumpTo: mockHandleJumpTo
});
bordSpy = jest.spyOn(board, "default");
navigationMenuSpy = jest.spyOn(navigationMenu, "default");
bordSpy.mockImplementation((props: BoardProps) => (
<>
<div data-testid="board" onClick={() => props.handleClick(0)} >
<div data-testid="board-current-status">{props.currentStatus}</div>
<div data-testid="board-current-square">{JSON.stringify(props.squares)}</div>
</div>
</>
));
navigationMenuSpy.mockImplementation((props: NavigationMenuProp) => (
<div data-testid="navigation-menu" onClick={() => props.handleJumpTo(1)}>
{JSON.stringify(props.history)}
</div>
));
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders Board component with correct props', () => {
// Gameコンポーネントを描画
render(<Game />);
// data-testid="board"が存在するか検証
expect(screen.getByTestId('board')).toBeInTheDocument();
// data-testid="board-current-status"のtextContentが期待値と一致するか検証
expect(screen.getByTestId('board-current-status').textContent).toBe(mockCurrentStatus);
// data-testid="square"のtextContentが期待値と一致するか検証
expect(screen.getByTestId('board-current-square').textContent).toBe(JSON.stringify(dummyState.history[dummyState.currentMove].squares));
//bordSpyの呼び出し回数と呼び出し時の引数を検証
expect(bordSpy).toHaveBeenCalledTimes(1);
expect(bordSpy).toHaveBeenNthCalledWith(1,
{
currentStatus: "Next player: X",
handleClick: expect.any(Function),
"squares": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
],
},
{}
);
});
test('renders NavigationMenu component with correct props', () => {
render(<Game/>);
expect(screen.getByTestId('navigation-menu')).toBeInTheDocument();
expect(screen.getByTestId('navigation-menu').textContent).toBe(JSON.stringify(dummyState.history));
expect(navigationMenuSpy).toHaveBeenCalledTimes(1);
expect(navigationMenuSpy).toHaveBeenNthCalledWith(1,
{
history: dummyState.history,
handleJumpTo: expect.any(Function),
},
{}
);
});
test('calls handleClick when a square is clicked', () => {
render(<Game />);
// data-testid='board'をクリック
fireEvent.click(screen.getByTestId('board'));
// bordSpyでdata-testid='board'に仕込んだイベントの発火回数と引数を検証
expect(mockHandleClick).toHaveBeenCalledTimes(1);
expect(mockHandleClick).toHaveBeenCalledWith(0);
});
test('calls jumpTo when a navigation button is clicked', () => {
render(<Game />);
// data-testid='navigation-menu'をクリック
fireEvent.click(screen.getByTestId('navigation-menu'));
// bordSpyでdata-testid='navigation-menu'に仕込んだイベントの発火回数と引数を検証
expect(mockHandleJumpTo).toHaveBeenCalledTimes(1);
expect(mockHandleJumpTo).toHaveBeenCalledWith(1);
});
});
useGameStateをモック化
jest.mock('../../hooks/use-game-state');
describe('Game Component', () => {
const mockUseGameState = useGameState as jest.MockedFunction<typeof useGameState>;
beforeEach(() => {
mockUseGameState.mockReturnValue({
state: dummyState,
currentStatus: mockCurrentStatus,
currentSquares: dummyState.history[dummyState.currentMove].squares,
handleClick: mockHandleClick,
handleJumpTo: mockHandleJumpTo
});
これによって
const Game = () => {
const {state, currentStatus, currentSquares, handleClick, handleJumpTo} = useGameState();
のuseGameStateの結果が固定化されます。
子供のコンポーネントのモック化
import * as board from "../board";
import * as navigationMenu from "../navigation-menu";
jest.mock('../board');
jest.mock('../navigation-menu');
describe('Game Component', () => {
let bordSpy: jest.SpyInstance;
let navigationMenuSpy: jest.SpyInstance;
beforeEach(() => {
bordSpy = jest.spyOn(board, "default");
navigationMenuSpy = jest.spyOn(navigationMenu, "default");
bordSpy.mockImplementation((props: BoardProps) => (
<>
<div data-testid="board" onClick={() => props.handleClick(0)} >
<div data-testid="board-current-status">{props.currentStatus}</div>
<div data-testid="board-current-square">{JSON.stringify(props.squares)}</div>
</div>
</>
));
navigationMenuSpy.mockImplementation((props: NavigationMenuProp) => (
<div data-testid="navigation-menu" onClick={() => props.handleJumpTo(1)}>
{JSON.stringify(props.history)}
</div>
));
これによって
<Board squares={currentSquares} currentStatus={currentStatus} handleClick={handleClick}/>
<NavigationMenu history={state.history} handleJumpTo={handleJumpTo}></NavigationMenu>
の結果が固定化されます。
Boardコンポーネントを含んだ検証
renders Board component with correct props
左側にbordSpy.mockImplementationの実装内容、右側にテストの内容を表示した画像は以下のようになります。
モックの実装と検証内容を見ながら検証内容を確認いただければ理解しやすいと思います。
モックでハンドラー以外のprosの値を全て埋め込んでいるので、expect(bordSpy).toHaveBeenNthCalledWith
もやるのは過剰なのですが、一つ目の例ですので過剰に検証しております。
calls handleClick when a square is clicked
クリック系のテストとなります。
bordSpy.mockImplementationで仕込んだイベントに対する検証を実施しています。
NavigationMenuコンポーネントを含んだ検証
対象が異なるだけでBoardコンポーネントのテストとほぼ同じ考え方なので説明は割愛させていただきます。
useGameStateとテスト
use-game-state.ts
単一のstate(GameState)として管理するようにしていますが、それ以外は公式とほぼ同じ処理となっております。
import {useCallback, useMemo, useState} from 'react';
import {GameState, SquareState} from "../types/game-type";
const calculateWinner = (squares: SquareState[]) => {
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
]
for (let i = 0; i < lines.length; i++) {
const [a, b, c] = lines[i]
if (
squares[a] &&
squares[a] === squares[b] &&
squares[a] === squares[c]
) {
return squares[a]
}
}
return null;
}
export const useGameState = () => {
const [state, setState] = useState<GameState>({
history: [
{
squares: Array(9).fill(null),
},
],
currentMove: 0,
})
const winner =
calculateWinner(state.history[state.currentMove].squares);
const currentStatus = useMemo(() => {
const xIsNext = state.currentMove % 2 === 0;
return winner ? `Winner: ${winner}` : `Next player: ${xIsNext ? 'X' : 'O'}`;
}, [winner, state.currentMove ]);
const handleClick = useCallback((squaresIndex: number) => {
setState(({history, currentMove}) => {
const currentStep = history[currentMove];
if (winner || currentStep?.squares[squaresIndex]) {
return {
history,
currentMove: history.length -1,
}
}
const squares = currentStep.squares;
const nextSquares = squares.slice();
const xIsNext = currentMove % 2 === 0;
if (xIsNext) {
nextSquares[squaresIndex] = 'X';
} else {
nextSquares[squaresIndex] = 'O';
}
const nextHistory = [...history.slice(0, currentMove + 1),
{squares: nextSquares}];
return {
history: nextHistory,
currentMove: nextHistory.length -1,
}
})
}, [winner]);
const handleJumpTo = useCallback((move: number) => {
setState(prev => ({
...prev,
currentMove: move
}))
}, []);
const currentSquares = state.history[state.currentMove].squares;
return {state, currentStatus, currentSquares, handleClick, handleJumpTo} as const;
}
use-game-state.test.ts
import { act, renderHook } from '@testing-library/react';
import { useGameState } from '../use-game-state';
describe('useGameState', () => {
test('initializes with correct stat', () => {
// useGameStateをrenderHookを利用してモック化
const { result } = renderHook(() => useGameState());
// 初期化されたstateが正しいか確認
expect(result.current.state.history).toHaveLength(1);
expect(result.current.state.history[0].squares).toEqual(Array(9).fill(null));
expect(result.current.state.currentMove).toBe(0);
expect(result.current.currentStatus).toBe('Next player: X');
});
test('updates state correctly when a square is clicked', () => {
const { result } = renderHook(() => useGameState());
// handleClickを呼び出し、stateが更新されるか確認
act(() => {
// Xが0番目のマスをクリック
result.current.handleClick(0);
});
// state.historyが2件になっていること
expect(result.current.state.history).toHaveLength(2);
// 先行はXなのでstate.history[1].squares[0]が'X'になっていること
expect(result.current.state.history[1].squares[0]).toBe('X');
// 次のプレイヤーがXOになっていること
expect(result.current.currentStatus).toBe('Next player: O');
});
test('does not update state if square is already filled', () => {
const { result } = renderHook(() => useGameState());
//
act(() => {
// Xが0番目のマスをクリック
result.current.handleClick(0);
// Oが再度0番目のマスをクリック
result.current.handleClick(0);
});
// state.historyが3件ではなく2件になっていること
expect(result.current.state.history).toHaveLength(2);
// Oのクリックが無視されていること
expect(result.current.state.history[1].squares[0]).toBe('X');
// 次のプレイヤーがXOになっていること
expect(result.current.currentStatus).toBe('Next player: O');
});
test('updates currentMove correctly when jumpTo is called', () => {
const { result } = renderHook(() => useGameState());
act(() => {
// Xが0番目のマスをクリック
result.current.handleClick(0);
// Oが1番目のマスをクリック
result.current.handleClick(1);
// NavigationMenuの0番目をクリック
result.current.handleJumpTo(0);
});
// currentMoveが0になっていること
expect(result.current.state.currentMove).toBe(0);
// 次のプレイヤーがOではなくXになっていること
expect(result.current.currentStatus).toBe('Next player: X');
});
test('calculates next player correctly', () => {
const { result } = renderHook(() => useGameState());
act(() => {
result.current.handleClick(0); // X
result.current.handleClick(1); // O
result.current.handleClick(3); // X
result.current.handleClick(2); // O
// この操作でXが勝利
result.current.handleClick(6); // X
});
// state.currentMoveが5になっていること
expect(result.current.state.currentMove).toBe(5);
// state.history[5].squaresが['X', 'O', 'O', 'X', null, null, 'X', null, null]になっていること
expect(result.current.currentSquares).toEqual(['X', 'O', 'O', 'X', null, null, 'X', null, null]);
// 結果が'Winner: X'になっていること
expect(result.current.currentStatus).toBe('Winner: X');
});
});
initializes with correct stat
コメントに書いてあるとおりなのですが、useGameStateをrenderHookを利用してモック化し、初期値を検証しています。
renderHookでモック化しても初期値は普通の初期化時と同じ値となります。
test('initializes with correct stat', () => {
// useGameStateをrenderHookを利用してモック化
const { result } = renderHook(() => useGameState());
// 初期化されたstateが正しいか確認
expect(result.current.state.history).toHaveLength(1);
expect(result.current.state.history[0].squares).toEqual(Array(9).fill(null));
expect(result.current.state.currentMove).toBe(0);
expect(result.current.currentStatus).toBe('Next player: X');
});
updates state correctly when a square is clicked
プレイヤー Xが0番目のマスをクリックした後の状態を検証しています。
test('updates state correctly when a square is clicked', () => {
const { result } = renderHook(() => useGameState());
// handleClickを呼び出し、stateが更新されるか確認
act(() => {
// Xが0番目のマスをクリック
result.current.handleClick(0);
});
// state.historyが2件になっていること
expect(result.current.state.history).toHaveLength(2);
// 先行はXなのでstate.history[1].squares[0]が'X'になっていること
expect(result.current.state.history[1].squares[0]).toBe('X');
// 次のプレイヤーがXOになっていること
expect(result.current.currentStatus).toBe('Next player: O');
});
does not update state if square is already filled
プレイヤー Xが0番目のマスをクリックした後、再度プレイヤー Oが0番目のマスをクリックしても無視されるとの検証を実施しています。
test('does not update state if square is already filled', () => {
const { result } = renderHook(() => useGameState());
//
act(() => {
// Xが0番目のマスをクリック
result.current.handleClick(0);
// Oが再度0番目のマスをクリック
result.current.handleClick(0);
});
// state.historyが3件ではなく2件になっていること
expect(result.current.state.history).toHaveLength(2);
// Oのクリックが無視されていること
expect(result.current.state.history[1].squares[0]).toBe('X');
// 次のプレイヤーがOになっていること
expect(result.current.currentStatus).toBe('Next player: O');
});
updates currentMove correctly when jumpTo is called
プレイヤーXとプレイヤーOが一回ずつクリックした後にNavigationMenuの0番目をクリックした時の状態を検証しています。
test('updates currentMove correctly when jumpTo is called', () => {
const { result } = renderHook(() => useGameState());
act(() => {
// Xが0番目のマスをクリック
result.current.handleClick(0);
// Oが1番目のマスをクリック
result.current.handleClick(1);
// NavigationMenuの0番目をクリック
result.current.handleJumpTo(0);
});
// currentMoveが0になっていること
expect(result.current.state.currentMove).toBe(0);
// 次のプレイヤーがOではなくXになっていること
expect(result.current.currentStatus).toBe('Next player: X');
});
updates currentMove correctly when jumpTo is called
プレイヤーXが勝利した時の状態を検証しています。
test('calculates next player correctly', () => {
const { result } = renderHook(() => useGameState());
act(() => {
result.current.handleClick(0); // X
result.current.handleClick(1); // O
result.current.handleClick(3); // X
result.current.handleClick(2); // O
// この操作でXが勝利
result.current.handleClick(6); // X
});
// state.currentMoveが5になっていること
expect(result.current.state.currentMove).toBe(5);
// state.history[5].squaresが['X', 'O', 'O', 'X', null, null, 'X', null, null]になっていること
expect(result.current.currentSquares).toEqual(['X', 'O', 'O', 'X', null, null, 'X', null, null]);
// 結果が'Winner: X'になっていること
expect(result.current.currentStatus).toBe('Winner: X');
});
Boardコンポーネントとテスト
Gameコンポーネント以外にも数個コンポーネントがありますが、テストの実装方針はGameコンポーネントと同様となりますので説明は割愛させていただきます。
board.tsx
import React from 'react';
import {SquareState} from "../types/game-type";
import BoardRow from "./board-row";
const rowLength = 3;
const columnLength = 3;
export type BoardProps = {
squares: SquareState[],
currentStatus: string,
handleClick: (i: number) => void
}
const Board = (props: BoardProps) => {
return (
<div className="game-board">
<div className="status">{props.currentStatus}</div>
{Array(rowLength).fill(0).map((_: undefined, rowIndex: number) => (
<BoardRow
rowIndex={rowIndex}
rowLength={columnLength}
squares={props.squares}
handleClick={props.handleClick}
key={`row-${rowIndex}`}
/>
))}
</div>
);
}
export default Board;
board.test.tsx
import React from 'react';
import {render, screen, fireEvent} from '@testing-library/react';
import Board from '../board';
import * as boardRow from "../board-row";
import {SquareState} from "../../types/game-type";
jest.mock('../board-row');
describe('Board Component', () => {
const onSquareClick = jest.fn();
const squares: SquareState[] = ["O", "O", "X", "O", "X", "O", "X", "O", "X"];
let boardRowSpy: jest.SpyInstance;
const mockCurrentStatus = "Next player: X";
beforeEach(() => {
boardRowSpy = jest.spyOn(boardRow, "default");
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the correct number of rows', () => {
render(<Board squares={squares} currentStatus={mockCurrentStatus} handleClick={onSquareClick}/>);
expect(screen.getByText(/Next player: X/i)).toBeInTheDocument();
expect(boardRowSpy).toHaveBeenCalledTimes(3);
Array(3).fill(0).forEach((_, rowIndex) => {
expect(boardRowSpy).toHaveBeenNthCalledWith(rowIndex + 1,
{
rowIndex,
rowLength: 3,
squares,
handleClick: onSquareClick,
},
{}
);
});
});
test('calls handleClick when square is clicked', () => {
boardRowSpy.mockImplementation(({ rowIndex, handleClick }) => {
return (
<div className='board-row' data-testid={`board-row-${rowIndex}`}
onClick={() => handleClick(rowIndex)} />
);
});
render(<Board squares={squares} currentStatus={mockCurrentStatus} handleClick={onSquareClick}/>);
const firstRow = screen.getByTestId('board-row-0');
fireEvent.click(firstRow);
expect(onSquareClick).toHaveBeenCalledTimes(1);
expect(onSquareClick).toHaveBeenNthCalledWith(1, 0);
const secondRow = screen.getByTestId('board-row-1');
fireEvent.click(secondRow);
expect(onSquareClick).toHaveBeenCalledTimes(2);
expect(onSquareClick).toHaveBeenNthCalledWith(2, 1);
});
});
BoardRowコンポーネントとテスト
board-row.tsx
import React from 'react';
import Square from "../components/square";
import { SquareState } from "../types/game-type";
type BoardRowProps = {
rowIndex: number;
rowLength: number;
squares: SquareState[];
handleClick: (i: number) => void;
};
const BoardRow = ({ rowIndex, rowLength, squares, handleClick }: BoardRowProps) => {
return (
<div className='board-row'>
{Array(rowLength).fill(0).map((_, columnIndex) => {
const squaresIndex = columnIndex + rowIndex * rowLength;
return (
<Square
value={squares[squaresIndex]}
onSquareClick={() => handleClick(squaresIndex)}
key={`column-${rowIndex}-${columnIndex}`}
/>
);
})}
</div>
);
};
export default BoardRow;
board-row.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import BoardRow from '../board-row';
import { SquareState } from '../../types/game-type';
import * as square from '../square';
jest.mock('../square');
describe('BoardRow Component', () => {
const handleClick = jest.fn();
const squares: SquareState[] = ["O", "O", "X", "O", "X", "O", "X", "O", "X"];
let squareSpy: jest.SpyInstance;
beforeEach(() => {
squareSpy = jest.spyOn(square, "default");
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the correct number of squares', () => {
render(<BoardRow rowIndex={0} rowLength={3} squares={squares} handleClick={handleClick} />);
expect(squareSpy).toHaveBeenCalledTimes(3);
Array(3).fill(0).forEach((_, columnIndex) => {
expect(squareSpy).toHaveBeenNthCalledWtesth(columnIndex + 1,
{
value: squares[columnIndex],
onSquareClick: expect.any(Function),
},
{}
);
});
});
test('calls handleClick when a square is clicked', () => {
squareSpy.mockImplementation(({ value, onSquareClick }) => {
return (
<button className='square' onClick={onSquareClick}>
{value}
</button>
);
});
render(<BoardRow rowIndex={0} rowLength={3} squares={squares} handleClick={handleClick} />);
const firstSquareButton = screen.getAllByRole('button')[0];
fireEvent.click(firstSquareButton);
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
NavigationMenuコンポーネントとテスト
navigation-menu.tsx
import React from 'react';
import {Step} from "../types/game-type";
import NavigationMenuItem from "./navigation-menu-item";
export type NavigationMenuProp = {
history: Step[],
handleJumpTo: (move: number) => void,
}
const NavigationMenu = (props: NavigationMenuProp) => {
return (
<div className='game-info'>
<ol>{
props.history.map((_step, move) => {
return (
<NavigationMenuItem key={move} move={move} handleJumpTo={props.handleJumpTo}/>
)
})
}
</ol>
</div>
);
};
export default NavigationMenu;
navigation-menu.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import NavigationMenu from '../navigation-menu';
import { Step } from '../../types/game-type';
import * as navigationMenuItem from "../navigation-menu-item";
import {NavigationMenuItemProps} from "../navigation-menu-item";
jest.mock('../navigation-menu-item');
describe('NavigationMenu Component', () => {
const jumpToMock = jest.fn();
let navigationMenuItemSpy: jest.SpyInstance;
beforeEach(() => {
navigationMenuItemSpy = jest.spyOn(navigationMenuItem, "default");
navigationMenuItemSpy.mockImplementation((props: NavigationMenuItemProps) => (
<div data-testid={`navigation-menu-item-${props.move}`} onClick={() => props.handleJumpTo(props.move)} />
));
});
afterEach(() => {
jest.clearAllMocks();
});
const generateHistory = (length: number) =>
Array(length).fill(0).map(() => ({squares: []}));
test('does not render any buttons when history is empty', () => {
render(<NavigationMenu history={[]} handleJumpTo={jumpToMock} />);
expect(screen.queryByRole('button', { name: /go to game start/i})).not.toBeInTheDocument();
Array(8).fill(0).forEach((_, index) =>
expect(screen.queryByRole('button', { name: `Go to move #${index+1}`})).not.toBeInTheDocument());
});
test('renders only the "Go to game start" button when history has one item', () => {
const history: Step[] = generateHistory(1)
render(<NavigationMenu history={history} handleJumpTo={jumpToMock} />);
expect(screen.getByTestId('navigation-menu-item-0')).toBeInTheDocument();
expect(screen.queryByTestId('navigation-menu-item-1')).not.toBeInTheDocument();
});
test('renders all move buttons when history has nine items', () => {
const history: Step[] = generateHistory(9)
render(<NavigationMenu history={history} handleJumpTo={jumpToMock} />);
Array(8).fill(0).forEach((_, index) =>
expect(screen.getByTestId(`navigation-menu-item-${index+1}`)).toBeInTheDocument())
expect(screen.queryByTestId('navigation-menu-item-9')).not.toBeInTheDocument();
});
});
NavigationMenuItemコンポーネントとテスト
navigation-menu-item.tsx
import React from 'react';
export type NavigationMenuItemProps = {
move: number;
handleJumpTo: (stepNumber: number) => void;
}
const NavigationMenuItem = (props: NavigationMenuItemProps) => {
const description = props.move > 0 ? `Go to move #${props.move}` : 'Go to game start'
return (
<li key={props.move}>
<button onClick={() => props.handleJumpTo(props.move)}>{description}</button>
</li>
);
};
export default NavigationMenuItem;
navigation-menu-item.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import NavigationMenuItem from '../navigation-menu-item';
describe('NavigationMenuItem Component', () => {
const handleJumpToMock = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
test('renders "Go to game start" button when move is 0', () => {
render(<NavigationMenuItem move={0} handleJumpTo={handleJumpToMock} />);
expect(screen.getByRole('button', { name: /go to game start/i })).toBeInTheDocument();
});
test('renders "Go to move #1" button when move is 1', () => {
render(<NavigationMenuItem move={1} handleJumpTo={handleJumpToMock} />);
expect(screen.getByRole('button', { name: /go to move #1/i })).toBeInTheDocument();
});
test('calls handleJumpTo with correct move number when button is clicked', () => {
render(<NavigationMenuItem move={1} handleJumpTo={handleJumpToMock} />);
userEvent.click(screen.getByRole('button', { name: /go to move #1/i }));
expect(handleJumpToMock).toHaveBeenCalledTimes(1);
expect(handleJumpToMock).toHaveBeenCalledWith(1);
});
});
終わりに
生成AIが凄い勢いで進化しており、これぐらいの内容であれば記事を作成する価値も無いような時代になっています。実際、今回のテストのテストタイトルもCopilotにほぼ考えてもらいました。
とはいえ、生成AIが提示してくれるコードを鵜呑みにするのは危険(IT技術者としてあるべき姿でないとの意味)ですので、今後も自分で試行錯誤し、うまく生成AIと付き合って行くことが大切だと感じております。
プロジェクトの全ファイルはgithubに登録しております。