Skip to content

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

  1. Save the program below as test_connect4.py in the same folder as your Connect4 Python (.py) file. Use the copy button in the corner of the code block.

  2. 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.)

  3. 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

test_connect4.py
"""
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.