Lecture Notes 8#

parametrized tests#

Consider

import green2red

def test_conversion_ratio_trivial():
    calculated = green2red.get_conversion_ratio(3, 3, 40)
    expected = 0
    assert calculated == expected

def test_conversion_ratio():
    calculated = green2red.get_conversion_ratio(3, 1.5, 40)
    expected = 0.039
    assert abs(calculated - expected) < .001

Instead of two testfunctions with explicit values we can introduce a parametrized test with the pytest framework

@pytest.mark.parametrize(
    'req, avail, cream, repl',
    [
        (3, 3, 40, 0),
        (3, 1.5, 40, 0.039),
    ]
)
def test_conversion_ratio(req, avail, cream, repl):
    calculated = green2red.get_conversion_ratio(req, avail, cream)
    expected = repl
    assert abs(calculated - expected) < .001
  • The test values replace the arguments in the test function

  • Allows for generalization to testing many values without duplication code

Likewise, in the leap year example we have

>>> def test_leap_year():
...     assert is_leap_year(2000) == True
...     assert is_leap_year(1999) == False
...     assert is_leap_year(1998) == False
...     assert is_leap_year(1996) == True
...     assert is_leap_year(1900) == False
...     assert is_leap_year(1800) == False
...     assert is_leap_year(1600) == True

vs

@pytest.mark.parametrize(
    'year, is_leap',
    [
        (2000, True),
        (1999, False),
        (1998, False),
        (1996, True),
        (1900, False),
        (1800, False),
        (1600, True),
    ]
)
def test_leap_year(year, is_leap):
    assert is_leap_year(year) == is_leap

Running the test suite in the latter case reports as if there had been separate test functions for each value

============================= test session starts ==============================

test_leap_year.py::test_leap_year[2000-True] PASSED                      [ 14%]
test_leap_year.py::test_leap_year[1999-False] PASSED                     [ 28%]
test_leap_year.py::test_leap_year[1998-False] PASSED                     [ 42%]
test_leap_year.py::test_leap_year[1996-True] PASSED                      [ 57%]
test_leap_year.py::test_leap_year[1900-False] PASSED                     [ 71%]
test_leap_year.py::test_leap_year[1800-False] PASSED                     [ 85%]
test_leap_year.py::test_leap_year[1600-True] PASSED                      [100%]

============================== 7 passed in 0.01s ===============================

test coverage#

  • used to measure how well code is tested

  • it requies an addition module, a pytest plugin

$ conda install pytest-cov

Now additional options are reported

$ pytest -v test_leap_year.py --cov leap_year 
---------- coverage: platform linux, python 3.11.0-final-0 -----------
Name           Stmts   Miss  Cover
----------------------------------
leap_year.py       4      0   100%
----------------------------------
TOTAL              4      0   100%

Sample exercises with pytest from codechalleng.es#

The tests contains pytest builtin tools,

  • during the tests pytest captures output from e.g. the print functions

  • in the test, it is possible to retrieve the printed text with the capfd fixture

test_driving.py#

from driving import allowed_driving


def test_not_allowed_to_drive(capfd):
    allowed_driving('tim', 17)
    output = capfd.readouterr()[0].strip()
    assert output == 'tim is not allowed to drive'


def test_allowed_to_drive(capfd):
    allowed_driving('bob', 18)
    output = capfd.readouterr()[0].strip()
    assert output == 'bob is allowed to drive'


def test_allowed_to_drive_other_name(capfd):
    allowed_driving('julian', 19)
    output = capfd.readouterr()[0].strip()
    assert output == 'julian is allowed to drive'

driving.py#

MIN_DRIVING_AGE = 18


def allowed_driving(name, age):
    """Print '{name} is allowed to drive' or '{name} is not allowed to drive'
       checking the passed in age against the MIN_DRIVING_AGE constant"""
    if age < MIN_DRIVING_AGE:
        print(f'{name} is not allowed to drive')
    else:
        print(f'{name} is allowed to drive')
============================= test session starts ==============================
...

test_driving.py::test_not_allowed_to_drive PASSED                        [ 33%]
test_driving.py::test_allowed_to_drive PASSED                            [ 66%]
test_driving.py::test_allowed_to_drive_other_name PASSED                 [100%]

