PyTest - Basic

  • 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ó

    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:

    Terminal window
    uv init test
    cd test
    uv add --dev pytest

    Assert

    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.

    Terminal window
    uv run test.py
    Hello

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

    Terminal window
    uv run test.py

    But 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"
    ^^^^^^^^^^^^^^^^
    AssertionError

    Test-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 defined

    The 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.py
    Traceback (most recent call last):
    File "/home/box/py/test.py", line 12, in <module>
    assert result["name"] == "Laura"
    ^^^^^^^^^^^^^^^^^^^^^^^^
    AssertionError

    Step 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!

    Precaució

    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:

    1. Write failing tests (Red)
    2. Make the test pass (Green)
    3. 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) == 2

    To run the tests, we’ll use pytest.

    Which tests are executed?
    Terminal window
    uv run pytest

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

    Terminal window
    uv run pytest file_name

    Runs the test functions in the file_name file

    Terminal window
    uv run pytest file_name::function_name

    Only runs the test function function_name found in file_name

    Let’s run it:

    Terminal window
    uv run pytest
    assert division(6,3) == 2
    E NameError: name 'division' is not defined

    The 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) == 2

    Now we can run the test and see that it fails.

    > assert division(6,3) == 2
    E assert None == 2
    E + where None = division(6, 3)
    test_division.py:6: AssertionError

    Our 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):
    2

    And 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) == 2

    And 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) == 3

    And when we test our function, we have a small surprise. The test failed!

    Terminal window
    E assert 2 == 3
    E + where 2 = division(9, 3)
    test_division.py:7: AssertionError

    Well, there’s something wrong with our division function.

    Let’s fix it:

    def division(a,b):
    return a / b

    Now we get the green bar again:

    ============================== 1 passat en 0,01 s =============== ========
    Consell

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

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

    Task

    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:

    1. typename. The name of the subclass.

    2. 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 == t2

    Let’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:

    Terminal window
    $ pytest
    test_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 items
    test_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.

    Task

    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 == expected

    Replacing 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.age

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

    .gitlab-ci.yml
    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.xml

    This configuration will:

    • Run tests automatically on each commit
    • Use the uv tool 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

    Task

    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 == expected
    Task

    Create 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 counter

    Pending