Writing the First Tests for a New Python Project

Jels Boulangier
Nov 21, 2020 · 6 min read
Image for post
Image for post
Photo by Kin Li on Unsplash

This article is part of a series How to Organically Grow your Python Project in which I cover the different steps you will go through when starting a new Python hobby project. The focus of this series lies on not knowing in advance what you want to make and thus not planning the whole project from the start.

In this article I’ll talk about how I started writing the first tests of my Python hobby project. This is a story from my point of view and does not serve as the only approach. It merely shows a path you can take to implement tests and make your application more robust. This article is also no exhaustive list of different types of tests. I will merely talk about the steps that I have organically taken and more elaborate testing can always occur later on in the project.

I decided to start with the simplest of tests, unit tests. These are fast, small tests that should be great in numbers. Each such test should check if a unit — the smallest piece of the code that can be usefully tested — behaves as desired. This behaviour should not only be tested in the expected case, but also in unexpected edge cases which might occur.

“A unit is the smallest piece of the code that can be usefully tested.”

In order to bring some structure in the project, you should put yout tests and application code in separate directories, otherwise the root directory will become a mess. Remember, and the end of the previous article, my application structure looked something like this:

main.py
belts.py
furnaces.py
natural_resources.py
...
plates.py

Actually, no structure at all… All files are in the root directory. And imagine adding a ton of test files. Not to speak of future development. It would become utter chaos. Insert order:

src/
main.py
belts.py
furnaces.py
natural_resources.py
...
plates.py
tests/
test_sample.py

Looks much better right? However, now there is a slight problem. You will get an error when writing a unit test in test_sample.py which, for example, checks behaviour of some class in belts.py. This will occur while importing that class into test_sample.py since relative imports to parent directories only work in a Python package (e.g.from ..src.belts import YellowBelt). Thus, you’ll have to turn your application into a package 😱. No worries, this is pretty straightforward. You only need an __init__.py file in order to make Python treat a directory as a package. In the simplest case, this file can even be empty. That’s it! Choosing a cool name for your package, the new application structure becomes:

factpy/
__init__.py
main.py
belts.py
furnaces.py
natural_resources.py
...
plates.py
tests/
test_sample.py

Pro tip, pick a name which is not yet on PyPi (a Python package repository) such that you can publish it later 💪. I’ll talk about how to do this in a future article.

You still have to rename all your previous imports to use the new package instead of just the old file name. E.g. from plates import CopperPlate becomes from factpy.plates import CopperPlate. In your test file test_sample.py, you can just import objects using the package name, e.g. from factpy.belts import YellowBelt.

You can run all tests with python -m pytest in the root directory. After you have written one test in that one file, you’ll get something like this:

Image for post
Image for post

Hooray, all tests have passed!

Now, I found it a bit frustrating to constantly write python -m pytest instead of just pytest, which is also possible according to the documentation. In order to be able to use this, you need to create a symbolic link from your Python environment to your package, mimicking the install of your own package as any other. Using conda, you need to run conda develop . in the root directory, which puts the package in the sys.path of your Python environment. (You can check the path’s content with python -c "import sys; [print(p) for p in sys.path]".) Doing this using pip is slightly different, just checkout the pytest docs in case you are interested. Now, running just pytest in the root directory will start your tests.

Now the test setup is properly configured, you can add more and more test. I started with a few simple ones in the test_sample.py to get familiar with pytest and how to write tests. Even though I know those tests shouldn’t be in the test_sample.py, but rather in a more meaningfully named file, it’s okay, refactoring comes later. Have I already said that continuous refactoring is a key process?

Here’s how that file looked when I started:

from factpy.oil_products import Petroleum
from factpy.oil_proccessing import (BasicOilProccessing,
AdvancedOilProccessing,
LightOilCracking)
def test_petroleum_production_rate_with_basic_oil_processing():
units_per_second = Petroleum.units_per_second(
BasicOilProccessing)
assert units_per_second == 9def test_petrolem_production_rate_with_advanced_oil_processing():
units_per_second = Petroleum.units_per_second(
AdvancedOilProccessing)
assert units_per_second == 11def test_petrolem_production_rate_with_light_oil_cracking():
units_per_second = Petroleum.units_per_second(LightOilCracking)
assert units_per_second == 10

Just a few simple tests. I won’t go into detail how to properly write unit tests because you’ll gradually find this out yourself when your project grows. And there’s lots of content covering this topic spread across the internet. I will give one more basic, but useful example of unit tests, that is parametrised tests. These enable parametrization of arguments for a test function. For example, if a test should behave the exact same way for two distinct objects, it’s counterproductive to write the same test twice. Remember the DRY principle (Don’t Repeat Yourself). Here’s an example of such a parametrised test:

uranium_types = [Uranium235, Uranium238]@pytest.mark.parametrize('uranium', uranium_types)
def test_uranium_ingredients_per_second(uranium):
needed = 10
crafting_time = 12
units = uranium.ingredient_units_needed_per_second(UraniumOre,
Centrifuge)
assert units == needed/crafting_time

The behaviour of the ingredient_units_needed_per_second() function has be to be the same for my two uranium types.

When the test should behave similar for the input objects yet not exactly the same, the parametrisation can be used for a set of input objects and their corresponding expected output. Let’s say the test should check for different output values. An example of such a test:

@pytest.mark.parametrize("recipe, expected",
[(AdvancedOilProcessing, 5),
(CoalLiquefaction, 18)])
def test_heavy_oil_production_rate_with_correct_recipes(recipe,
expected):
assert HeavyOil.units_per_second(recipe) == expected

This way, I don’t need an conditional if-statement inside the test to distinguish between both recipes.

Enough said about the basics of unit tests. What happens now is that you’ll expand your application code accompanied by unit tests. This will ensure that you won’t break existing functionality and properly introduce new features. You can’t imagine how often a failing test pointed out a bug in the code which otherwise I wouldn’t had noticed. And similar to refactoring your application code, you should constantly refactor your tests as well. Renaming, restructuring, adding layers of abstraction etc. The addition of unit tests will result in a more robust application!

In the next article, I’ll talk about how to automate running your tests instead of you having to manually start them with the help of continuous integration.

The Startup

Medium's largest active publication, followed by +756K people. Follow to join our community.

Jels Boulangier

Written by

Self-taught software, data, and cloud engineer who continuously improves his skills through blogs, videos, and online tutorials.

The Startup

Medium's largest active publication, followed by +756K people. Follow to join our community.

Jels Boulangier

Written by

Self-taught software, data, and cloud engineer who continuously improves his skills through blogs, videos, and online tutorials.

The Startup

Medium's largest active publication, followed by +756K people. Follow to join our community.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store