Skip to content

Basics

Level: Starter☕

Do I need unit tests?

In my opinion, yes.

For your reference, there are a lot of discussions on the internet:

What is unit testing, and why is it important?

Who does unit testing?

What is your honest opinion about unit testing?

And some arguments against unittest you may also want to know:

What are some arguments against unit testing?

Just unit tests?

Not just unit tests, we also use pytest for integration testing, and E2E testing. So it's important

Objective

In this tutorial, you will learn how to:

  • Write a simple test.
  • Use pytest.mark.parametrize.
  • Test expected exceptions.
  • View test code coverage.

Preparation

Install Pytest

$ pip install pytest

Installing pytest...

Write a Simple Test

Let's create two files coffee.py and test_coffee.py

coffee.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def get_ingredients(coffee: str) -> list:
    """Get Ingredient Given a Coffee Type

    Args:
        coffee (str): type of the coffee

    Raises:
        Exception: unsupported coffee type

    Returns:
        list: list of ingredients
    """
    if coffee == "latte":
        return ["espresso", "steamed milk"]
    elif coffee == "cappuccino":
        return ["espresso", "steamed milk", "foam"]
    elif coffee == "mocha":
        return ["espresso", "steamed milk", "chocolate"]
    elif coffee == "americano":
        return ["espresso", "hot water"]
    raise Exception(f"Unsupported coffee type: {coffee}")
test_coffee.py
1
2
3
4
5
6
from coffee import get_ingredients


def test_get_ingredients():
    """Test Whether get_ingredients Returns Corret Ingredients"""
    assert get_ingredients("latte") == ["espresso", "steamed milk"]

You should have a folder structure as below:

.
├── coffee.py
└── test_coffee.py

In coffee.py, we define a method to return coffee ingredients for several types of coffee, and raise an exception if an unsupported coffee type is encountered.

In test_coffee.py, we test the method to confirm it returns correct ingredients for latte.

Now let's run the test:

$ pytest test_coffee.py

collecting ...
  test_coffee.py ✓         100% ██████████

Results (0.03s):
       1 passed

Write Multple Tests with parametrize

Our coffee's get_ingredients method can process several types of coffee, therefore, we need to add more test cases to cover more conditions.

parametrize

parametrize helps pass different arguments to the test functions, making writing multple test cases more convenient.

Please read more information on parametrize at pytest - parametrize

Update the test script to inlucde an extra coffee type: cappuccino

test_coffee.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import pytest

from coffee import get_ingredients


@pytest.mark.parametrize(
    "coffee_type, ingredients", # (1)
    [
        ("latte", ["espresso", "steamed milk"]),
        ("cappuccino", ["espresso", "steamed milk", "foam"]),
    ],
)
def test_get_ingredients(coffee_type, ingredients): # (2)
    """Test Whether get_ingredients Returns Corret Ingredients"""
    assert get_ingredients(coffee_type) == ingredients
  1. Let's give this argument the name coffee_type, ingredients.
  2. Declare the argument.

Run the test script again.

$ pytest test_coffee.py

collecting ...
 test_coffee.py ✓✓         100% ██████████

Results (0.03s):
      2 passed

As you can tell from the stdout, there are totally two test cases now.

Can I use multiple parametrize

You can use multiple parametrize within one test. Here is an example from parametrize:

import pytest


@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_foo(x, y):
    pass

There will be 2x2=4 test cases.

Test the Exception

Perhaps you have already noticed, it is possible for the function to raise an exception. How do we test such case?

Test expected exceptions

Pytest provides pytest.raises to solve such case, read more about it here.

Let's add another test to assert an expected exception.

test_coffee.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import pytest

from coffee import get_ingredients


@pytest.mark.parametrize(
    "coffee_type, ingredients",
    [
        ("latte", ["espresso", "steamed milk"]),
        ("cappuccino", ["espresso", "steamed milk", "foam"]),
    ],
)
def test_get_ingredients(coffee_type, ingredients):
    """Test Whether get_ingredients Returns Corret Ingredients"""
    assert get_ingredients(coffee_type) == ingredients


def test_unsupported_coffee():
    with pytest.raises(Exception) as excinfo:
        get_ingredients("flat white")
    assert "Unsupported coffee type: flat white" in str(excinfo)
Run the test script again

$ pytest test_coffee.py

collecting ...
 test_coffee.py ✓✓✓         100% ██████████

Results (0.03s):
      3 passed

Is there another way to assert an expected exception?

Yes. Here are some tasks for you to try.

Try different ways to assert an expected exception:

  • pytest.raises(Exception, match=r"Unsupported coffee type: .*")
  • pytest.mark.xfail

Test Coverage

First, install pytest-cov.

$ pip install pytest-cov

Installing pytest-cov...

Now let's have look at the code coverage of our tests:

$ pytest --cov=coffee test_coffee.py

collecting ... 
test_coffee.py ✓✓✓       100% ██████████

-- coverage: platform darwin, python 3.8.9-final-0 --
Name        Stmts   Miss  Cover
-------------------------------
coffee.py      10      2    80%
-------------------------------
TOTAL          10      2    80%


Results (0.05s):
    3 passed

$ pytest --cov-report term-missing --cov=coffee test_coffee.py

collecting ... 
test_coffee.py ✓✓✓       100% ██████████

-- coverage: platform darwin, python 3.8.9-final-0 --
Name        Stmts   Miss  Cover   Missing
-----------------------------------------
coffee.py      10      2    80%   18, 20
-----------------------------------------
TOTAL          10      2    80%


Results (0.05s):
    3 passed

$ pytest --cov-branch --cov-report term-missing --cov=coffee test_coffee.py

collecting ... 
test_coffee.py ✓✓✓                          100% ██████████

--- coverage: platform darwin, python 3.8.9-final-0 --------
Name             Stmts   Miss Branch BrPart  Cover   Missing
------------------------------------------------------------
coffee.py           10      2      8      2    78%   18, 20
------------------------------------------------------------
TOTAL               10      2      8      2    78%


Results (0.05s):
    3 passed

What is branch coverage?

From coverage:

Where a line in your program could jump to more than one next line, coverage.py tracks which of those destinations are actually visited, and flags lines that haven’t visited all of their possible destinations.

A naive case is:

branch.py
1
2
3
4
5
def branch_cov(x: int) -> bool:
    result = False
    if x > 0:
        result = True
    return result
test_branch.py
1
2
3
4
5
from branch import branch_cov


def test_branch_cov():
    assert branch_cov(1)

Folder Structure

.
├── branch.py
└── test_branch.py

$ pytest --cov-branch --cov-report term-missing --cov=branch test_branch.py

collecting ... 
 test_branch.py ✓                       100% ██████████

--- coverage: platform darwin, python 3.8.9-final-0 ---
Name        Stmts   Miss Branch BrPart  Cover   Missing
-------------------------------------------------------
branch.py       5      0      2      1    86%   3->5
-------------------------------------------------------
TOTAL           5      0      2      1    86%


Results (0.05s):
    1 passed