Quick intro to unit-testing with pytest

Make sure that you install:

!pip install pytest pytest-regtest pytest-cov mock
Requirement already satisfied: pytest in /Users/uweschmitt/Projects/pytest-intro/lib/python3.6/site-packages
Requirement already satisfied: pytest-regtest in /Users/uweschmitt/Projects/pytest-intro/lib/python3.6/site-packages
Collecting pytest-cov
  Using cached pytest_cov-2.5.1-py2.py3-none-any.whl
Collecting mock
  Using cached mock-2.0.0-py2.py3-none-any.whl
Requirement already satisfied: py>=1.4.33 in /Users/uweschmitt/Projects/pytest-intro/lib/python3.6/site-packages (from pytest)
Requirement already satisfied: setuptools in /Users/uweschmitt/Projects/pytest-intro/lib/python3.6/site-packages (from pytest)
Collecting coverage>=3.7.1 (from pytest-cov)
  Using cached coverage-4.4.1-cp36-cp36m-macosx_10_10_x86_64.whl
Requirement already satisfied: six>=1.9 in /Users/uweschmitt/Projects/pytest-intro/lib/python3.6/site-packages (from mock)
Collecting pbr>=0.11 (from mock)
  Using cached pbr-3.1.1-py2.py3-none-any.whl
Installing collected packages: coverage, pytest-cov, pbr, mock
Successfully installed coverage-4.4.1 mock-2.0.0 pbr-3.1.1 pytest-cov-2.5.1

About pytest

  • pytest finds the test functions for you
  • runs them
  • prints helpful diagnostic information if tests fail
  • offers helpful additional functionalities (fixtures, ...) which support testing

Setup

# create fresh folders
!rm -rf tests
!rm -rf project
!mkdir -p tests
!mkdir -p project

# folders are python packages
!touch tests/__init__.py
!touch project/__init__.py

# to support "import project" from within the "tests" folder we manipulate the
# lookup path list. the correct way to this is to setup a propper python package, see 
# https://siscourses.ethz.ch/python_packaging_hands_on/hands_on_python_packaging.html#1

import sys, os
sys.path.append(os.path.abspath("project"))
import project
%%file project/algorithm.py

def add(a, b):
    return a + b

def sub(a, b):
    return a - b
Writing project/algorithm.py

First test file

%%file tests/test_simple.py

from project.algorithm import add

def test_one():
    assert add(1, 1) == 2
    
def test_two():
    """ this test will fail !"""
    a = [1, 2, 3]
    b = [3, 4]
    assert add(a, b) == [1, 2, 3, 4]
Writing tests/test_simple.py

First test run

The flag -v lists every test function on a separate line:

!py.test -v tests
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /Users/uweschmitt/Projects/pytest-intro/bin/python3.6
cachedir: .cache
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 2 items                                                               

tests/test_simple.py::test_one PASSED
tests/test_simple.py::test_two FAILED

=================================== FAILURES ===================================
___________________________________ test_two ___________________________________

    def test_two():
        """ this test will fail !"""
        a = [1, 2, 3]
        b = [3, 4]
>       assert add(a, b) == [1, 2, 3, 4]
E       assert [1, 2, 3, 3, 4] == [1, 2, 3, 4]
E         At index 3 diff: 3 != 4
E         Left contains more items, first extra item: 4
E         Full diff:
E         - [1, 2, 3, 3, 4]
E         ?           ---
E         + [1, 2, 3, 4]

tests/test_simple.py:11: AssertionError
====================== 1 failed, 1 passed in 0.04 seconds ======================

Comment: You see that the red lines provide a lot of context information why this check failed

Lets fix our test

%%file tests/test_simple.py

from project.algorithm import add

def test_one():
    assert add(1, 1) == 2
    
def test_two():
    """ this test will fail !"""
    a = [1, 2, 3]
    b = [3, 4]
    assert add(a, b) == [1, 2, 3, 3, 4]
Overwriting tests/test_simple.py
!pytest -v tests
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /Users/uweschmitt/Projects/pytest-intro/bin/python3.6
cachedir: .cache
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 2 items                                                               

tests/test_simple.py::test_one PASSED
tests/test_simple.py::test_two PASSED

=========================== 2 passed in 0.01 seconds ===========================

pytest Fixtures

