Part 11: Test things
Contents
Part 11: Test things#
In this section we’ll add a read command, which the player will use to read a clue from the book.
Since our program is starting to get complicated, we’ll also start writing tests. This will help us find out if we break something, even if we don’t happen to play the part of the game that triggers it. Be sure to do the Testing Lesson if you haven’t already.
Part 11.1: Setup#
In this section we’ll get a basic test up and running.
Demo
A. Install pytest
#
[ ]
Installpytest
using the instructions here.[ ]
To make sure it works, typepytest --help
in the terminal.
B. Configure VS Code#
[ ]
Configure VS Code by following the first two steps here. When you are prompted to select the directory containing the tests, choose . Root directory.[ ]
Install the extension Python Test Explorer for Visual Studio Code. either from the marketplace or by typing the following into the terminal.code --install-extension littlefoxteam.vscode-python-test-adapter
[ ]
You may need to restart vscode.
C. Prepare for import#
In order to run tests, we’ll need to treat our game like a module. In order for that to work, we’ll need to make sure of a couple of things.
[ ]
When you callmain()
, it must be under aif __name__ == "__main__"
statement. Otherwisemain()
will be called when you do an import, and bad things will happen. (See Part 1.2 for more info.)[ ]
The filename for your game must be all lowercased with no spaces or other special characters except for underscores (_
). Rename your file if you need to.Good:
adventure_game.py
game.py
Bad:
Adventure Game.py
My-Game.py
D. Create and run tests#
[ ]
Create a new file namedtest_game.py
[ ]
In it, import your module with something likeimport adventure
[ ]
Write a function calledtest_truth()
[ ]
In it, write a simple assert statementassert True
[ ]
Run your test by typingpytest -v test_game.py
in the terminal.
Code
1import adventure
2
3def test_truth():
4 assert True
You can add the following to your pyproject.toml
to always run pytest
in verbose mode.
[tool.pytest.ini_options]
addopts = "-v"
E. Run tests in vscode#
[ ]
Click the Testing icon (that looks like a test beaker)[ ]
You may need to click the Refresh Tests icon (that looks like a arrow in a circle) to find your tests.[ ]
Click the Run Tests icon (that looks like a play button).
Part 11.2: Test is_for_sale()
#
For our first real test, we’ll start with something simple. Let’s test the
is_for_sale()
function.
To do this, we’ll call the is_for_sale()
function with a fake item then check
the result with an assert
statement.
Demo
A. Define test_is_for_sale()
#
This first test will make sure that when is_for_sale()
is called with an item
with a "price"
key it returns True
.
[ ]
Modify the line where you import your game module to instead just importis_for_sale
which should look something likefrom adventure import is_for_sale
.[ ]
Write a function calledtest_is_for_sale()
.[ ]
Make a fake item to pass tois_for_sale()
.We know that an item is a dictionary, and that
is_for_sale()
checks to see if the dictionary has a"price"
key. So all you really need for your fake item is a dictionary with a"price"
key–for our purposes, it doesn’t even matter what the value is. But to make it a little more clear, we’ll add a"name"
as well.Make a dictionary with
"name"
and"price"
keys and assign it to the variablefake_item
.[ ]
Callis_for_sale()
with the argumentfake_item
and assign it to the variableresult
.[ ]
Write anassert
statement that checks ifresult
is truthy, and has a failure message like:"is_for_sale() should return True if the item has a price"
[ ]
Run your test, either at the command line or in VS Code.
Code
1from adventure import is_for_sale
2
3def test_is_for_sale():
4 fake_item = {
5 "name": "An Expensive Thing",
6 "price": "a lot",
7 }
8
9 result = is_for_sale(fake_item)
10
11 assert result, "is_for_sale() should return True if the item has a price"
12
B. Define test_is_for_sale_without_price()
#
We want to make sure to test the opposite condition as well – that when an
item doesn’t have a "price"
key, is_for_sale()
returns False
.
[ ]
Write a function calledtest_is_for_sale_without_price()
.[ ]
Make a fake item to pass tois_for_sale()
.This time all that matters is that it is a dictionary without a
"price"
key. But like before, let’s include a"name"
key for the sake of clarity.Make a dictionary with a
"name"
key and assign it to the variablefake_item
.[ ]
Callis_for_sale()
with the argumentfake_item
and assign it to the variableresult
.[ ]
Write anassert
statement that checks ifresult
is falsy, and has a failure message like:[ ]
Run your tests, either at the command line or in VS Code."is_for_sale() should return False if the item doesn't have a price"
Code
1from adventure import is_for_sale
2
3def test_is_for_sale():
4 fake_item = {
5 "name": "An Expensive Thing",
6 "price": "a lot",
7 }
8
9 result = is_for_sale(fake_item)
10
11 assert result, "is_for_sale() should return True if the item has a price"
12
13def test_is_for_sale_without_price():
14 fake_item = {
15 "name": "A Priceless Thing",
16 }
17
18 result = is_for_sale(fake_item)
19
20 assert not result, \
21 "is_for_sale() should return False if the item doesn't have a price"
Part 11.3: Test error()
#
Let’s add another easy test, this time of the error()
function.
Conceptually this will entail calling the error()
function, then check what
is printed to make sure it is what we expect.
Demo
Tip
The console module automatically detects if the code is being run by another program (say, from a test) and disables colors and effects if so. This is handy, so you won’t have to account for those escape code characters in your tests.
A. Define test_error()
#
With is_for_sale()
we were able to check the value returned by the function.
This is typically how functions and unit tests should be written.
Since our game is interactive though, we’ve been printing rather than returning a lot of the time. To test functions like that we’ll have to take an extra step of capturing the output–that is, sending the text somewhere we can access it intead of actually printing it.
To do this we’ll use a special object provided by pytest called capsys
to
capture the printed output. After any code that prints we can call the
.readouterr()
method to retrieve the captured output, and access it via the
.out
property of the resulting object.
[ ]
Adderror
to your import line, something like:from adventure import is_for_sale, error
.[ ]
Add atest_error()
function with the parametercapsys
.[ ]
Callerror()
with any message you like[ ]
Assign the results ofcapsys.readouterr().out
to the variableoutput
[ ]
Write an assert statement thatoutput
equals what you expect to be printed, with a failure message like:[ ]
Run your tests, either at the command line or in VS Code."The formatted error message should be printed."
Code
1from adventure import error, is_for_sale
2
3def test_is_for_sale():
4 fake_item = {
5 "name": "An Expensive Thing",
6 "price": "a lot",
7 }
8
9 result = is_for_sale(fake_item)
10
11 assert result, "is_for_sale() should return True if the item has a price"
12
13def test_is_for_sale_without_price():
14 fake_item = {
15 "name": "A Priceless Thing",
16 }
17
18 result = is_for_sale(fake_item)
19
20 assert not result, \
21 "is_for_sale() should return False if the item doesn't have a price"
22
23def test_error(capsys):
24 error("You ruined everything.")
25 output = capsys.readouterr().out
26
27 assert output == "! Error You ruined everything.\n\n", \
28 "The formatted error message should be printed."
Part 11.4: Test debug()
#
This should be very similar to test_error()
.
Demo
A. Define test_debug()
#
[ ]
Import thedebug
function.Since your import line is starting to get long, you might want to break it up onto multiple lines. Add an open parenthesis before the first function name, then put each function on its own line, ending with the closed parenthesis on a its own line.
[ ]
Add atest_debug()
function with the parametercapsys
.[ ]
Calldebug()
with any message you like[ ]
Assign the results ofcapsys.readouterr().out
to the variableoutput
[ ]
Write an assert statement thatoutput
equals what you expect to be printed, with a failure message like:"The formatted debug message should be printed."
[ ]
Run your tests, either at the command line or in VS Code.
Code
1from adventure import (
2 debug,
3 error,
4 header,
5 is_for_sale,
6 write,
7)
8
36def test_debug(capsys):
37 debug("Have some cake.")
38
39 output = capsys.readouterr().out
40 assert output == "# Have some cake.\n", \
41 "The formatted debug message should be printed."
Part 11.5: Test header()
and write()
#
Can you write tests for the header()
and write()
functions on your own?
Demo
header()
43def test_header(capsys):
44 header("Headline")
45
46 output = capsys.readouterr().out
47 assert output == "\n Headline\n\n", \
48 "The formatted header should be printed."
49
50
write()
51def test_write(capsys):
52 write("oh hai")
53
54 output = capsys.readouterr().out
55 assert output == " oh hai\n", \
56 "write() should print the indented text followed by a new line"
Part 11.6: Test inventory_change()
#
In this section we’ll write a test for the first function that changes the game state.
Demo
A. Add teardown#
Much of our code cares about or interacts with the state of the game–that is,
the data we have stored in PLAYER
, PLACES
and ITEMS
.
When writing tests that modify state, it’s important to make sure that each test is fully independent–that is, each test should be able to be run alone or alongside other tests in any order. One component of that is making sure that each test starts with a clean slate.
For this section we’ll be adding teardown code–that is, code that will cleanup after each test. Our code will save copies of our three state dictionaries before anything is changed. Then in the teardown function we’ll copy those dictionaries back into our module.
To get the teardown function to run after each test we’ll need to jump through a few extra hoops, by taking advantage of some advanced pytest features. It’s not important that you understand this right now–you can go ahead and just copy the relevant code into your test file.
1from copy import deepcopy
2
3import pytest
4
5import adventure
6from adventure import (
7 debug,
8 error,
9 header,
10 inventory_change,
11 is_for_sale,
12 write,
13)
14
15
16PLAYER_STATE = deepcopy(adventure.PLAYER)
17PLACES_STATE = deepcopy(adventure.PLACES)
18ITEMS_STATE = deepcopy(adventure.ITEMS)
19DEBUG_STATE = True
20
21
22def revert():
23 """Revert game data to its original state."""
24 adventure.PLAYER = deepcopy(PLAYER_STATE)
25 adventure.PLACES = deepcopy(PLACES_STATE)
26 adventure.ITEMS = deepcopy(ITEMS_STATE)
27 adventure.DEBUG = DEBUG_STATE
28
29
30@pytest.fixture(autouse=True)
31def teardown(request):
32 """Auto-add teardown method to all tests."""
33 request.addfinalizer(revert)
34
B. Define test_inventory_change()
#
Another rule of thumb when writing tests that deal with state is that the test should assume as little as possible.
For example, I happen to have PLAYER["inventory"]["gems"]
set to 50
right
now. So I could write this test to call inventory_change("gems")
then check
that PLAYER["inventory"]["gems"] == 51
. The problem is, if I change my code
later to start with 0
gems, or rename gems to coins this test would fail even
though the the inventory_change()
function would still work just fine.
So the first thing we’ll do is add some fake data to PLAYER["inventory"]
.
[ ]
Import theinventory_change
function.[ ]
Add atest_inventory_change()
function[ ]
Add a key of your choice toadventure.PLAYER["inventory"]
with whatever quantity you’d like.[ ]
Callinventory_change()
with your new key as the argument[ ]
Write anassert
statement that checks that the quantity for that key is now equal to whatever it was before plus one. Give it a failure message like:"inventory_change() with no quantity argument should add 1."
[ ]
Run your tests, either at the command line or in VS Code.
Code
86def test_inventory_change():
87 adventure.PLAYER["inventory"]["problems"] = 99
88 inventory_change("problems")
89
90 assert adventure.PLAYER["inventory"]["problems"] == 100, \
91 "inventory_change() with no quantity argument should add 1."
92
C. Define test_teardown()
#
In this test we’ll make sure that the teardown code from part A is working.
[ ]
Add atest_teardown()
function[ ]
Add anassert
statement that the key you added in the previous test is not inPLAYER["inventory"]
with a failure message like:"Each test should start with a fresh data set."
[ ]
Run your tests, either at the command line or in VS Code.
Code
86def test_inventory_change():
87 adventure.PLAYER["inventory"]["problems"] = 99
88 inventory_change("problems")
89
90 assert adventure.PLAYER["inventory"]["problems"] == 100, \
91 "inventory_change() with no quantity argument should add 1."
92
93def test_teardown():
94 assert "problems" not in adventure.PLAYER["inventory"], \
95 "Each test should start with a fresh data set."
96
97
D. Define test_inventory_change_missing_key()
#
Now we’ll add another test case for inventory_change()
. This one will make
sure that the function works even when the key is not already in the inventory
dictionary.
[ ]
Add atest_inventory_change_missing_key()
function[ ]
Setadventure.PLAYER["inventory"]
to an empty dictionary.[ ]
Callinventory_change()
with the key of your choice.[ ]
Write anassert
statement that checks your new key is in the inventory dictionary."inventory_change() should add missing keys to the inventory"
[ ]
Write anassert
statement that checks that the quantity for that key is now equal to1
. Give it a failure message like:"inventory_change() with no quantity argument should add 1."
[ ]
Run your tests, either at the command line or in VS Code.
Code
98def test_inventory_change_missing_key():
99 adventure.PLAYER["inventory"] = {}
100 inventory_change("brain")
101
102 assert "brain" in adventure.PLAYER["inventory"], \
103 "inventory_change() should add missing keys to the inventory"
104
105 assert adventure.PLAYER["inventory"]["brain"] == 1, \
106 "inventory_change() should add to zero if key was missing"
107
108
E. Additional test cases#
Can you add the next two tests on your own?
[ ]
inventory_change_subtract()
should use a negative number for the quantity argument and assert that that amount was subtracted from inventory[ ]
inventory_change_remove()
should use a negative number for the quantity argument so that there will be0
left and assert that the key was removed from inventory
test_inventory_change_subtract()
109def test_inventory_change_subtract():
110 adventure.PLAYER["inventory"]["bottles of beer"] = 99
111 inventory_change("bottles of beer", -1)
112
113 assert adventure.PLAYER["inventory"]["bottles of beer"] == 98, \
114 "inventory_change() should reduce inventory when qty is negative"
115
116
test_inventory_change_remove()
117def test_inventory_change_remove():
118 adventure.PLAYER["inventory"]["chances"] = 1
119 inventory_change("chances", -1)
120
121 assert "chances" not in adventure.PLAYER["inventory"], \
122 "inventory_change() should remove the item when there are none left"
Part 11.7: Test do_drop()
#
Now that we’ve learned how to write tests in general, how to test captured output, and how to safely test functions that change state, we can finally write a test for one of our command functions.
We’ll start with the do_drop()
function.
Demo
Caution
We’re going to use the inventory_change()
, player_has()
, and place_has()
functions to help in our do_drop()
tests. It would be a good idea to write
tests for player_has()
and place_has()
first, that way you know that those
functions work reliably before using them in another test.
I encourage you to work on those. However, we won’t be going into those tests in detail here.
A. Define test_do_drop_no_args()
#
The first test case is very simple. We want to check that if the user didn’t type anything, they get the appropriate error message.
[ ]
Import the functionsdo_drop
,player_has
andplace_has
[ ]
Add the functiontest_do_drop_no_args()
with the parametercapsys
[ ]
Calldo_drop()
with an empty list for an argument[ ]
Assign the results ofcapsys.readouterr().out
to the variableoutput
[ ]
Write an assert statement that the appropiate debug message is inoutput
[ ]
Write an assert statement that the appropiate error message is inoutput
[ ]
Run your tests, either at the command line or in VS Code.
Code
1from copy import deepcopy
2
3import pytest
4
5import adventure
6from adventure import (
7 debug,
8 do_drop,
9 error,
10 header,
11 inventory_change,
12 is_for_sale,
13 place_has,
14 player_has,
15 write,
16)
17
18
128def test_do_drop_no_args(capsys):
129 do_drop([])
130 output = capsys.readouterr().out
131
132 assert "Trying to drop: []" in output, \
133 "Debug message should be in output"
134 assert "Error What do you want to drop?" in output, \
135 "User error should be in output"
136
137
B. Define test_do_drop_missing_item()
#
This test will check that if the player tries to drop something they don’t have in inventory, they they get the appropriate error message.
[ ]
Add the functiontest_do_drop_missing_item()
with the parametercapsys
[ ]
Setadventure.PLAYER["inventory"]
to an empty dictionary.[ ]
Calldo_drop()
with a list containing an any string as an argument[ ]
Assign the results ofcapsys.readouterr().out
to the variableoutput
[ ]
Write an assert statement that the appropriate debug message is inoutput
[ ]
Write an assert statement that the appropriate error message is inoutput
[ ]
Run your tests, either at the command line or in VS Code.
Code
138def test_do_drop_missing_item(capsys):
139 adventure.PLAYER["inventory"] = {}
140 do_drop(["anything"])
141
142 output = capsys.readouterr().out
143
144 assert "Trying to drop: ['anything']" in output, \
145 "Debug message should be in output"
146
147 assert "You don't have any 'anything'" in output, \
148 "User error should be in output"
149
150
C. Define test_do_drop()
#
This test will successfully drop something, then make sure that it was added to the place and removed from inventory.
[ ]
Add the functiontest_do_drop()
with the parametercapsys
[ ]
Call theinventory_change()
function with the key of your choice to add a fake item to inventory[ ]
Calldo_drop()
with a list containing the key as an argument[ ]
Assign the results ofcapsys.readouterr().out
to the variableoutput
[ ]
Write an assert statement that the appropriate debug message is inoutput
[ ]
Write an assert statement that the appropriate message is inoutput
[ ]
Write an assert statement that calls theplace_has()
function with the key to make sure the item was added to the place.[ ]
Write an assert statement that calls theplayer_has()
function with the key to make sure the item is not in inventory.[ ]
Run your tests, either at the command line or in VS Code.
Code
151def test_do_drop(capsys):
152 inventory_change("mic")
153
154 do_drop(["mic"])
155 output = capsys.readouterr().out
156
157 assert "Trying to drop: ['mic']" in output, \
158 "Debug message should be in output"
159
160 assert "You set down the mic" in output, \
161 "User message should be in output"
162
163 assert place_has("mic"), \
164 "The dropped item should be in the place"
165
166 assert not player_has("mic"), \
167 "The dropped item should not be in inventory"
And the rest#
We’ve held off on writing tests until now because I felt you needed more experience coding before adding another potentially confusing component. Ideally though, you want to write your tests at the same time you write the corresponding code. As you can see, back-filling can be a drudge.
Unless you’re excited about writing all of your tests now, I’d recommend adding a test at the beginning of each coding session and whenever something breaks.
Going forward we’ll be writing tests for all of our new code, and running them regularly.