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#

  1. [ ] Install pytest using the instructions here.

  2. [ ] To make sure it works, type pytest --help in the terminal.

B. Configure VS Code#

  1. [ ] Configure VS Code by following the first two steps here. When you are prompted to select the directory containing the tests, choose . Root directory.

  2. [ ] 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
    
  3. [ ] 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.

  1. [ ] When you call main(), it must be under a if __name__ == "__main__" statement. Otherwise main() will be called when you do an import, and bad things will happen. (See Part 1.2 for more info.)

  2. [ ] 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#

  1. [ ] Create a new file named test_game.py

  2. [ ] In it, import your module with something like import adventure

  3. [ ] Write a function called test_truth()

    • [ ] In it, write a simple assert statement assert True

  4. [ ] Run your test by typing pytest -v test_game.py in the terminal.

Code
Listing 730 test_game.py#
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.

Listing 731 pyproject.toml#
[tool.pytest.ini_options]
addopts = "-v"

E. Run tests in vscode#

  1. [ ] Click the Testing icon (that looks like a test beaker)

  2. [ ] You may need to click the Refresh Tests icon (that looks like a arrow in a circle) to find your tests.

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

  1. [ ] Modify the line where you import your game module to instead just import is_for_sale which should look something like from adventure import is_for_sale.

  2. [ ] Write a function called test_is_for_sale().

  3. [ ] Make a fake item to pass to is_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 variable fake_item.

  4. [ ] Call is_for_sale() with the argument fake_item and assign it to the variable result.

  5. [ ] Write an assert statement that checks if result is truthy, and has a failure message like:

    "is_for_sale() should return True if the item has a price"

  6. [ ] Run your test, either at the command line or in VS Code.

Code
Listing 732 test_game.py#
 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.

  1. [ ] Write a function called test_is_for_sale_without_price().

  2. [ ] Make a fake item to pass to is_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 variable fake_item.

  3. [ ] Call is_for_sale() with the argument fake_item and assign it to the variable result.

  4. [ ] Write an assert statement that checks if result is falsy, and has a failure message like:

  5. [ ] 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
Listing 733 test_game.py#
 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.

  1. [ ] Add error to your import line, something like: from adventure import is_for_sale, error.

  2. [ ] Add a test_error() function with the parameter capsys.

  3. [ ] Call error() with any message you like

  4. [ ] Assign the results of capsys.readouterr().out to the variable output

  5. [ ] Write an assert statement that output equals what you expect to be printed, with a failure message like:

  6. [ ] Run your tests, either at the command line or in VS Code.

    "The formatted error message should be printed."

Code
Listing 734 test_game.py#
 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()#

  1. [ ] Import the debug 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.

  2. [ ] Add a test_debug() function with the parameter capsys.

  3. [ ] Call debug() with any message you like

  4. [ ] Assign the results of capsys.readouterr().out to the variable output

  5. [ ] Write an assert statement that output equals what you expect to be printed, with a failure message like:

    "The formatted debug message should be printed."

  6. [ ] 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
Listing 735 test_game.py#
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()
Listing 736 test_game.py#
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()
Listing 737 test_game.py#
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.

Listing 738 test_game.py#
 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"].

  1. [ ] Import the inventory_change function.

  2. [ ] Add a test_inventory_change() function

  3. [ ] Add a key of your choice to adventure.PLAYER["inventory"] with whatever quantity you’d like.

  4. [ ] Call inventory_change() with your new key as the argument

  5. [ ] Write an assert 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."

  6. [ ] Run your tests, either at the command line or in VS Code.

Code
Listing 739 test_game.py#
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.

  1. [ ] Add a test_teardown() function

  2. [ ] Add an assert statement that the key you added in the previous test is not in PLAYER["inventory"] with a failure message like:

    "Each test should start with a fresh data set."

  3. [ ] Run your tests, either at the command line or in VS Code.

Code
Listing 740 test_game.py#
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.

  1. [ ] Add a test_inventory_change_missing_key() function

  2. [ ] Set adventure.PLAYER["inventory"] to an empty dictionary.

  3. [ ] Call inventory_change() with the key of your choice.

  4. [ ] Write an assert statement that checks your new key is in the inventory dictionary.

    "inventory_change() should add missing keys to the inventory"

  5. [ ] Write an assert statement that checks that the quantity for that key is now equal to 1. Give it a failure message like:

    "inventory_change() with no quantity argument should add 1."

  6. [ ] Run your tests, either at the command line or in VS Code.

Code
Listing 741 test_game.py#
 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?

  1. [ ] inventory_change_subtract() should use a negative number for the quantity argument and assert that that amount was subtracted from inventory

  2. [ ] inventory_change_remove() should use a negative number for the quantity argument so that there will be 0 left and assert that the key was removed from inventory

test_inventory_change_subtract()
Listing 742 test_game.py#
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()
Listing 743 test_game.py#
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.

  1. [ ] Import the functions do_drop, player_has and place_has

  2. [ ] Add the function test_do_drop_no_args() with the parameter capsys

  3. [ ] Call do_drop() with an empty list for an argument

  4. [ ] Assign the results of capsys.readouterr().out to the variable output

  5. [ ] Write an assert statement that the appropiate debug message is in output

  6. [ ] Write an assert statement that the appropiate error message is in output

  7. [ ] 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
Listing 744 test_game.py#
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.

  1. [ ] Add the function test_do_drop_missing_item() with the parameter capsys

  2. [ ] Set adventure.PLAYER["inventory"] to an empty dictionary.

  3. [ ] Call do_drop() with a list containing an any string as an argument

  4. [ ] Assign the results of capsys.readouterr().out to the variable output

  5. [ ] Write an assert statement that the appropriate debug message is in output

  6. [ ] Write an assert statement that the appropriate error message is in output

  7. [ ] Run your tests, either at the command line or in VS Code.

Code
Listing 745 test_game.py#
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.

  1. [ ] Add the function test_do_drop() with the parameter capsys

  2. [ ] Call the inventory_change() function with the key of your choice to add a fake item to inventory

  3. [ ] Call do_drop() with a list containing the key as an argument

  4. [ ] Assign the results of capsys.readouterr().out to the variable output

  5. [ ] Write an assert statement that the appropriate debug message is in output

  6. [ ] Write an assert statement that the appropriate message is in output

  7. [ ] Write an assert statement that calls the place_has() function with the key to make sure the item was added to the place.

  8. [ ] Write an assert statement that calls the player_has() function with the key to make sure the item is not in inventory.

  9. [ ] Run your tests, either at the command line or in VS Code.

Code
Listing 746 test_game.py#
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.