def square(value):
return value * value
def test_square():
assert square(-1) == 1
assert square(1) == 1
assert square(2) == 4
assert square(5) == 2519 Testing
19.1 Why bother writing tests?
Correctness
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 our code is to write tests. Here we’ll be looking at so-called unit tests.
These tests target atomic pieces (units) of code that we can test isolated. For example, here’s a simple function that computes the square of a number and a unit test:
Refactoring
When we want to modify working code, since tests can give us some degree of confidence that we are not breaking the functionality, for example, while optimizing running time.
Documentation
Tests can serve as a way to communicate other people (our ourselves) the expected behaviour of our code.
19.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 in itself, there are even dedicated testing conferences!
Take a look at the test suite of popular open source libraries to learn how testing looks like in the “real world”.
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).
19.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 pytestAfter 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",
]19.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 * valueSo 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.
If you’ve been following along, your project should look more or less like this:

Now put this content in test_square.py:
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) == 25Notice that we do not call the function, we just define it. 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 testsYou should see something like this:
tests/test_square.py . [100%]
================== 1 passed in 0.01s =================That’s our first test, we’re cruising! :D
19.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 * valueCode
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
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.
19.4 Exercises
- Modify the function
squareinsquare.pyso that it returnsvalue * value * 2and run the test again. Pay close attention to the error messages and the diffs! - Write a function called
reverse_strthat 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. - Make the function
reverse_strstrict 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”.