The Quest for Reliable Code (Part 1.1)

Practical examples to help you explore Software Testing in Python

Aditya Rajput
MDG Space
5 min readAug 22, 2023

--

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.

In the wise words of Dr Michael Morbius, ‘It’s Morbin’ time

🔗 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:

  1. For Pytest to detect your functions as tests, both the file name and the function name should start with the prefix “test_
  2. 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.
  3. Each function under the TestRandomString class verifies the generate_random_string function’s behavior under different input conditions.
  4. 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.
  5. 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:

The implementation details have been removed for brevity’s sake

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:

  1. The Act 🔨 and Assert 🔍 phases are the same as before
  2. 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.
  3. 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:

  1. 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").
  2. 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.
  3. 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 to my_short_link, which will then yield execution to test_delete_link.
  1. 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.
  2. 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 inside pre_init_db, it has to be declared that way.
  3. 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:

The implementation details have been removed for brevity’s sake

Clearly the first integration/E2E test that comes to mind is shortening a link, then checking that the app correctly “displays” and redirects.

  1. 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.
  2. The test uses the TestClient provided by our fixture to sequentially request the /shorten, /{back_half} and /{back_half}/+ endpoints and verifies their responses.
  3. 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.

If you’re using golden files often, you can create a fixture that loads them separately

Where app_display_fail.txt looks like this:

Yeah I know it’s pretty short and could’ve been asserted as a string. This is just an example.

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

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

--

--

Aditya Rajput
MDG Space

Secretary @MDG Space | Ex-intern @PLAID | GSoC '22 @Matrix.org | InterIIT silver medalist