For implementing test fixtures pytest offers a different approach to "classic" setup and tear-down methods based on function arguments.

Here we use a fixture tmpdir which is part of pytest:

%%file tests/test_fixtures.py 

def test_files(tmpdir):
    print()
    print()
    print("tmpdir is", type(tmpdir))
    print("temp folder for this test is", tmpdir.strpath)
    print()
    
Writing tests/test_fixtures.py

The "-s" flag shows the output from the print statements:

!py.test -vs tests
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /Users/uweschmitt/Projects/pytest-intro/bin/python3.6
cachedir: .cache
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 3 items                                                               

tests/test_fixtures.py::test_files 

tmpdir is <class 'py._path.local.LocalPath'>
temp folder for this test is /private/var/folders/k8/zfp7dvcs1m326gz1brql1tv80000gn/T/pytest-of-uweschmitt/pytest-302/test_files0

PASSED
tests/test_simple.py::test_one PASSED
tests/test_simple.py::test_two PASSED

=========================== 3 passed in 0.03 seconds ===========================

We now create our own fixture to mimic setup / teardown

%%file tests/test_fixtures.py 

import pytest
import os

@pytest.fixture
def temp_text_file(tmpdir):
    
    # setup
    
    path = tmpdir.join("test_text_file.txt").strpath
    with open(path, "w") as fh:
        print("line 1", file=fh)
        print("line 2", file=fh)
        
    # pass it to the test function
    yield path
    
    # now test function is done, do some cleanup:
    os.remove(path)
    

def test_files(temp_text_file):
    lines = open(temp_text_file, "r").readlines()
    assert lines == ["line 1\n", "line 2\n"]
Overwriting tests/test_fixtures.py
!py.test -v tests/
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /Users/uweschmitt/Projects/pytest-intro/bin/python3.6
cachedir: .cache
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 3 items                                                               

tests/test_fixtures.py::test_files PASSED
tests/test_simple.py::test_one PASSED
tests/test_simple.py::test_two PASSED

=========================== 3 passed in 0.02 seconds ===========================

Regression tests

Regression tests do not test for correct results but checks if known and acknowledged results change.

The pytest-regtest plug-in for pytest supports this by offering a regtest fixture (it is not a fixture in its strict sense, but is used the same way as pytest implements fixtures). This "fixture" works like a file handle:

%%file tests/test_for_regressions.py


from project.algorithm import add

def test_1(regtest):
    result = add("12345", "6789")
    print(result, file=regtest)
    
    result = add([1, 2, 3], [4, 5, 6])
    print(result, file=regtest)
Writing tests/test_for_regressions.py

The first time we run the test it will fail, because implemented now how to record the results, but did not approve results yet:

!pytest -v tests
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /Users/uweschmitt/Projects/pytest-intro/bin/python3.6
cachedir: .cache
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 4 items                                                               

tests/test_fixtures.py::test_files PASSED
tests/test_for_regressions.py::test_1 FAILED
tests/test_simple.py::test_one PASSED
tests/test_simple.py::test_two PASSED

=================================== FAILURES ===================================
____________________________________ test_1 ____________________________________
file /Users/uweschmitt/Projects/pytest-intro/src/tests/test_for_regressions.py, line 5
  def test_1(regtest):
E       
>       Regression test failed
>       checked against /Users/uweschmitt/Projects/pytest-intro/src/tests/_regtest_outputs/test_for_regressions.test_1.out
>       
>       
>       --- is
>       +++ tobe
>       @@ -1,3 +1 @@
>       -123456789
>       -[1, 2, 3, 4, 5, 6]
>       

/Users/uweschmitt/Projects/pytest-intro/src/tests/test_for_regressions.py:5
====================== 1 failed, 3 passed in 0.02 seconds ======================

We check this output (lines starting with -). And if we regard it as correct we approve this as follows:

!pytest -v --regtest-reset tests/
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /Users/uweschmitt/Projects/pytest-intro/bin/python3.6
cachedir: .cache
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 4 items                                                               

tests/test_fixtures.py::test_files PASSED
tests/test_for_regressions.py::test_1 PASSED
tests/test_simple.py::test_one PASSED
tests/test_simple.py::test_two PASSED

=========================== 4 passed in 0.03 seconds ===========================

And if we run the tests again everything is fine now:

