Code should have a set of automated tests that allow you to design new code and modify existing code while verifying that everything continues to work correctly.
- Introducció
- Project setup
- Assert
- Test-Driven development
- Pytest
- Learn by Testing
- Continuous Integration
- Tasks
- Pending
Introducció
To write a program, you need to test that it works.
🤔Què ens diu l’Uncle Bob sobre els tests?
Project setup
Create a new project and install the required testing dependencies:
uv init testcd testuv add --dev pytestAssert
Create the file test.py:
msg = "Hello World!"hello = msg[:5]
print(hello)The old way to test code was to do a “print” and check that the result displayed on screen is what you expected.
uv run test.pyHelloInstead of doing a print and checking the screen, you can assert the expected result:
msg = "Hello World!"hello = msg[:5]
assert hello == "Hello"If everything goes well, you won’t see anything:
uv run test.pyBut if hello is not "Hello" because you made a mistake:
msg = "Hello World!"hello = msg[:3]
assert hello == "Hello"You’ll see an error message:
Traceback (most recent call last): File "/home/box/py/test.py", line 4, in <module> assert hello == "Hello" ^^^^^^^^^^^^^^^^AssertionErrorTest-Driven development
Test-Driven Development is a methodology where you write tests before implementing the actual code.
Modify the test.py file.
Add a list of people:
persons = [ {"name": "Eva", "cognom": "Vilaregut", "sexe": "Dona", "edat": 19, "altura": 162}, {"name": "Joan", "cognom": "Sales", "sexe": "Home", "edat": 25, "altura": 173}, {"name": "Raquel", "cognom": "Viñales", "sexe": "Dona", "edat": 12, "altura": 123}, {"name": "Esther", "cognom": "Parra", "sexe": "Dona", "edat": 33, "altura": 178}, {"name": "Miquel", "cognom": "Amorós", "sexe": "Home", "edat": 56, "altura": 166}, {"name": "Laura", "cognom": "Casademunt", "sexe": "Dona", "edat": 41, "altura": 182},]We’ll write code that returns the name of the tallest person in the list.
Step 1: Write the Test First
Instead of writing the code that finds the tallest person, the first thing you should do is write the test:
assert result["name"] == "Laura"If you run the code, you’ll get a fairly obvious error:
Traceback (most recent call last): File "/home/box/py/test.py", line 10, in <module> assert result["name"] == "Laura" ^^^^^^NameError: name 'result' is not definedThe variable result is not defined.
Step 2: Make Small Progress
It may seem a bit absurd, but the art of programming consists of being able to advance in tiny steps when necessary.
The error tells you what to do next: define a result variable where the result will be stored.
result = {}
assert result["name"] == "Laura"When running the code, the error will now be different:
Traceback (most recent call last): File "/home/box/py/test.py", line 12, in <module> assert result["name"] == "Laura" ~~~~~~^^^^^^^KeyError: 'nom'The result variable doesn’t have the “name” key.
What we can do is add the first person from the list as the result (if their name isn’t “Laura”!):
result = persons[0]
assert result["name"] == "Laura"When running the code, the error will be that the result is “Eva” and not “Laura”.
box@python:~/py$ /bin/python3 /home/box/py/test.pyTraceback (most recent call last): File "/home/box/py/test.py", line 12, in <module> assert result["name"] == "Laura" ^^^^^^^^^^^^^^^^^^^^^^^^AssertionErrorStep 3: Iterate Through the List
What we need to do now is iterate through all elements of the list with a for:
result = {}for person in persons: result = person
assert result["name"] == "Laura"Now the test works because “Laura” is the last element of the list!
Just because a test passes doesn’t mean the code is correctly implemented.
Modify the list of people so that “Laura” is not the last one on the list.
Verify that the test no longer works.
Step 4: Implement the Actual Logic
Now you can modify the code to verify that person is taller than result before modifying result:
result = persons[0]for person in persons: if person["altura"] > result["altura"]: result = person
assert result["name"] == "Laura"And we can optimize the code by starting from the second element of the list:
result = persons[0]for person in persons[1:]: if person["altura"] > result["altura"]: result = person
assert result["name"] == "Laura"The TDD Cycle
In summary, TDD consists of three steps:
- Write failing tests (Red)
- Make the test pass (Green)
- Refactor the code (Refactor) — improve it

