Test driven development in Python using Pytest
Share this post

Introduction

Testing code is a very important step during software development. When a programmer submits a project, he must be sure that the software he created works correctly. There are many errors that may appear while using the software, such as wrong input data type, wrong input file, wrong API, or incorrect user operation. Tests help to predict possible code usage scenarios and prevent errors from appearing. Code with properly written tests is much easier to debug and the number of errors that may appear while using the program is smaller. Tests can also help to identify and solve problems. If you are convinced enough (I’m sure you are!) I encourage you to read this article!

What is TDD (Test-driven development)?

TDD (Test-driven development) is a software development technic that presents software requirements by tests. When writing tests, the programmer writes down the requirements that the final software should meet. The test can only be passed when the final code fulfills all conditions. Thanks to this, during software development, code is repeatedly tested and all of the requirements are collected which determine that the final software has been written correctly and does not contain errors. 

TDD approach can be presented in three steps:

  1. Write tests that contain all future software requirements. All tests should fail.
  2. Develop minimal software and check its correctness by running tests. This step should end when all tests pass.
  3. Refactor and improve your code. Run tests again remembering they should pass. 

What is the Pytest framework?

Pytest is one of the most popular frameworks for testing python code. There are many advantages that make people enjoy using Pytest. Definitely, one of them is the ease of use, among extra plugins and very well-written documentation. Pytest supports unit tests and allows you to write simple scalable test sets. Moreover, Pytest lets programmers use fixtures, parametrization, and gives an opportunity to skip selected tests during execution. In addition, it’s open-source and on the Internet there are many articles and tutorials about testing python code using Pytest. Due to these all pros lots of developers decide to write tests using this framework.

Basic Pytest test case

First of all, we need to consider how structure of our project should look like. There are several approaches, but in my opinion, it is best to separate the tests from the code that is going to be tested. I propose that our project should have the following structure. 

└── project
    ├── sources
    │ ├── code.py
    │ └── __init__.py
    └── tests
     ├── test_code.py
     └── __init__.py
  • In the sources package, we will keep project logic.
  • In the tests package, we will keep tests that check the correctness of project logic.

Remember! If you want Pytest to find your test file without listing it during execution you have to add ‘test’ prefix or suffix in the file name.

Let’s write a simple function that will return the number of ‘a’ letters (upper and lower case) in a given word. As you can see, the function has protection (cast to string) against specifying a ‘word’ argument that is a different data type than string.

# project/sources/code.py

def count_a(word = None):
    return str(word).lower().count('a')

Next, let’s write a test that will confirm that the function returns the correct result. You have to know that the test method should have a ‘test’ substring at the beginning or at the end of the name. Only then Pytest will find the testing method and will execute it. First, we need to import the count_a function. To check function correctness, we use an assert statement, which checks whether the result of the function is equal to the expected result. We would like to cover all possible cases when errors could appear. Let’s look at the following example:

# project/tests/test_code.py

from sources.code import count_a

def test_count_a():
    assert count_a('AaA') == 3
    assert count_a(2) == 0
    assert count_a(3.14) == 0
    assert count_a('watermelon') == 1
    assert count_a() == 0

To run Pytest you have to just simply write ‘pytest’ in your console. We use the verbose flag to see more detailed output:

$ pytest -v

Running the test gives the following result:

TDD in Python 1

 

As you can see our test passed, function count_a works properly. 

What if we delete protection against providing argument in different type than string? Let’s remove cast from function and run the test again.

# project/sources/code.py
def count_a(word=None):
    return word.lower().count('a')

$ pytest -v

Test failed because of an AttributeError. Pytest marked which assert statement returned false and where exception appeared in tested function. 

TDD in Python using Pytest

From now on, you know how to write a simple test with Pytest. It is high time to present to you how to use the Pytest package in the TDD technique.