!pytest -v tests/
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /Users/uweschmitt/Projects/pytest-intro/bin/python3.6
cachedir: .cache
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 4 items                                                               

tests/test_fixtures.py::test_files PASSED
tests/test_for_regressions.py::test_1 PASSED
tests/test_simple.py::test_one PASSED
tests/test_simple.py::test_two PASSED

=========================== 4 passed in 0.02 seconds ===========================

Lets break the regression test by modifying the test file:

%%file tests/test_for_regressions.py

from project.algorithm import add

def test_1(regtest):
    result = add("12345", "678910")
    print(result, file=regtest)
    result = add([1, 2, 3], [4, 5, 6])
    print(result, file=regtest)   
Overwriting tests/test_for_regressions.py
!pytest -v tests/
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /Users/uweschmitt/Projects/pytest-intro/bin/python3.6
cachedir: .cache
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 4 items                                                               

tests/test_fixtures.py::test_files PASSED
tests/test_for_regressions.py::test_1 FAILED
tests/test_simple.py::test_one PASSED
tests/test_simple.py::test_two PASSED

=================================== FAILURES ===================================
____________________________________ test_1 ____________________________________
file /Users/uweschmitt/Projects/pytest-intro/src/tests/test_for_regressions.py, line 4
  def test_1(regtest):
E       
>       Regression test failed
>       checked against /Users/uweschmitt/Projects/pytest-intro/src/tests/_regtest_outputs/test_for_regressions.test_1.out
>       
>       
>       --- is
>       +++ tobe
>       @@ -1,3 +1,3 @@
>       -12345678910
>       +123456789
>       [1, 2, 3, 4, 5, 6]
>       

/Users/uweschmitt/Projects/pytest-intro/src/tests/test_for_regressions.py:4
====================== 1 failed, 3 passed in 0.02 seconds ======================

The line starting with - tells the current result, the line with + is the expected result

The recorded outputs of the regtest folders are in the folder tests/_regtest_output, so don't forget to add them to your version control system.

!pytest -v --regtest-reset tests/
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /Users/uweschmitt/Projects/pytest-intro/bin/python3.6
cachedir: .cache
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 4 items                                                               

tests/test_fixtures.py::test_files PASSED
tests/test_for_regressions.py::test_1 PASSED
tests/test_simple.py::test_one PASSED
tests/test_simple.py::test_two PASSED

=========================== 4 passed in 0.02 seconds ===========================

Mocking objects

%%file tests/test_with_mock.py

import os
import mock

def test_with_mock():
    
    with mock.patch("os.getcwd") as my_mock:
        my_mock.return_value = "here"
        
        assert os.getcwd() == "here"
Writing tests/test_with_mock.py
!pytest -v tests
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /Users/uweschmitt/Projects/pytest-intro/bin/python3.6
cachedir: .cache
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 5 items                                                               

tests/test_fixtures.py::test_files PASSED
tests/test_for_regressions.py::test_1 PASSED
tests/test_simple.py::test_one PASSED
tests/test_simple.py::test_two PASSED
tests/test_with_mock.py::test_with_mock PASSED

=========================== 5 passed in 0.03 seconds ===========================

Test coverage

!py.test --cov project --cov-report html --cov-report term-missing tests/
============================= test session starts ==============================
platform darwin -- Python 3.6.0, pytest-3.2.3, py-1.4.34, pluggy-0.4.0
rootdir: /Users/uweschmitt/Projects/pytest-intro/src, inifile:
plugins: regtest-0.15.1, cov-2.5.1
collected 5 items                                                               

tests/test_fixtures.py .
tests/test_for_regressions.py .
tests/test_simple.py ..
tests/test_with_mock.py .

---------- coverage: platform darwin, python 3.6.0-final-0 -----------
Name                   Stmts   Miss  Cover   Missing
----------------------------------------------------
project/__init__.py        0      0   100%
project/algorithm.py       4      1    75%   6
----------------------------------------------------
TOTAL                      4      1    75%
Coverage HTML written to dir htmlcov


=========================== 5 passed in 0.07 seconds ===========================
# This also wrote a nice HTML report to the `htmlcov` folder:
!ls -l htmlcov/index.html
-rw-r--r--  1 uweschmitt  staff  3310 Oct 21 11:36 htmlcov/index.html