How to write better programs in Python with Pytest - Part 1


LFS

Testing is an essential part of software development. It helps us ensure that our code is correct, reliable, and maintainable. Pytest is a popular testing framework for Python that makes it easy to write and run unit tests.

In this tutorial, you will learn how to use pytest to test your Python code. You will start by installing pytest and writing your first test. Then, you will learn how to use assertions to check the output of your code, and how to use fixtures to set up test dependencies. You will also learn how to test exceptions, multiple outputs, and edge cases.

By the end of this tutorial, you will be able to use pytest to test your Python code with confidence, knowing that your code is reliable and working as expected.

So let’s get started!

Steps

1. Install pytest:

To install pytest, you can use pip:

pip install pytest

2. Write your tests:

Tests in pytest are simply functions that start with the word “test”. Here is an example test function that checks if a list is empty:

def test_list_is_empty():
    assert [] == []

You can have as many test functions as you want in a file. It’s a good practice to have one test file for each module or function you want to test.

3. Test discovery:

By default, pytest will look for test files in the current directory and its subdirectories, and for test functions inside those files. Test files are files that match the pattern “test_.py” or “_test.py”, and test functions are functions that start with the word “test”.

You can customize this behavior by using the -k option to specify a pattern for test functions, or the -p option to specify a plugin for discovering tests.

4. Run your tests:

To run your tests, you can use the pytest command followed by the name of the test file or directory:

pytest test_example.py

If you want to run all the tests in a directory, you can simply specify the directory name:

pytest tests/

5. Assertions:

To check if a certain condition is true in your tests, you can use the assert statement. If the condition is false, the test will fail and pytest will report the error.

Here is an example of an assertion that checks if a list is empty:

def test_list_is_empty():
    assert [] == []

You can also use the assert statement to compare the output of a function to the expected result:

def test_sum_function():
    assert sum([1, 2, 3]) == 6

6. Fixtures:

Fixtures are functions that are used to set up test dependencies. They are decorated with the @pytest.fixture decorator, and they can be passed as arguments to test functions.

Here is an example of a fixture that creates a list:

@pytest.fixture
def my_list():
    return [1, 2, 3]

def test_list_length(my_list):
    assert len(my_list) == 3

Fixtures can also have parameters, which can be used to customize their behavior:

@pytest.fixture
def my_list(length):
    return [1] * length

def test_list_length(my_list):
    assert len(my_list) == 3

You can then specify the fixture parameter when calling the test function:

def test_list_length(my_list(length=3)):
    assert len(my_list) == 3

Here are some example tests using pytest:

1. Testing function arguments:

You can use pytest to test that a function is correctly handling its arguments. For example, consider the following function that calculates the area of a rectangle:

def rectangle_area(width, height):
    return width * height

You can test that the function is correctly handling different values for the width and height arguments:

def test_rectangle_area():
    assert rectangle_area(2, 3) == 6
    assert rectangle_area(5, 5) == 25
    assert rectangle_area(0, 5) == 0
    assert rectangle_area(5, 0) == 0
    assert rectangle_area(-2, 3) == 6
    assert rectangle_area(2, -3) == 6

2. Testing edge cases:

It’s a good idea to test your functions with edge cases, such as maximum or minimum values, empty input, or input with a large number of elements.

For example, you can test the rectangle_area function with a very large value for the width and height:

def test_rectangle_area_edge_cases():
    assert rectangle_area(10**6, 10**6) == 10**12
    assert rectangle_area(-10**6, 10**6) == 10**12
    assert rectangle_area(10**6, -10**6) == 10**12

3. Testing exceptions:

You can use pytest to test that a function is correctly raising an exception under certain conditions. To do this, you can use the pytest.raises function as a context manager.

For example, consider the following function that calculates the square root of a number:

def square_root(x):
    if x < 0:
        raise ValueError("Number must be positive")
    return math.sqrt(x)

You can test that the function is correctly raising a ValueError when the input is negative:

def test_square_root():
    with pytest.raises(ValueError):
        square_root(-1)

4. Testing multiple outputs:

You can use pytest to test that a function is correctly returning multiple outputs. To do this, you can use the pytest.approx function to compare the outputs to the expected values.

For example, consider the following function that calculates the roots of a quadratic equation:

def quadratic_roots(a, b, c):
    discriminant = b**2 - 4*a*c
    if discriminant < 0:
        raise ValueError("Equation has no real roots")
    elif discriminant == 0:
        x = -b / (2*a)
        return x, x
    else:
        x1 = (-b + math.sqrt(discriminant)) / (2*a)
        x2 = (-b - math.sqrt(discriminant)) / (2*a)
        return x1, x2

You can test that the function is correctly returning the roots of the equation:

ddef test_quadratic_roots():
    assert quadratic_roots(1, 0, -1) == (1, -1)
    assert quadratic_roots(1, -3, 2) == (2, 1)
    assert quadratic_roots(1, 5, 6) == pytest.approx((-2.5, -1.5))
    with pytest.raises(ValueError):
        quadratic_roots(1, 1, 1)

In this example, the first test case checks that the roots of the equation x^2 - 1 = 0 are correct. The second test case checks that the roots of the equation x^2 - 3x + 2 = 0 are correct. The third test case checks that the roots of the equation x^2 + 5x + 6 = 0 are correct, using the pytest.approx function to compare the roots to the expected values. The fourth test case checks that the function is correctly raising a ValueError when the discriminant is negative.

