1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-09-23

はじめに

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

この記事ではReactのチュートリアル:三目並べをTypeScriptで書き換え、コンポーネントとロジックの分離を意識した構成(Container/Presentationalパターン)にし、UTを実装するとの内容となります。

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

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

プロジェクトの全ファイルはgithubに登録しております。

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

チュートリアル:三目並べの冒頭の説明の引用となりますが、概要は以下のようになります。

このチュートリアルでは、小さな三目並べゲーム (tic-tac-toe) を作成します。このチュートリアルを読むにあたり、React に関する事前知識は一切必要ありません。このチュートリアルで学ぶ技法は React アプリを構築する際の基礎となるものであり、マスターすることで React についての深い理解が得られます。

画面イメージ

以下のような画面となります。
スクリーンショット 2024-09-22 193947.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

状態管理

公式版は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

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

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をモック化

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
        });

これによって

game.tsxの抜粋
const Game = () => {
    const {state, currentStatus, currentSquares, handleClick, handleJumpTo} = useGameState();

のuseGameStateの結果が固定化されます。

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

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>
        ));   
        

これによって

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

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

calls handleClick when a square is clicked

クリック系のテストとなります。

bordSpy.mockImplementationで仕込んだイベントに対する検証を実施しています。
スクリーンショット 2024-09-23 122016.png

NavigationMenuコンポーネントを含んだ検証

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

useGameStateとテスト

use-game-state.ts

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

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

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

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番目のマスをクリックした後の状態を検証しています。

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番目のマスをクリックしても無視されるとの検証を実施しています。

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番目をクリックした時の状態を検証しています。

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が勝利した時の状態を検証しています。

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

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

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

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

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

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

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

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

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に登録しております。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?