Unit Testing Your Connect4 Library
Before your library plays a single tournament game, you can find out whether it would survive one. The test program below throws 10,000 randomly-generated games and every possible winning line at your code — which is more scrutiny than your opponents will manage, and far better to face in private.
Overview
A unit test is a small, automated check that verifies one specific piece of code — usually a single function — behaves correctly for known inputs.
In short: unit tests test the smallest "units" of code in isolation.
A program that performs unit tests for your Connect4 assignment is provided below to help you detect any issues. These tests cannot guarantee that your assignment will run correctly in all cases! The intent is to detect typical errors before your final submission.
Builds on: Connect4 Check Move Hints — your library needs all four required functions before the tests mean much.
You've got it when…
- The test program runs against your library file.
- Every test reports PASS.
Collaboration & AI
Work: On your own — this is a check of your own library.
AI — AIAS Level 1, No AI: The tests do the checking; your job is to read the results and fix your own code. What the levels mean.
Do This
-
Save the program below as
test_connect4.pyin the same folder as your Connect4 Python (.py) file. Use the copy button in the corner of the code block. -
Run the program. It finds your library file automatically — you do not need to edit the test program at all. (If it finds more than one .py file to choose from, it will ask you which one is yours.)
-
Review the output and make corrections to your Connect4 library until the tests have a PASS result.
Warning
Address any FAIL or ERROR results from top to bottom. A failure on an earlier test can cause failures on later tests — a library that won't even import is going to fail everything after it, and fixing the import may fix the rest.
The Test Program
"""
Utility for unit testing Connect4 library submissions.
To use this:
(1) Save this file to the same location as the module (library) file to test.
(2) Run this program. It finds your library file automatically.
Author: Steve Willis
Version: 1.2
"""
### DO NOT EDIT THIS FILE! ###
from contextlib import redirect_stdout, redirect_stderr
import copy
import importlib
import inspect
import io
import os
import random
import sys
ROWS = 6
COLS = 7
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
MODULE_NAME = None # set automatically by find_module_name()
def find_module_name():
"""Find the library to test: the only other .py file in this folder."""
this_file = os.path.basename(__file__)
candidates = [f[:-3] for f in sorted(os.listdir(HERE))
if f.endswith(".py") and f != this_file]
if len(candidates) == 1:
return candidates[0]
if len(candidates) == 0:
print("No library found to test!")
print(f"Save your Connect4 .py file in this folder: {HERE}")
return None
print("More than one .py file is in this folder. Which is your library?")
for i, name in enumerate(candidates):
print(f" ({i}) {name}.py")
while True:
choice = input("Enter a number: ")
if choice.isdigit() and int(choice) < len(candidates):
return candidates[int(choice)]
def empty_state():
"""Helper function to generate an empty game board."""
return [["-" for j in range(COLS)] for i in range(ROWS)]
def tied_state():
"""Helper function to generate a tied game board."""
row_a = list("RRRYRRR")
row_b = list("YYYRYYY")
return [row_a, row_b, row_a, row_b, row_a, row_b]
def rand_state():
"""Helper function to generate a random, reachable state with no winner."""
def check_winner():
directions = ((0, 1), (1, 0), (1, 1), (1, -1))
for r in range(ROWS):
for c in range(COLS):
p = state[r][c]
if p not in ("R", "Y"):
continue
for dr, dc in directions:
er, ec = r + 3 * dr, c + 3 * dc
if 0 <= er < ROWS and 0 <= ec < COLS:
if all(state[r + i * dr][c + i * dc] == p for i in range(4)):
return True
return None
y_count = 0
r_count = 0
state = empty_state()
player = "R"
moves = random.randint(0, ROWS * COLS - 1) # number of moves to make
heights = [0] * COLS # how many pieces currently in each column (0..6)
for _ in range(moves):
legal_cols = [c for c in range(COLS) if heights[c] < ROWS]
if not legal_cols:
break
c = random.choice(legal_cols)
r = ROWS - 1 - heights[c]
state[r][c] = player
heights[c] += 1
while check_winner():
# take back the last (winning) move
state[r][c] = "-"
heights[c] -= 1
legal_cols.remove(c)
if len(legal_cols) > 0:
c = random.choice(legal_cols)
r = ROWS - 1 - heights[c]
state[r][c] = player
heights[c] += 1
else:
return state, player
player = "Y" if player == "R" else "R"
return state, player
def test_01_import():
"""The library imports without error"""
if not os.path.exists(os.path.join(HERE, f"{MODULE_NAME}.py")):
return f"{MODULE_NAME}.py does not exist"
importlib.import_module(MODULE_NAME)
return None
def test_02_has_required_functions():
"""The library contains all required functions with correct spelling"""
required = {
"get_name()",
"check_move(piece, column, state)",
"check_win(state)",
"make_move(piece, state)"
}
module = importlib.import_module(MODULE_NAME)
fns = inspect.getmembers(module, inspect.isfunction)
sigs = {f"{name}{inspect.signature(obj)}" for name, obj in fns}
missing = required - sigs
return f"missing {', '.join(missing)}" if missing else None
def test_03_get_name():
"""get_name() returns a string with nonzero length"""
module = importlib.import_module(MODULE_NAME)
result = module.get_name()
err = None
if not isinstance(result, str):
err = "did not return a string"
elif len(result) < 1:
err = "returned string is zero length"
return err
def test_04_check_move_input():
"""check_move() accepts either a R or Y piece"""
module = importlib.import_module(MODULE_NAME)
r = module.check_move("R", 0, empty_state())
y = module.check_move("Y", 0, empty_state())
return "did not accept both piece types" if not all([r, y]) else None
def test_05_check_move_bounds():
"""check_move() rejects nonexistent columns"""
module = importlib.import_module(MODULE_NAME)
result_low = module.check_move("R", -1, empty_state())
result_high = module.check_move("R", COLS, empty_state())
err = None
if not isinstance(result_low, bool) or not isinstance(result_high, bool):
err = "wrong type returned"
elif result_low or result_high:
err = "allowed nonexistent column(s)"
return err
def test_06_check_move_empty():
"""check_move() accepts moves into partially-filled columns"""
module = importlib.import_module(MODULE_NAME)
err = None
# create a state with all but the top row filled with no winning combos
state = [
["-", "-", "-", "-", "-", "-", "-"],
["R", "Y", "R", "Y", "R", "Y", "R"],
["Y", "R", "Y", "R", "Y", "R", "Y"],
["Y", "R", "Y", "R", "Y", "R", "Y"],
["R", "Y", "R", "Y", "R", "Y", "R"],
["R", "Y", "R", "Y", "R", "Y", "R"],
]
# it is yellow's turn based on piece counts
results = [module.check_move("Y", c, copy.deepcopy(state)) for c in range(COLS)]
if not all(isinstance(r, bool) for r in results):
err = "wrong type returned (expected True or False)"
elif not all(results):
err = "one or more partially-filled columns was not accepted"
return err
def test_07_check_move_full():
"""check_move() rejects moves in full columns"""
module = importlib.import_module(MODULE_NAME)
err = None
results = [module.check_move("R", c, tied_state()) for c in range(COLS)]
if not all([isinstance(r, bool) for r in results]):
err = "wrong type returned"
elif any(results):
err = "move allowed with full board"
return err
def test_08_test_check_win_in_progress_empty():
"""check_win() detects a game is in progress (tested with empty board)."""
module = importlib.import_module(MODULE_NAME)
result = module.check_win(empty_state())
if not isinstance(result, str):
return "wrong type returned"
if result not in ("WIN", "TIE", "IN PROGRESS"):
return f"invalid response {result} (not WIN/TIE/IN PROGRESS)"
if result != "IN PROGRESS":
return f"incorrect response {result} (expected IN PROGRESS)"
return None
def test_09_test_check_win_in_progress():
"""check_win() detects a game is in progress (tested with 1 - 41 pieces)."""
module = importlib.import_module(MODULE_NAME)
tied = tied_state()
state = empty_state()
# copy one piece at a time from the tied state to an initially empty state
# from the bottom up.
i = 0
for r in reversed(range(ROWS)):
for c in range(COLS):
i += 1
if i == 42: # don't test a 42nd piece...would be a tie
return None
state[r][c] = tied[r][c]
result = module.check_win(state)
if not isinstance(result, str):
return "wrong type returned"
if result not in ("WIN", "TIE", "IN PROGRESS"):
return f"invalid response {result} (not WIN/TIE/IN PROGRESS)"
if result != "IN PROGRESS":
return f"incorrect response {result} (expected IN PROGRESS)"
def test_10_test_check_win_horiz():
"""check_win() detects all 24 possible horizontal wins"""
count = 0
module = importlib.import_module(MODULE_NAME)
for r in range(ROWS):
for c in range(COLS-3):
state = empty_state()
state[r][c] = state[r][c+1] = state[r][c+2] = state[r][c+3] = "R"
result = module.check_win(state)
if not isinstance(result, str):
return "wrong type returned"
if result not in ("WIN", "TIE", "IN PROGRESS"):
return f"invalid response {result} (not WIN/TIE/IN PROGRESS)"
if result == "WIN":
count += 1
return f"detected {count}/24 possible wins" if count != 24 else None
def test_11_test_check_win_vert():
"""check_win() detects all 21 possible vertical wins"""
count = 0
module = importlib.import_module(MODULE_NAME)
for r in range(ROWS-3):
for c in range(COLS):
state = empty_state()
state[r][c] = state[r+1][c] = state[r+2][c] = state[r+3][c] = "R"
result = module.check_win(state)
if not isinstance(result, str):
return "wrong type returned"
if result not in ("WIN", "TIE", "IN PROGRESS"):
return f"invalid response {result} (not WIN/TIE/IN PROGRESS)"
if result == "WIN":
count += 1
return f"detected {count}/21 possible wins" if count != 21 else None
def test_12_test_check_win_diag():
"""check_win() detects all 24 possible diagonal wins"""
count = 0
module = importlib.import_module(MODULE_NAME)
# descending
for r in range(ROWS-3):
for c in range(COLS-3):
st = empty_state()
st[r][c] = st[r+1][c+1] = st[r+2][c+2] = st[r+3][c+3] = "R"
result = module.check_win(st)
if not isinstance(result, str):
return "wrong type returned"
if result not in ("WIN", "TIE", "IN PROGRESS"):
return f"invalid response {result} (not WIN/TIE/IN PROGRESS)"
if result == "WIN":
count += 1
# ascending
for r in range(ROWS-3):
for c in range(COLS-3):
st = empty_state()
st[r+3][c] = st[r+2][c+1] = st[r+1][c+2] = st[r][c+3] = "R"
result = module.check_win(st)
if not isinstance(result, str):
return "wrong type returned"
if result not in ("WIN", "TIE", "IN PROGRESS"):
return f"invalid response {result} (not WIN/TIE/IN PROGRESS)"
if result == "WIN":
count += 1
return f"detected {count}/24 possible wins" if count != 24 else None
def test_13_check_win_tie():
"""check_win() detects a tie/draw"""
module = importlib.import_module(MODULE_NAME)
state = tied_state()
result = module.check_win(tied_state())
if not isinstance(result, str):
return "wrong type returned"
if result not in ("WIN", "TIE", "IN PROGRESS"):
return f"invalid response {result} (not WIN/TIE/IN PROGRESS)"
elif result != "TIE":
return f"incorrect response ({result}, expected TIE)"
return None
def test_14_make_move_operation():
"""make_move() makes legal moves for 10,000 randomly-generated games"""
n_tests = 10000
tied = tied_state()
count = 0
for _ in range(n_tests):
state, piece = rand_state()
module = importlib.import_module(MODULE_NAME)
result = module.make_move(piece, copy.deepcopy(state))
if not isinstance(result, int):
return f"wrong type returned after {count:,} legal moves"
if 0 <= result < COLS and state[0][result] == "-":
count += 1 # the move is legal
if count != n_tests:
return f"made {count:,} / {n_tests:,} legal moves"
else:
return None
def main():
global MODULE_NAME
MODULE_NAME = find_module_name()
if MODULE_NAME is None:
return
print()
print(f"Testing {MODULE_NAME}.py")
print()
print("HOW TO USE THIS TOOL")
print("====================")
print("Address any FAIL or ERROR results below from TOP TO BOTTOM!")
print("A failure on an earlier test can cause failures on later tests.")
print()
current_module = sys.modules[__name__]
fns = sorted(inspect.getmembers(current_module, inspect.isfunction))
fns = [fn for name, fn in fns if name.startswith("test")]
for test in fns:
try:
with OutputSuppressor():
err = test()
except:
err = "program crashed while running test"
result = "PASS" if not err else "FAIL"
print(f"{result}: {test.__doc__}")
if err:
print(f"\t{err}")
print()
print("End of tests.")
class OutputSuppressor:
"""Context manager to suppress stdout/stderr."""
def __enter__(self):
self._stdout = io.StringIO()
self._stderr = io.StringIO()
self._out_cm = redirect_stdout(self._stdout)
self._err_cm = redirect_stderr(self._stderr)
self._out_cm.__enter__()
self._err_cm.__enter__()
return self
def __exit__(self, exc_type, exc, tb):
self._err_cm.__exit__(exc_type, exc, tb)
self._out_cm.__exit__(exc_type, exc, tb)
return False # don't swallow exceptions
if __name__ == "__main__":
main()
Turn It In
- A screenshot of the output of the test program showing your test results.
How It's Graded
This lab is worth up to 4 points. One score covers everything you turn in.
| Score | What it looks like |
|---|---|
| 4 — Excellent | Your screenshot shows every test reporting PASS against your own library — including the 10,000-game make_move stress test, the row that humbles people. |
| 3 — Above Average | One FAIL remains in a single function, and the rest of the suite — imports, signatures, all 69 winning lines — passes clean. |
| 2 — Average | Several FAILs unaddressed, or failures fixed out of order so later results can't be trusted. |
| 1 — Below Average | The library never imports (everything after test 1 fails), or the screenshot doesn't show your library's name being tested. |
| 0 — Failing | No screenshot turned in. |