Some best practices

1. Group tests by functionality:

It’s a good idea to group your tests by the functionality they are testing. This can make it easier to find and maintain your tests.

For example, you can create a tests directory at the root of your repository, and inside it create subdirectories for each module or component you want to test. For example:

tests/
    calculator/
        test_add.py
        test_subtract.py
        test_multiply.py
        test_divide.py
    users/
        test_create_user.py
        test_update_user.py
        test_delete_user.py

2. Use descriptive names for test files and test functions:

It’s a good idea to use descriptive names for your test files and test functions. This can make it easier to understand the purpose of each test and identify which tests need to be run in a given situation.

For example, you can use the name of the function or module being tested as the prefix for the test file or test function name. For example:

# Test file name
test_add.py
# Test function names
def test_add_two_positive_numbers():
    ...
def test_add_two_negative_numbers():
    ...
def test_add_positive_and_negative_numbers():
    ...

3. Use fixtures to reduce duplication:

Fixtures are functions that are used to set up test dependencies. They can be used to reduce duplication in your tests and make them easier to maintain.

For example, consider the following test function that tests the create_user function:

def test_create_user():
    user = create_user("John", "Doe", "john.doe@example.com")
    assert user.first_name == "John"
    assert user.last_name == "Doe"
    assert user.email == "john.doe@example.com"

This test function sets up the test data and calls the create_user function every time it is run. To reduce duplication, you can use a fixture to set up the test data and pass it to the test function:

@pytest.fixture
def user_data():
    return ("John", "Doe", "john.doe@example.com")

def test_create_user(user_data):
    first_name, last_name, email = user_data
    user = create_user(first_name, last_name, email)
    assert user.first_name == first_name
    assert user.last_name == last_name
    assert user.email == email

This test function is now more concise and easier to read, and the test data is separated from the test logic.

3. Create tests to ensure Condition Coverage

Condition coverage/Predicate Coverage is a measure of how well the tests are exercising the different combinations of conditions in the code. It is a type of structural coverage that focuses on the control flow of the code.

To test different combinations of conditions, you can use test cases with different input values that will exercise the different combinations of conditions in the code.

For example, consider the following function that calculates the discount for a purchase based on the total amount and the customer’s loyalty status:

def calculate_discount(amount: float, loyalty_status: str) -> float:
    if amount > 100:
        discount = 0.1
    elif amount > 50:
        discount = 0.05
    else:
        discount = 0.0
    
    if loyalty_status == "gold":
        discount += 0.05
    elif loyalty_status == "silver":
        discount += 0.03
    
    return amount * discount

To test different combinations of conditions, you can write test cases with different input values that will exercise the different conditions in the code. To test the else clause in a conditional statement, you can write a test case with input values that will not satisfy any of the conditions in the if or elif clauses.

def test_calculate_discount():
    # Test amount > 100 and loyalty_status == "gold"
    assert calculate_discount(110, "gold") == 11.5
    
    # Test amount > 50 and loyalty_status == "silver"
    assert calculate_discount(60, "silver") == 3.9
    
    # Test amount <= 50 and loyalty_status == "bronze"
    assert calculate_discount(40, "bronze") == 0.0

4. Test one thing at a time

Unit tests should test one thing at a time. This means that each test should only test a single unit of code, such as a function or method. This makes it easier to understand the purpose of each test and identify which tests need to be run in a given situation.

5. Test for expected outcomes

Unit tests should test for expected outcomes. This means that you should test that your code is producing the correct output, rather than testing the implementation details.

Evaluating Tests

How do you know whether you have tested your code enough. Even though there is no Universal standard for this, there are several metrics that can help developers understand if they have done their unit testing well.

1. Risk assessment

Risk assessment can help you determine which areas of the code are most critical and need more testing. You can identify the areas of the code that are most important to the project, or that have the highest potential for failure, and focus your testing efforts on those areas. Testing can sometimes be a time consuming process, so make sure you prioritize what to test first before you jump into code coverage.

2. Code coverage

Code coverage is a measure of how much of the code is being tested by the unit tests. A high code coverage means that a large portion of the code is being tested, which can provide, uptp a certain extent, confidence that the code is working as expected.

To measure code coverage, you can use a tool like coverage.py or pytest-cov. These tools can run your tests and generate a report showing which lines of code are being covered by the tests.

3. Test duration

The duration of the unit tests is an important metric, as it can indicate how long it takes to run the tests and how long it takes to get feedback on the code changes. Long-running tests can slow down the development process and make it harder to get feedback on code changes.

4. Test stability

Test stability is a measure of how consistently the tests pass or fail. Stable tests are expected to pass or fail consistently, while unstable tests may pass or fail randomly. Unstable tests can be hard to debug and can reduce confidence in the test suite.

To measure test stability, you can run the tests multiple times and track the pass/fail rate. You can also use tools like pytest-rerunfailures to automatically rerun failed tests and check if they are stable.

5. Test maintainability

Test maintainability is a measure of how easy it is to update and maintain the tests over time. Maintainable tests are easy to understand and modify, while hard-to-maintain tests may require a lot of effort to update and may be prone to breaking when the code changes.

To measure test maintainability, you can track the time and effort required to update the tests and the frequency of test breaks due to code changes. You can also use practices like writing descriptive test names and using fixtures to reduce duplication and improve maintainability.

Part 2: How to use Pytest in VSCode