The Quest for Reliable Code (Part 1.1)
Practical examples to help you explore Software Testing in Python
Ready to look at some illustrative examples of software tests? I hope you remember what we discussed previously, because it is essential that you know the theory before we get into code.
With that out of the way, let’s get started with learning about testing in Python.
🔗 The Project
For the purpose of this demonstration, we’ll be using a basic URL shortener project powered by fastapi and peewee.
One of the best libraries out there when it comes to testing in Python is Pytest, and that is what we will use.
🧪 The Tests
🔬
1: Act and Assert
Let’s start simple. Here’s a function:
And here is the file that tests it:
And here’s the explanation:
- For Pytest to detect your functions as tests, both the file name and the function name should start with the prefix “
test_
” - We create a class to group the actual functions, so that the scope is clearer. Each class can be thought of as testing one unit of code, whether that be a class or a function.
- Each function under the
TestRandomString
class verifies thegenerate_random_string
function’s behavior under different input conditions. - The lines marked with a
# 🔨
comment form the portion of the test where the unit under scrutiny is actually executed. Hence, this is called the Act phase. - The lines marked with a
# 🔍
comment form the portion of the test where the output of the Act phase is compared against the expected output (or, in some cases, we simply assert that an exception has been raised). This, fittingly thanks to the Pytest syntax, is called the Assert or Expect phase.
🔬
2: Arrange and Assassinate
Let’s ramp things up a bit. Here’s a class that provides a simple interface to store and retrieve objects from an SQLite database:
We’ll go through the tests for this class one by one. The obvious thing to test is whether this class can create, store and then retrieve a given link. Let’s see the test for that:
- The Act 🔨 and Assert 🔍 phases are the same as before
- However, in order for the Act phase to function properly, we need to set up some things beforehand. The lines are marked with a
# 🏗️
comment, and called the Arrange or Build phase. Generally, in the Arrange phase, we set up the inputs and dependencies of the unit under scrutiny. We assume that the operations in the Arrange phase will work as intended, and are already being tested elsewhere. - Sometimes, after the Assert phase, there are things that the developer needs to clean up or tear down: tempfiles, sessions, buffers, etc. This phase is called the Assassinate or Destory phase, indicated in the above snippet by a
# 🔪
comment.
Hence, in total there are four major phases of a unit test:
Arrange → Act → Assert → Assassinate
🔬
3: Fixtures
Oftentimes, some portions of the Arrange and Assassinate phases are used in multiple tests, and most testing libraries have some implementation that allows so-called Set-Up or Tear-Down subroutines that will be executed before or after test execution, respectively.
Pytest combines Set-Ups and Tear-Downs into functions called “fixtures”. Here’s an example:
- A fixture is defined by decorating any top-level function with
@pytest.fixture
. One way to use a fixture is to decorate the test function with@pytest.mark.usefixtures("your_fixture_name")
. - All the code before the
yield
statement in your fixture will be executed before the test starts, thus forming a part of the Arrange phase. - All the code after the
yield
statement in your fixture will be executed after the test starts, thus forming a part of the Assassinate phase.
Fixtures can be declared to be scoped to the function, class, module, package or session. What this means is that you can tell Pytest to execute the fixture once for each function, class, module, package or session. Thus a time-consuming setup task can be delegated to a session-scoped fixture, saving time and resources.
🔬
4: Fixtures 2— Electric Boogaloo
Two more quick facts about fixtures:
- Fixtures can yield (i.e. return) values! To receive the value yielded by a fixture, it can be declared as an argument in your function.
- Fixtures can request other fixtures! In the example below, first
pre_init_db
will be called, then it will yield execution tomy_short_link
, which will then yield execution totest_delete_link
.
- When we declare an argument with the same name as a fixture (as on line 23), Pytest will populate it with the value yielded by the fixture.
- Unfortunately, the only way a fixture can require another is by declaring it as an argument. So even though the fixture
my_short_link
does not use the value insidepre_init_db
, it has to be declared that way. - Fixtures can be requested more than once during the same test, and Pytest won’t execute them again for that test.
🔬
5: Integration
“Let’s get to integration tests already!”, you must be thinking. Well, good news for you, ’cause here we are. The core of the URL shortener project is the server that handles incoming HTTP requests. This is defined using the fastapi library as follows:
Clearly the first integration/E2E test that comes to mind is shortening a link, then checking that the app correctly “displays” and redirects.
- We define a fixture to initialize a temp database¹ and also initialize a
TestClient
to help us make requests to the server without having to handle the details. - The test uses the
TestClient
provided by our fixture to sequentially request the/shorten
,/{back_half}
and/{back_half}/+
endpoints and verifies their responses. - The fixture disconnects from and deletes the temp database.
🔬
6: Golden Files
Sometimes, the expected output is more complicated than a simple string. In such cases, we serialize the expected object and store it as a golden file. These golden files are usually plain text/image files in a separate directory under tests/
. You will need to load their contents into the memory before the assertion.
Where app_display_fail.txt
looks like this:
That’s all for now. Testing is a huge realm of possibilities and paradigms, but these are the foundations when it comes to testing in Python.
And now I’m afraid our time must come to an end, traveler. From the way the ground is shaking, it seems that a gang of bulldozers is here to knock down my lovely cottage and build a bypass instead. I hope that what you’ve learned here will help you on your development journey. Fare thee well!
Follow MDG Space to get notified about further articles in this series!
📜 The Topics Covered
- Pytest syntax
- Test anatomy:
Arrange → Act → Assert → Assassinate
- Unit tests
- Fixtures
- Integration tests
- Golden files
📚 The Resources
Take these tomes with you, for the journey ahead is long and you’ll need some books to keep you company.
📌 The Footnotes
- I’ve used a tempfile-based DB instead of an in-memory DB because… something keeps failing during tests but not during normal execution. This is also a good idea generally: E2E/integration tests should try to replicate the production environment as much as possible.