Skip to the code
cf.

Tic-tac-toe

The tea shop keeps a tablet on the window table so regulars can play while their tea steeps. Build TicTacToe, the default export: tic-tac-toe for two players taking turns on one screen. It takes no props.

The board is 9 buttons in 3 rows. Give each one the accessible name Row 1, column 1 through Row 3, column 3 with aria-label, and show X, O, or nothing as its text. X always goes first. A square that's taken is disabled.

Above the board, a <p aria-live="polite"> shows the game's status: Next player: X (or O) while the game is on, Winner: X (or O) when someone gets three in a row, a column, or either diagonal, and Draw when the board is full and nobody won. A win on the ninth square is a win, not a draw.

Once someone wins, the game is over: every square is disabled.

A Restart button clears the board and gives the first move back to X.

Below the board, list the history: a Go to game start button, then one button per move made so far, Go to move 1, Go to move 2, and so on. Clicking one shows the board as it was after that move, with the right player to go next. Playing a move from there throws away the moves that came after it, so the history now ends at the new move.

<p aria-live="polite">Next player: O</p>
<button aria-label="Row 1, column 1">X</button>
<button aria-label="Row 1, column 2"></button>
...
<button>Restart</button>
<ol>
  <li><button>Go to game start</button></li>
  <li><button>Go to move 1</button></li>
</ol>

What the tests check

  1. starts with an empty board and X to play
    render(<App />);
    for (let row = 1; row <= 3; row++) {
      for (let column = 1; column <= 3; column++) {
        const square = screen.getByRole("button", { name: "Row " + row + ", column " + column });
        expect(square.textContent).toBe("");
        expect(square).toBeEnabled();
      }
    }
    expect(screen.getByText("Next player: X")).toBeInTheDocument();
    
  2. players take turns, X first
    const square = (row, column) => screen.getByRole("button", { name: "Row " + row + ", column " + column });
    render(<App />);
    await user.click(square(1, 1));
    expect(square(1, 1)).toHaveTextContent("X");
    expect(screen.getByText("Next player: O")).toBeInTheDocument();
    await user.click(square(2, 2));
    expect(square(2, 2)).toHaveTextContent("O");
    expect(screen.getByText("Next player: X")).toBeInTheDocument();
    
  3. a taken square is disabled and keeps its mark
    const square = (row, column) => screen.getByRole("button", { name: "Row " + row + ", column " + column });
    render(<App />);
    await user.click(square(1, 1));
    expect(square(1, 1)).toBeDisabled();
    await user.click(square(1, 1));
    expect(square(1, 1)).toHaveTextContent("X");
    expect(screen.getByText("Next player: O")).toBeInTheDocument();
    
  4. finds a win in a row and in a column
    const square = (row, column) => screen.getByRole("button", { name: "Row " + row + ", column " + column });
    const play = async (moves) => {
      for (const [row, column] of moves) await user.click(square(row, column));
    };
    const first = render(<App />);
    await play([[1, 1], [2, 1], [1, 2], [2, 2], [1, 3]]);
    expect(screen.getByText("Winner: X")).toBeInTheDocument();
    first.unmount();
    render(<App />);
    await play([[1, 1], [1, 2], [2, 1], [2, 2], [3, 3], [3, 2]]);
    expect(screen.getByText("Winner: O")).toBeInTheDocument();
    
  5. finds a win on either diagonal
    const square = (row, column) => screen.getByRole("button", { name: "Row " + row + ", column " + column });
    const play = async (moves) => {
      for (const [row, column] of moves) await user.click(square(row, column));
    };
    const first = render(<App />);
    await play([[1, 1], [1, 2], [2, 2], [1, 3], [3, 3]]);
    expect(screen.getByText("Winner: X")).toBeInTheDocument();
    first.unmount();
    render(<App />);
    await play([[1, 1], [1, 3], [1, 2], [2, 2], [3, 3], [3, 1]]);
    expect(screen.getByText("Winner: O")).toBeInTheDocument();
    
  6. no more moves once someone has won
    const square = (row, column) => screen.getByRole("button", { name: "Row " + row + ", column " + column });
    render(<App />);
    for (const [row, column] of [[1, 1], [2, 1], [1, 2], [2, 2], [1, 3]]) await user.click(square(row, column));
    for (const [row, column] of [[2, 3], [3, 1], [3, 2], [3, 3]]) expect(square(row, column)).toBeDisabled();
    await user.click(square(3, 3));
    expect(square(3, 3).textContent).toBe("");
    expect(screen.getByText("Winner: X")).toBeInTheDocument();
    
  7. calls a full board with no winner a draw
    const square = (row, column) => screen.getByRole("button", { name: "Row " + row + ", column " + column });
    render(<App />);
    // X O X
    // X O O
    // O X X
    for (const [row, column] of [[1, 1], [1, 2], [1, 3], [2, 2], [2, 1], [2, 3], [3, 2], [3, 1], [3, 3]]) {
      await user.click(square(row, column));
    }
    expect(screen.getByText("Draw")).toBeInTheDocument();
    expect(screen.queryByText(/Winner/)).not.toBeInTheDocument();
    
  8. a win on the ninth square is a win, not a draw
    const square = (row, column) => screen.getByRole("button", { name: "Row " + row + ", column " + column });
    render(<App />);
    // X O X
    // X O O
    // X X O   (X fills the first column with the last move)
    for (const [row, column] of [[1, 1], [1, 2], [1, 3], [2, 2], [2, 1], [2, 3], [3, 2], [3, 3], [3, 1]]) {
      await user.click(square(row, column));
    }
    expect(screen.getByText("Winner: X")).toBeInTheDocument();
    expect(screen.queryByText("Draw")).not.toBeInTheDocument();
    
  9. Restart clears the board and gives X the first move
    const square = (row, column) => screen.getByRole("button", { name: "Row " + row + ", column " + column });
    render(<App />);
    await user.click(square(1, 1));
    await user.click(square(2, 2));
    await user.click(square(3, 3));
    await user.click(screen.getByRole("button", { name: "Restart" }));
    expect(square(1, 1).textContent).toBe("");
    expect(square(2, 2).textContent).toBe("");
    expect(square(1, 1)).toBeEnabled();
    expect(screen.getByText("Next player: X")).toBeInTheDocument();
    await user.click(square(2, 2));
    expect(square(2, 2)).toHaveTextContent("X");
    
  10. jumps back in the history and drops the moves after it
    const square = (row, column) => screen.getByRole("button", { name: "Row " + row + ", column " + column });
    render(<App />);
    await user.click(square(1, 1));
    await user.click(square(2, 2));
    await user.click(square(3, 3));
    expect(screen.getByRole("button", { name: "Go to game start" })).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Go to move 1" }));
    expect(square(1, 1)).toHaveTextContent("X");
    expect(square(2, 2).textContent).toBe("");
    expect(square(3, 3).textContent).toBe("");
    expect(screen.getByText("Next player: O")).toBeInTheDocument();
    await user.click(square(1, 3));
    expect(square(1, 3)).toHaveTextContent("O");
    expect(screen.getByRole("button", { name: "Go to move 2" })).toBeInTheDocument();
    expect(screen.queryByRole("button", { name: "Go to move 3" })).not.toBeInTheDocument();
    expect(screen.getByText("Next player: X")).toBeInTheDocument();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).