Let’s imagine that we want to implement the UserDatabase class. Only information such as name, surname, age, and id number could be added to the database. Age can’t be a negative value and must be lower than 120. Name and surname obviously have to be a string and id number must be unique. The user may be removed from the database after providing the id number. In addition, the class should enable displaying the name of the oldest user added to the database. 

Write a test

Following the TDD method, we start work by writing a test that will check all use cases and user stories. In the example below, we used the raises method from the Pytest library to check if the expected exception will appear.  

# project/tests/test_code.py
import pytest
from sources.code import UsersDatabase

def test_user_database():
    # Create user database object
    database = UsersDatabase()

    # Add new users
    database.add_user('John', 'Smith', 29, 'e31sf')
    database.add_user('Emily', 'Taylor', 12, 'd24da')
    database.add_user('Lily', 'Thomas', 66, 'd33fw')
    assert database.get_number_of_users() == 3
    
    # Add two users with the same id
    with pytest.raises(ValueError):
        database.add_user('John', 'Smith', 29, 'e31sf')

    # Remove user
    database.delete_user_by_id('d24da')
    assert database.get_number_of_users() == 2
 
    # What if age is a string
    with pytest.raises(TypeError):
        database.add_user('John', 'Thomas', 'age', 'dc33s')

    # What if age equals 200
    with pytest.raises(ValueError):
        database.add_user('Ava', 'Brown', 200, 'dsd2f')

    # What if age is negative value
    with pytest.raises(ValueError):
        database.add_user('Ava', 'Brown', -10, 'dsdw3')

    # What if name is a integer
    with pytest.raises(TypeError):
        database.add_user(2323, 'Smith', 23, 'd3ff2')

    # What if surname is a float
    with pytest.raises(TypeError):
        database.add_user('John', 3.14, 19, 'd31xe')

    # What if new user doesn't have id
    with pytest.raises(TypeError):
        database.add_user(name='John', surname='Wilson', age=19)

Write a code

Right now it’s time to write the code remembering about the test that should be passed when we finish! The final code could look like sniped below:

# project/sources/code.py

class UsersDatabase:
    def __init__(self):
        self.database = []

    def add_user(self, name, surname, age, id_number):
        if not isinstance(age, int):
            raise TypeError

        if not isinstance(name, str):
            raise TypeError
 
        if not isinstance(surname, str):
            raise TypeError

        if age not in range(1, 120):
            raise ValueError

        if any(user.id_number == id_number for user in self.database):
            raise ValueError

        self.database.append(User(name, surname, age, id_number))

    def delete_user_by_id(self, id_number):
        for user in self.database:
            if user.id_number == id_number:
                self.database.remove(user)

    def get_oldest_user_name(self):
        oldest_user = sorted(self.database, key=lambda x: x.age, reverse=True)[0]
        return oldest_user.name 

    def get_number_of_users(self):
        return len(self.database)

class User:
    def __init__(self, name, surname, age, id_number):
        self.name = name
        self.surname = surname
        self.age = age
        self.id_number = id_number

 

Run a test

Let’s run a Pytest and check the test result.

$ pytest -v

TDD in python 2

 

The test passed. Our code is ready.

Conclusion

Code testing could help to avoid many errors that are sometimes not visible at first glance. Writing tests require a bit of work and could be time-consuming, but it avoids situations where we have to improve the code over and over again as users find more bugs. 

TDD is a technique where tests are written first  to cover all possible errors and wrong code usage. Then the code is written based on the tests and only when all tests pass the code can be considered finished. This technique helps software developers to be more focused on project requirements and possible cases when something could be erroneous.

Pytest is the most popular framework for testing python code. It has a lot of advantages and this is the reason why people use Pytest very often. Using the TDD technique with the Pytest framework gives an opportunity to improve python code quality and user satisfaction.

Contact us if you need some help implemeting this on your side

Check out our blog for more details on Data Engineering:

Data Engineering

Share this post
Close

Send Feedback