============================== 3 passed in 0.01s ===============================

test_colors.py#

from unittest.mock import patch

from colors import print_colors

NOT_VALID = 'Not a valid color'


def call_print_colors():
    # some people prefer sys.exit instead of break
    try:
        print_colors()
    except SystemExit:
        pass


@patch("builtins.input", side_effect=['quit'])
def test_straight_quit(input_mock, capsys):
    # user only enter quit, program prints bye and breaks loop
    call_print_colors()
    actual = capsys.readouterr()[0].strip()
    expected = 'bye'
    assert actual == expected


@patch("builtins.input", side_effect=['blue', 'quit'])
def test_one_valid_color_then_quit(input_mock, capsys):
    # user enters blue = valid color so print it
    # then user enters quit so break out of loop = end program
    call_print_colors()
    actual = capsys.readouterr()[0].strip()
    expected = 'blue\nbye'
    assert actual == expected


@patch("builtins.input", side_effect=['Blue', 'quit'])
def test_title_cased_color_is_fine_too(input_mock, capsys):
    # user enters Blue = valid color, program needs to lowercase it
    # then user enters quit so break out of loop = end program
    call_print_colors()
    actual = capsys.readouterr()[0].strip()
    expected = 'blue\nbye'
    assert actual == expected


@patch("builtins.input", side_effect=['green', 'quit'])
def test_one_invalid_color_then_quit(input_mock, capsys):
    # user enters green which is not in VALID_COLORS so continue the loop,
    # user then enters quit so loop breaks (end function / program)
    call_print_colors()
    actual = capsys.readouterr()[0].strip()
    expected = f'{NOT_VALID}\nbye'
    assert actual == expected


@patch("builtins.input", side_effect=['white', 'red', 'quit'])
def test_invalid_then_valid_color_then_quit(nput_mock, capsys):
    # white is not a valid color so continue the loop,
    # then user enters red which is valid so print it, then quit
    call_print_colors()
    actual = capsys.readouterr()[0].strip()
    expected = f'{NOT_VALID}\nred\nbye'
    assert actual == expected


@patch("builtins.input", side_effect=['yellow', 'orange', 'quit'])
def test_valid_then_invalid_color_then_quit(input_mock, capsys):
    # yellow is a valid color so print it, user then enters orange
    # which is not a valid color so continue loop, lastly user
    # enters quit so exit loop = reaching end function / program
    call_print_colors()
    actual = capsys.readouterr()[0].strip()
    expected = f'yellow\n{NOT_VALID}\nbye'
    assert actual == expected
  • Here we also have the patch function from the unittest.mock library.

  • It replaces defined library calls in the tested function (here input: save command line entry)

  • Solution is less complicated than the tests

  • An infinite loop asking for input is finished when the input value is quit

VALID_COLORS = ['blue', 'yellow', 'red']


def print_colors():
    """In the while loop ask the user to enter a color,
       lowercase it and store it in a variable. Next check: 
       - if 'quit' was entered for color, print 'bye' and break. 
       - if the color is not in VALID_COLORS, print 'Not a valid color' and continue.
       - otherwise print the color in lower case."""
    while True:
        color = input("Enter a color:").lower()
        if color == 'quit':
            print('bye')
            break
        elif color not in VALID_COLORS:
            print("Not a valid color")
        else:
            print(color)

if __name__ == "__main__":
    print_colors()
============================= test session starts ==============================
...

test_colors.py::test_straight_quit PASSED                                [ 16%]
test_colors.py::test_one_valid_color_then_quit PASSED                    [ 33%]
test_colors.py::test_title_cased_color_is_fine_too PASSED                [ 50%]
test_colors.py::test_one_invalid_color_then_quit PASSED                  [ 66%]
test_colors.py::test_invalid_then_valid_color_then_quit PASSED           [ 83%]
test_colors.py::test_valid_then_invalid_color_then_quit PASSED           [100%]

============================== 6 passed in 0.01s ===============================

Running the code

$ python colors.py 
Enter a color:red
red
Enter a color:Blue
blue
Enter a color:quit
bye