For a program as complex as a game, you must have organized your code into functions and/or classes. To test these functions/classes you should write a test program.
This is a minesweeper game that I have laying around.
"""Minesweeper game. Find all safe squares while avoiding bombs."""
import random
import time
import datetime
import tkinter as tk
from tkinter import messagebox
from collections import defaultdict
class Cell(tk.Button):
"""A button for the Minsweeper game. Press me to reveal a bomb or
information about surrounding bombs.
"""
def __init__(self, parent, row, column, count):
super().__init__(
parent, text=" ", width=2, relief=tk.RAISED, command=self.press
)
self.parent = parent
self.row = row
self.column = column
self.count = count
self.pressed = False
self.flagged = False
self.bind("<Button-3>", self.flag)
def press(self):
"""Button pressed. Show bomb info"""
if not self.pressed:
self.configure(relief=tk.SUNKEN)
self.pressed = True
self["text"] = "X" if self.count < 0 else str(self.count)
self.parent.clear_cell(self)
def flag(self, _):
"""Right mouse button pressed. Toggle flag marker"""
if not self.pressed:
self.flagged = not self.flagged
self["text"] = "F" if self.flagged else " "
class Board(tk.Tk):
"""Minesweeper playing surface. I make a grid of buttons that are pressed
to reveal bombs or information about surrounding bombs.
"""
neighbors = ((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1))
def __init__(self, rows, columns, bomb_count):
super().__init__()
self.title("Minesweeper")
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.rows = rows
self.columns = columns
self.safe_count = rows * columns - bomb_count
# Make all the bombs
bombs = defaultdict(lambda: 0)
for index in random.sample(range(rows * columns), k=bomb_count):
bombs[(index // columns, index % columns)] = 1
# Make the map
self.cells = []
for row in range(rows):
for column in range(columns):
# Count bombs in surrounding cells. Use -1 to indicate
# cell contains a bomb.
if bombs[(row, column)]:
count = -1
else:
count = sum(bombs[(row + r, column + c)] for c, r in self.neighbors)
cell = Cell(self, row, column, count)
cell.grid(row=row, column=column)
self.cells.append(cell)
# Add status display to bottom of window
self.done = False
self.status = tk.Label(self, text="")
self.status.grid(row=rows, column=0, columnspan=columns)
self.start_time = time.time()
self.update_status()
def cell(self, row, column):
"""Return cell at (row, column)"""
if 0 <= row < self.rows and 0 <= column < self.columns:
return self.cells[row * self.columns + column]
raise IndexError
def clear_cell(self, cell):
"""Cell was selected"""
if cell.count < 0:
self.done = True
messagebox.showinfo("Today marks the passing of you.")
else:
self.safe_count -= 1
if self.safe_count <= 0:
self.done = True
messagebox.showinfo("Winner, winner, chicken dinner!")
elif cell.count == 0:
# All surrounding cells are save. Open them
for c, r in self.neighbors:
try:
self.cell(cell.row + r, cell.column + c).press()
except IndexError:
pass
def update_status(self):
"""Update status display"""
delta = str(datetime.timedelta(seconds=int(time.time() - self.start_time)))
self.status["text"] = f"{delta} Remaining {self.safe_count}"
if not self.done:
self.after(1000, self.update_status)
Board(10, 10, 10).mainloop()
The first step to writing a test is take the code on the bottom of the file and reorganize so it is not run when the file is imported in a test program.
if __name__ == "__main__":
Board(10, 10, 10).mainloop()
If this was more than a single line I would write a main() function.
def main():
board = Board(10, 10, 10)
board.mainloop()
if __name__ == "__main__":
main()
Now I can import my game module into my test program. Lets say I was having a problem with the Cell class not showing the correct count. I can write a program that creates a cell and tests the functions.
test.py
import tkinter as tk
from minesweeper import Cell
# Need to make a tkinter root window to test Cell
root = tk.Tk()
# Make a cell that has a bomb. Press the cell and verify
# the button shows "X"
cell = Cell(root, 1, 1, -1)
cell.press()
print(cell["text"])
When I run the program I get an error.
Error:
AttributeError: '_tkinter.tkapp' object has no attribute 'clear_cell'
My Cell clas calls parent.clear_cell(). My test program needs to make a "test fixture" that provides this functionality.
import tkinter as tk
from minesweeper import Cell
# Need to make a tkinter root window to test Cell
class TestWindow(tk.Tk):
def __init__(self):
super().__init__()
self.cell = Cell(self, 1, 1, 0)
def clear_cell(self, cell):
"""Dummy method to make Cell happy."""
def test(self, count):
"""Veify that Cell displays the correct text when pressed."""
self.cell.count = count
self.cell.pressed = False
self.cell.press()
expected_text = "X" if count < 0 else str(count)
return self.cell["text"] == expected_text
testWindow = TestWindow()
for i in range(-1, 9):
print("Test", i, testWindow.test(i))
Output:
Test -1 True
Test 0 True
Test 1 True
Test 2 True
Test 3 True
Test 4 True
Test 5 True
Test 6 True
Test 7 True
Test 8 True
All my tests passed. If I am having problems with the button labels, it must be something outside Cell.
While testing I decide to write a test to verify Cell.flag() works.
# Need to make a tkinter root window to test Cell
class TestWindow(tk.Tk):
def __init__(self):
super().__init__()
self.cell = Cell(self, 1, 1, 0)
def testFlag(self, flag):
"""Veify that toggling the flag works."""
self.cell.pressed = False
self.cell.flagged = flag == "F"
self.cell["text"] = flag
self.cell.flag(None)
expected_text = " " if flag == "" else "F"
return self.cell["text"] == expected_text
testWindow = TestWindow()
print('testFlag("F")', testWindow.testFlag("F"))
print('testFlag(" ")', testWindow.testFlag(" "))
Output:
testFlag("F") True
testFlag(" ") True
I like this way of testing. I do some research and see there are python packages to simplify writing tests. I find something called unittest that is included with my python distribution.
https://docs.python.org/3/library/unittest.html
I restructure my test code to be compatible with unittest.
import tkinter as tk
from minesweeper import Cell
import unittest
# Need to make a tkinter root window to test Cell
class TestWindow(tk.Tk):
def __init__(self):
super().__init__()
self.withdraw()
self.cell = Cell(self, 1, 1, 0)
def clear_cell(self, cell):
"""Dummy method to make Cell happy."""
def set_count(self, count):
self.cell.count = count
self.cell.pressed = False
self.cell.press()
return self.cell["text"]
def toggle_flag(self, flag):
self.cell.pressed = False
self.cell.flagged = flag == "F"
self.cell["text"] = flag
self.cell.flag(None)
return self.cell["text"]
class CellTestCase(unittest.TestCase):
def setUp(self):
self.window = TestWindow()
def tearDown(self):
self.window.destroy()
def test_cell_press(self):
self.assertEqual(self.window.set_count(-1), "X")
for i in range(8):
self.assertEqual(self.window.set_count(i), str(i))
def test_toggle_flag(self):
self.assertEqual(self.window.toggle_flag("F"), " ")
self.assertEqual(self.window.toggle_flag(" "), " ")
I run the unittest from the commandline.
Output:
(venv)>python -m unittest test_cell
.F
======================================================================
FAIL: test_toggle_flag (test_cell.CellTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "...test_cell.py", line 47, in test_toggle_flag
self.assertEqual(self.window.toggle_flag(" "), " ")
AssertionError: 'F' != ' '
- F
The test find the error I intentionally left in the test code. I correct the test and run again.
Output:
(venv)>python -m unittest test_cell
..
----------------------------------------------------------------------
Ran 2 tests in 0.162s
OK
Another popular testing framework is pytest.
https://docs.pytest.org/en/7.4.x/
I change my test program to be compatible with pytest.
import tkinter as tk
from minesweeper import Cell
import pytest
# Need to make a tkinter root window to test Cell
class Window(tk.Tk):
def __init__(self):
super().__init__()
self.withdraw()
self.cell = Cell(self, 1, 1, 0)
def clear_cell(self, cell):
"""Dummy method to make Cell happy."""
def set_count(self, count):
self.cell.count = count
self.cell.pressed = False
self.cell.press()
return self.cell["text"]
def toggle_flag(self, flag):
self.cell.pressed = False
self.cell.flagged = flag == "F"
self.cell["text"] = flag
self.cell.flag(None)
return self.cell["text"]
@pytest.fixture
def window():
return Window()
def test_cell_press(window):
assert window.set_count(-1) == "X"
for i in range(8):
assert window.set_count(i) == str(i)
def test_toggle_flag(window):
assert window.toggle_flag("F") == " "
assert window.toggle_flag(" ") == " "
Notice I put the error back in the test_togle_flag test.
I run the test.
Output:
(venv)>pytest
=========================================================== test session starts ===========================================================
platform win32 -- Python 3.10.7, pytest-7.4.2, pluggy-1.3.0
rootdir: C:\Users\hystadd\Documents\python\sandbox\games
plugins: anyio-3.6.1
collected 2 items
test_cell.py .F [100%]
================================================================ FAILURES =================================================================
____________________________________________________________ test_toggle_flag _____________________________________________________________
window = <test_cell.Window object .>
def test_toggle_flag(window):
assert window.toggle_flag("F") == " "
> assert window.toggle_flag(" ") == " "
E AssertionError: assert 'F' == ' '
E Strings contain only whitespace, escaping them using repr()
E - ' '
E + 'F'
test_cell.py:43: AssertionError
========================================================= short test summary info =========================================================
FAILED test_cell.py::test_toggle_flag - AssertionError: assert 'F' == ' '
======================================================= 1 failed, 1 passed in 0.41s =======================================================