17  Testing

17.1 Why bother writing tests?

Correctness: This is our goal number one when writing code, since incorrect code is arguably useless. By incorrect we do not mean proper business logic nor being statistically sound. We mean something much simpler: The code does what we think it is doing.
One way to build some confidence about the correctness of code is to write tests. We are going to deal here with so-called unit tests. As the name suggests, these tests target pieces, blocks (units) of code that we can test isolated. For example, here’s a simple function that computes the square of a number and a little test:

def square(value):
    return value * value

def test_square():
    assert square(-1) == 1
    assert square(1) == 1
    assert square(2) == 4
    assert square(5) == 25

test_square()

Refactoring: Having tests in place can be also useful when we want to modify working code, since they will give us at least some degree of confidence that we are not breaking the functionality when we refactor the code, for example, while optimizing running time.

As we saw above we can write the assertions about our code in a function that we then call. Calling each test manually is a bit cumbersome and error prone. We will use instead a testing framework that will do this automatically for us (plus, it’s going to provide us with a whole bunch of handy functionalities out of the box).

17.2 What to test

In a way writing tests is the easy part. The much more challenging questions are what to test and how. Those are of course questions that depend on the context and the system we are testing.
We will focus on two aspects that are important in the context of working with data: Verifying assumptions about the data (content, shape, etc.) and checking that transformations in a data processing pipeline are doing the right thing. Further examples of aspects to be tested include: Verifying responses from an API, check that a machine learning model can deal with a given input, etc. Testing software is in itself a whole field, there are even dedicated testing conferences!

Tip

Take a look at the test suite of popular open source libraries to learn how testing looks like in the “real world”.

17.3 Pytest

Although python has a built-in module for unit tests, we will use pytest, a third-party library which is much more convenient and very well developed and maintained.

Let’s add that dependency to our project. Testing dependencies are usually included under “development” dependencies. That means, dependencies that will be used during development but not later on (for example when running the application code in production).
We can also do that with our package manager uv passing the --dev flag:

uv add --dev pytest

After executing that line, you can verify that the dependency where the dependency is listed by looking into the pyproject.toml file, which should look like this:

[tool.uv]
dev-dependencies = [
    "pytest>=8.3.3",
]

17.3.1 Basic Testing

In the python ecosystem it is rather common to have a separated directory for tests, but that is just an implicit convention and no hard requirement at all. But to keep things tidy we’ll follow that.

First we will put the source code that has our functionality in a file under src/pycourse/square.py

def square(value):
    return value * value

So let’s create a directory (next to src) called tests and make a file called tests/test_square.py including our previous test_square code:

from pycourse.square import square  # Import function from source code test

def test_square():
    assert square(-1) == 1
    assert square(1) == 1
    assert square(2) == 4
    assert square(5) == 25

Notice that we do not call the function, we just define it.

Important

The file must be called test_*.py or *_test.py for pytest to discover it and the unit tests must start with test_.

We can now run our tests (pytest will discover files and tests following the pattern above mentioned):

uv run pytest tests

You should see something like this:

tests/test_square.py .                          [100%]

================== 1 passed in 0.01s =================

That’s our first test, we’re cruising! :D

17.3.2 Make sure something fails

Sometimes we want to make sure our program fails given a certain input. For example, let’s say we want to check the input of our square function is a float or integer. We could add that logic to the function and handle the case:

def square(value):
    if not isinstance(value, (float, int)):
        raise ValueError(f"Invalid input. Expected float, got {type(value)}")
    return value * value
Code
import ipytest
ipytest.autoconfig()
%%ipytest

import pytest

def test_square():
    assert square(-1) == 1
    assert square(1) == 1
    assert square(2) == 4
    assert square(5) == 25  
    with pytest.raises(ValueError):
        square("foo")
    with pytest.raises(ValueError):
        square(None)
.                                                                                            [100%]
1 passed in 0.01s
Note

Checking the types of the functions explicitly like in the example is not a very “pythonic” thing to do, as we tend to care more about the behaviour than about the types (duck typing). Also it is a relatively inefficient way to do it. But sometimes we do trade some purity and efficiency for correctness, for example in data pipelines where errors can go unattended because they do not lead to syntax errors.

17.4 Exercises

  1. Modify the function square in square.py so that it returns value * value * 2 and run the test again. Pay close attention to the error messages and the diffs!
  2. Write a function called reverse_str that takes a string and returns the reversed version, eg “hola” -> “aloh”. Write a unit test and make sure the tests pass. Think about covering corner cases in your tests.S
  3. Make the function reverse_str strict to only accept strings as input and throw an error if another type is passed. Write a unit test that covers both the “happy path” and the “error path”.