Project Goals
3 min- Refactor the game into a testable engine (logic separate from I/O).
- Test win detection for all 8 lines with parametrize.
- Test draws, illegal moves, and turn-taking.
- Use fixtures for board setup.
Warm-Up · Separate Logic from I/O
5 minThe Level-2 game mixed print/input with the rules — that's hard to test. The first job of testing old code is often to extract the logic into pure functions/classes you can call directly.
# ttt.py — the testable ENGINE (no print/input!) class Board: def __init__(self): self.cells = [" "] * 9 # indices 0-8 def move(self, i, player): if self.cells[i] != " ": raise ValueError("cell taken") self.cells[i] = player def winner(self): lines = [(0,1,2),(3,4,5),(6,7,8), # rows (0,3,6),(1,4,7),(2,5,8), # cols (0,4,8),(2,4,6)] # diagonals for a, b, c in lines: if self.cells[a] == self.cells[b] == self.cells[c] != " ": return self.cells[a] return None def is_full(self): return " " not in self.cells
Testable code keeps logic separate from input/output. A Board class you can poke directly is trivial to test; a while loop full of input() is not. "Make it testable" usually means "make it a function/class that takes data and returns data".
Plan · What to Test
14 minThe test checklist for a game engine
✅ a fresh board is empty, no winner ✅ a legal move places the mark ✅ an illegal move (taken cell) raises ✅ each of the 8 winning lines is detected (parametrize!) ✅ a full board with no line is a draw ✅ winner is None mid-game
Fixtures for common boards
import pytest from ttt import Board @pytest.fixture def empty(): return Board() @pytest.fixture def almost_won(): b = Board() b.move(0, "X"); b.move(1, "X") # X needs cell 2 to win the top row return b
Parametrize the 8 winning lines
WINNING_LINES = [ (0, 1, 2), (3, 4, 5), (6, 7, 8), # rows (0, 3, 6), (1, 4, 7), (2, 5, 8), # cols (0, 4, 8), (2, 4, 6), # diagonals ] @pytest.mark.parametrize("line", WINNING_LINES) def test_each_winning_line(line): b = Board() for cell in line: b.move(cell, "X") assert b.winner() == "X"
One test, eight cases — every win condition checked, each reported separately. If a refactor breaks the anti-diagonal, you see exactly [line6] fail.
Build · test_ttt.py
12 min# test_ttt.py — full suite import pytest from ttt import Board WINNING_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), ] @pytest.fixture def empty(): return Board() def test_fresh_board(empty): assert empty.winner() is None assert not empty.is_full() def test_legal_move(empty): empty.move(4, "X") assert empty.cells[4] == "X" def test_illegal_move_raises(empty): empty.move(0, "X") with pytest.raises(ValueError, match="taken"): empty.move(0, "O") @pytest.mark.parametrize("line", WINNING_LINES, ids=[f"line{i}" for i in range(8)]) def test_winning_lines(line): b = Board() for cell in line: b.move(cell, "O") assert b.winner() == "O" def test_draw(): b = Board() # X O X / X O O / O X X → full, no winner layout = "XOXXOOOXX" for i, mark in enumerate(layout): b.move(i, mark) assert b.is_full() assert b.winner() is None def test_no_winner_midgame(empty): empty.move(0, "X"); empty.move(4, "O") assert empty.winner() is None
$ pytest test_ttt.py -v test_fresh_board PASSED test_legal_move PASSED test_illegal_move_raises PASSED test_winning_lines[line0] PASSED ... (8 lines) ... test_draw PASSED test_no_winner_midgame PASSED 14 passed
Read the diff
Fourteen tests, a fixture for the empty board, parametrize for the eight win lines, and pytest.raises for the illegal move. Every rule of the game is now pinned down. Change the win-detection logic and any break is caught instantly, by name. That confidence is the payoff of a good suite.
Extensions
13 minAdd a test that moving to cell 9 (or -1) raises. Make the engine validate the index.
Add turn-tracking to the engine (X then O then X...). Test that two X moves in a row raises "not your turn".
Parametrize the winning-line test over BOTH players (X and O) using stacked parametrize, so all 16 combinations are checked.
Stretch · Find a Real Bug
8 minOpen your original Level-2 Tic-Tac-Toe code. Extract its win-check logic and run your test suite against it. Did your old code have a bug (a missing diagonal, an off-by-one)? Document any bug your tests caught.
Recap
3 minTesting old code starts with extracting logic from I/O into a class you can call directly. Then: a fixture for setup, parametrize for the repetitive cases (8 win lines), pytest.raises for errors, and explicit draw/midgame tests. The result is a suite that locks in every rule and catches any regression by name. Next: a bigger target — the L3 Dungeon Quest.
Homework
4 minBuild the full Tic-Tac-Toe engine + test suite (≥ 14 tests). Include the out-of-range and turn-enforcement extensions. Run against your original L2 code and report any bug found. All tests green on the fixed engine.
Use test_ttt.py as the base and add index validation + turn tracking to the engine, with matching tests. A common L2 bug is a missing diagonal in the win check — your parametrized test catches it immediately.