Reactチュートリアル：三目並べをTypeScriptにしてコンポーネントを分割してロジックを分離してUTを実装する

# はじめに
Reactの書籍は多く出版されていますが、コンポーネントの分割やロジック分離を意識し、UTも実装するとの情報はまだまだ少ないと感じております。

この記事ではReactの[チュートリアル：三目並べ](https://ja.react.dev/learn/tutorial-tic-tac-toe#building-the-board)をTypeScriptで書き換え、コンポーネントとロジックの分離を意識した構成(Container/Presentationalパターン)にし、UTを実装するとの内容となります。

公式はチュートリアル形式ですが、本記事では、完成形からの簡単な説明とUTの実装について記載させていただきます。

利用したReactのバージョンは18.3.1となります。

プロジェクトの全ファイルは[github](https://github.com/small-java-world/react-tutorial)に登録しております。

# Reactのチュートリアル：三目並べの説明

[チュートリアル：三目並べ](https://ja.react.dev/learn/tutorial-tic-tac-toe#building-the-board)の冒頭の説明の引用となりますが、概要は以下のようになります。
>このチュートリアルでは、小さな三目並べゲーム (tic-tac-toe) を作成します。このチュートリアルを読むにあたり、React に関する事前知識は一切必要ありません。このチュートリアルで学ぶ技法は React アプリを構築する際の基礎となるものであり、マスターすることで React についての深い理解が得られます。


## 画面イメージ
以下のような画面となります。
![スクリーンショット 2024-09-22 193947.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102138/fc26ead4-f04d-53c8-63ef-0929ddd171d6.png)

# 三目並べのコード説明

## ディレクトリ構成とファイルリスト

三目並べの処理が完成した時点のファイルリストは以下となります。

```
プロジェクトルート
└── 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
```

## 公式との相違点の概要

### コンポーネント構成
公式とな異なり、コンポーネントを細かく分割しております。

![origina3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102138/c23fea32-a86a-f9fd-e110-01fbd728c568.png)


### 状態管理
公式版はGameコンポーネント内からuseStateを呼び出しています。

```typescript
export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
```

今回の記事では、コンポーネントとロジックをできるだけ分離したいので、カスタムフックとしてuse-game-state.ts内で単一のstate(GameState)として管理するようにしています。単一の状態で管理した方が分かりやすいからですが、currentMoveだけの更新処理もあるので、違うだろと思われても仕方ないですが、オブジェクトのコピー処理のコストの小ささを考えると妥当と思います。


```typescript
export const useGameState = () => {
    const [state, setState] = useState<GameState>({
        history: [
            {
                squares: Array(9).fill(null),
            },
        ],
        currentMove: 0,
    })
```

GameStateなどの共通的な型はgame-type.tsで定義しております。

```typescript
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コンポーネントを呼び出しています。

```typescript
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
```typescript: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

```typescript: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をモック化
```typescript:game.tsxの抜粋
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
        });
```

これによって
```typescript:game.tsxの抜粋
const Game = () => {
    const {state, currentStatus, currentSquares, handleClick, handleJumpTo} = useGameState();
```
のuseGameStateの結果が固定化されます。

### 子供のコンポーネントのモック化

```typescript:game.test.tsxの抜粋
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>
        ));   
        
```

これによって
```typescript:game.tsxの抜粋
            <Board squares={currentSquares} currentStatus={currentStatus} handleClick={handleClick}/>
            <NavigationMenu history={state.history} handleJumpTo={handleJumpTo}></NavigationMenu>
```
の結果が固定化されます。
            
### Boardコンポーネントを含んだ検証

#### renders Board component with correct props
左側にbordSpy.mockImplementationの実装内容、右側にテストの内容を表示した画像は以下のようになります。
モックの実装と検証内容を見ながら検証内容を確認いただければ理解しやすいと思います。
![スクリーンショット 2024-09-23 120843.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102138/acb06014-d778-5092-1b7d-0336b2d4b73a.png)

モックでハンドラー以外のprosの値を全て埋め込んでいるので、``expect(bordSpy).toHaveBeenNthCalledWith``
もやるのは過剰なのですが、一つ目の例ですので過剰に検証しております。

#### calls handleClick when a square is clicked
クリック系のテストとなります。

bordSpy.mockImplementationで仕込んだイベントに対する検証を実施しています。
![スクリーンショット 2024-09-23 122016.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102138/c9ad4105-f52d-21d3-62d2-9dc434a2f8b9.png)

### NavigationMenuコンポーネントを含んだ検証
対象が異なるだけでBoardコンポーネントのテストとほぼ同じ考え方なので説明は割愛させていただきます。

## useGameStateとテスト

### use-game-state.ts
単一のstate(GameState)として管理するようにしていますが、それ以外は公式とほぼ同じ処理となっております。

```typescript:use-game-state.ts
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

```typescript: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でモック化しても初期値は普通の初期化時と同じ値となります。

```typescript:use-game-state.test.tsの抜粋
    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番目のマスをクリックした後の状態を検証しています。

```typescript:use-game-state.test.tsの抜粋
    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番目のマスをクリックしても無視されるとの検証を実施しています。

```typescript:use-game-state.test.tsの抜粋
    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番目をクリックした時の状態を検証しています。

```typescript:use-game-state.test.tsの抜粋
    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が勝利した時の状態を検証しています。

```typescript:use-game-state.test.tsの抜粋
    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

```typescript: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

```typescript: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

```typescript: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

```typescript: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

```typescript: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

```typescript:navigation-menu.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

```typescript: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
```typescript: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](https://github.com/small-java-world/react-tutorial)に登録しております。