Pytest
The pytest module allows you to manage a set of tests.
Create a new file called test_division.py, which contains a test:
def test_division(): assert division(6,3) == 2To run the tests, we’ll use pytest.
uv run pytestRuns ALL tests. Searches for files that start with test_ within the directory.
For each file, it searches for functions that contain the word ‘test’ in their name at the beginning or end.
These are the functions it executes.
uv run pytest file_nameRuns the test functions in the file_name file
uv run pytest file_name::function_nameOnly runs the test function function_name found in file_name
Let’s run it:
uv run pytest assert division(6,3) == 2E NameError: name 'division' is not definedThe test we just wrote doesn’t even compile. This is easy enough to fix.
What’s the minimum we can do to make it compile, even if it doesn’t execute?
We need a basic implementation of division().
Again, we’ll do the minimum work possible just to make the test compile:
def division(a,b): 0
def test_division(): assert division(6,3) == 2Now we can run the test and see that it fails.
> assert division(6,3) == 2E assert None == 2E + where None = division(6, 3)
test_division.py:6: AssertionErrorOur test framework has executed the small piece of code we started with, and we realized that even though we expected “2” as a result, we got “None”.
Failure is progress. Now we have a concrete measure of failure. This is better than just vaguely knowing we’re failing. Our programming problem has transformed from “make a division” to “make this test work and then make the rest of the tests work”. Much simpler. A much smaller margin for fear. We can make this test pass.
You probably won’t like the solution, but the goal right now is not to get the perfect answer, the goal is to pass the test. Later we’ll make our sacrifice to the altar of truth and beauty.
Here’s the smallest change I could imagine that would make our test pass:
def division(a,b): 2And the test fails. What’s happening?
In some languages, a function always returns a result which is the last statement, but it turns out Python doesn’t, and we have to indicate it explicitly with return.
Thanks to writing the test first, we’ve learned this.
We can fix it:
def division(a,b): return 2
def test_division(): assert division(6,3) == 2And our test passes:
test_division.py . [100%]==================== 1 passed in 0.01s =======================But can we be sure the function is correctly implemented? Let’s write another assertion to test the division function because who knows…
def test_division(): assert division(6,3) == 2 assert division(9,3) == 3And when we test our function, we have a small surprise. The test failed!
E assert 2 == 3E + where 2 = division(9, 3)test_division.py:7: AssertionErrorWell, there’s something wrong with our division function.
Let’s fix it:
def division(a,b): return a / bNow we get the green bar again:
============================== 1 passat en 0,01 s =============== ========Do these steps seem too small? Remember that TDD is not about taking small steps, but about being able to take small steps.
Would you code day-to-day with such small steps? No. Try small steps with an example of your own choice. If you can take steps that are too small, you can surely take steps of the right size. If you only take larger steps, you’ll never know if smaller steps are appropriate.
Edge Cases
Because what will happen if we do this test?
assert division(5, 2) == 2.5With Python, the quotient returned by the / operator is always a float even if the operands are int and the result can be represented with an int.
But this is not true in other languages. For example, in Java, if we divide two ints, the result is an int, in this case the result would be 2.
And what about division(2,3) and division(4,0)? What result do you expect?
assert division(2, 3) == ???assert division(4, 0) == ???With all these tests implemented, you can be sure that if something changes in the division function, you’ll know.
Yes, I know the / operator won’t change its behavior.
List all the test cases that should be tested for the division operator (/).
But functions are not that simple, and some are very complex and depend on other functions and libraries.
Learn by Testing
Besides testing code you’ve written, you can test code others have written.
This is important for learning how other people’s code works.
namedtuple() is a function available in collections that allows you to create named tuple subclasses.
This way the code is more readable because instead of an index you can use a name to access the different elements of the tuple using the obj.attr notation.
To create a namedtuple, you need to provide two positional arguments to the function:
-
typename. The name of the subclass. -
field_names. A list of names
Create the file test_tuple.py:
"""Test the Task data type."""from collections import namedtuple
Task = namedtuple('Task',['summary','owner','done','id'])
def test_defaults(): """Using no parameters should invoke defaults.""" t1 = Task() t2 = Task(None, None, False, None) assert t1 == t2Let’s assume the named tuple will be created with default values. Why not? Python is a friendly language. But we’re TDD developers, so we’ll let pytest check if this assumption is true:
FAILED test_task.py::test_defaults - TypeError: Task.__new__() missing 4 required positional arguments: 'summary...============================== 1 failed in 0.02s ===============================And you’re seeing the dreaded red bar.
The problem with Python is that it’s a friendly language without static typing, so it can’t know the data type of our tuples and infer what default values to use.
However, we have a way to assign default values to each attribute with ‘defaults’
Task = namedtuple('Task',['summary','owner','done','id'])Task.__new__.__defaults__ = (None,None,False,None)Now we have the green bar, legendary in songs and history:
$ pytesttest_task.py . [100%]============================== 1 passed in 0.01s ===============================Now we’ll test how to access members by name and not by index, which is one of the main reasons to use namedtuple.
Add this test to test_task.py:
def test_member_access(): """Check .field functionality of namedtuple."""
t = Task('buy milk', 'brian') assert t.summary == 'buy milk' assert t.owner == 'brian' assert (t.done,t.id) == (False,None)collected 2 itemstest_task.py .. [100%]============================== 2 passed in 0.01s ===============================The test passes! But more importantly, the previous test was also tested and we can see it still passes.
Converting to Dictionary
You can convert tuple instances to dictionaries using ._asdict(). This method returns a new dictionary that uses the field names as keys. The keys of the resulting dictionary are in the same order as the fields of the original namedtuple
def test_as_dict():
Person = namedtuple("Person", "name age height") jane = Person("Jane", 25, 1.75) assert jane._asdict() == ""This test fails since the tuple we created is very different from an empty string.
Test that a namedtuple can be converted to a dict:
def test_asdict(): """_asdict() should return a dictionary."""
task = Task('do something', 'tokken', True, 21)
# your code
assert dict == expecteddef test_asdict(): """_asdict() should return a dictionary."""
task = Task('do something', 'tokken', True, 21) dict = task._asdict() expected = { 'summary':'do something', 'owner': 'tokken', 'done': True, 'id': 21 }
assert dict == expectedReplacing Values
Since named tuples are immutable, it may seem counterintuitive that the namedtuple object comes with the ._replace() method, which allows you to replace values in a named tuple.
The way this works is that a new tuple is created.
The advantage of this approach is that it allows us to modify only specific values while preserving the original values
def test_replace():
Person = namedtuple('Person', ['name', 'age', 'location', 'profession'])
mike = Person('Mike', 33, 'Toronto', 'Veterinari') assert mike.age == 33
newMike = mike._replace(age=44) assert newMike.age == 44
assert mike.age == 33 assert mike.age != newMike.ageContinuous Integration
To automatically run your tests whenever you push code to Gitlab, you can set up continuous integration (CI).
Create a new file .gitlab-ci.yml in the root of your project:
test: stage: test image: ghcr.io/astral-sh/uv:debian script: - uv sync - uv run pytest --junitxml=report.xml artifacts: when: always paths: - report.xml reports: junit: report.xmlThis configuration will:
- Run tests automatically on each commit
- Use the
uvtool to manage dependencies - Generate a test report in JUnit XML format
- Store the report as an artifact, even if tests fail
- Display test results directly in GitLab’s merge request interface
For more detailed information about setting up CI/CD pipelines, see Gitlab - Test
Tasks
Test that you can change the values of a namedtuple with _replace. In this case, we want to change done and id of the Task tuple.
def test_replace(): """replace() should change passed in fields."""
task = Task('finish book', 'brian', False)
# your code
assert task == expecteddef test_replace(): """replace() should change passed in fields."""
task = Task('finish book', 'brian', False) task = task._replace(id=10, done=True)
expected = Task('finish book', 'brian', True, 10)
assert task == expectedCreate 3 asserts that test the functionality of this function.
def adn_count_base(adn, base): """Compta el número d'aparicions de la base dintre de la cadena d'adn""" counter = 0 for x in adn: if x == base: counter += 1
return counterBelow the main method, add these test functions.
def test_adn_count(): assert adn_count_base("GATACT","A") == 2 assert adn_count_base("GATACT","G") == 1 assert adn_count_base("ATGGATTAG","T") == 3