Mocker l’ouverture d’un fichier et tester les exceptions

Pour la fonction suivante, il est possible d’exécuter différents tests.
Les 3 exemples de tests ci-dessous montrent:

  • Comment vérifier qu’un appel de fonction raise une exception
  • Comment vérifier qu’un fichier de config est valide
  • Comment créer un fichier de config temporaire “bouchonné”.
1
2
3
4
5
6
7
8
9
10
11
12
import os 
import json

class InvalidConfig(Exception):
pass

def load_config(config_path):
try:
with open(config_path, 'r') as json_file:
return json.load(json_file)
except (OSError, IOError, json.JSONDecodeError) as exception:
raise InvalidConfig(exception)
1
2
3
def test_missing_conf_file():
with pytest.raises(InvalidConfig):
load_config('does-not-exist.json')
1
2
3
4
5
6
7
8
def test_invalid_conf_file(tmpdir):
json_content = (
'%%%%%%%%%%\n'
)
tmp_config = tmpdir.join('temp-config_file.json')
tmp_config.write_text(json_content, encoding='utf-8')
with pytest.raises(InvalidConfig):
load_config(tmp_config.strpath)
1
2
3
4
5
6
7
8
9
10
11
12
def test_valid_conf_file(tmpdir):
json_content = (
'{\n'
'"hello": "olivier", \n'
'"titi": "tata"\n'
'}\n'
)
tmp_config = tmpdir.join('temp-config_file.json')
tmp_config.write_text(json_content, encoding='utf-8')
parsed_config = load_config(tmp_config.strpath)
assert parsed_config['hello'] == 'olivier'
assert parsed_config['titi'] == 'tata'

Parametrize tests with fixtures

Option 1

Exemple:

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

def integer_to_binary(input, zero_pad_length=0):
"""
Converts an integer to a zero-padded binary string.
"""
return "{{:0{}b}}".format(zero_pad_length).format(input)

@pytest.fixture(params=[{"input": 8, "expectedResult": "1000"}, {"input": 5, "expectedResult": "0"}, {"input": 1, "expectedResult": "1"}])
def testCase(request):
return request.param

def test_my_converter(testCase):
result = integer_to_binary(testCase["input"])
assert result == testCase["expectedResult"]

Output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
====================================================================================================== test session starts ======================================================================================================
platform darwin -- Python 3.7.3, pytest-4.5.0, py-1.8.0, pluggy-0.11.0 -- /Users/olivier/Dev/.venv/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/olivier/Dev/
collected 3 items

test_tmp.py::test_my_converter[testCase0] PASSED [ 33%]
test_tmp.py::test_my_converter[testCase1] FAILED [ 66%]
test_tmp.py::test_my_converter[testCase2] PASSED [100%]

=========================================================================================================== FAILURES ============================================================================================================
_________________________________________________________________________________________________ test_my_converter[testCase1] __________________________________________________________________________________________________

testCase = {'expectedResult': '0', 'input': 5}

def test_my_converter(testCase):
result = integer_to_binary(testCase["input"])
> assert result == testCase["expectedResult"]
E AssertionError: assert '101' == '0'
E - 101
E + 0

test_tmp.py:15: AssertionError
============================================================================================== 1 failed, 2 passed in 0.08 seconds ===============================================================================================

Option 2

Example

1
2
3
4
5
6
7
8
9
10
11
12
import pytest

def integer_to_binary(input, zero_pad_length=0):
"""
Converts an integer to a zero-padded binary string.
"""
return "{{:0{}b}}".format(zero_pad_length).format(input)


@pytest.mark.parametrize('input, expectedResult', [(8, "1000"), (5, "0"), (1, "1")])
def test_integer_to_binary(input, expectedResult):
assert expectedResult == integer_to_binary(input)

Same Output.

Scopes des fixtures

Une fixture peut avoir plusieurs scopes: test, module ou session.

Exemple de fixture au niveau des tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
@pytest.fixture()
def user():
print("Creating user")
return User('Python', 'Awesome')

def test_is_prime(user):
assert is_prime(user, 2) is True
assert is_prime(user, 3) is True
assert is_prime(user, 4) is False

def test_prime_factors(user):
assert prime_factors(user, 2) == [2]
assert prime_factors(user, 12) == [2, 2, 3]

Exemple de fixture au niveau du module:

Il suffit de changer le scope dans l’annotation `@pytest.fixture()`:

1
2
3
4
5
6
@pytest.fixture(scope='module')
def user():
print("Creating user")
return User('Python', 'Awesome')

...

Tagger ses tests avec des fixtures

Exemple:

1
2
3
4
5
6
7
8
import pytest
import math_func

@pytest.mark.strings
def test_add_strings():
result = math_func.add('Hello', ' World')
assert result == 'Hello World'
assert type(result) is str

Appeler les tests ayant un tag particulier:

1
pytest -v -m strings

Skipper un test (grâce à un tag)

Simple skip

Exemple:

1
2
3
4
5
6
7
8
import pytest
import math_func

@pytest.mark.skip(reason='do not run this test for no reason')
def test_add_strings():
result = math_func.add('Hello', ' World')
assert result == 'Hello World'
assert type(result) is str

Exécution:

1
pytest -v

skipif

Exemple:

1
2
3
4
5
6
7
8
9
import pytest
import math_func
import sys

@pytest.mark.skipif(sys.version_info < (3, 3), reason='')
def test_add_strings():
result = math_func.add('Hello', ' World')
assert result == 'Hello World'
assert type(result) is str

Exécution:

1
pytest -v

Code d’initialisation et de clôture des tests

Option 1: Setup and Teardown

Exemple de code de setup (Connection à une BDD par exemple):

Au lieu de:

1
2
3
4
5
6
7
8
import pytest

def test_olivier_data():
db = StudentDB()
db.connect('data.json')
olivier_data = db.get_data('Olivier')
assert olivier_data['id'] == 1
assert olivier_data['name'] == 'Olivier'

On peut initialiser la fonction setup_module qui sera exécutée au démarrage des tests:

On écrit plutôt:

1
2
3
4
5
6
7
8
9
10
11
import pytest

db = None
def setup_module(module):
db = StudentDB()
db.connect('data.json')

def test_olivier_data():
olivier_data = db.get_data('Olivier')
assert olivier_data['id'] == 1
assert olivier_data['name'] == 'Olivier'

Avec la fonction teardown_module on exécute du code lorsque les tests sont terminés (pour fermer la connexion avec une BDD par exemple)

Exemple:

1
2
def teardown_module(module):
db.close()

Option 2: Avec des Fixtures avec un scope module et un générateur

On peut réécrire le code précédent avec des fixtures.

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

@pytest.fixture(scope='module')
def db():
print("{}setup{}".format("-"*10, "-"*10))
db = StudentDB()
db.connect('data.json')
yield db ## yield is canceled by return
print("{}teardown{}".format("-"*10, "-"*10))
db.close()
## implicit return when not specified

def test_olivier_data(db):
olivier_data = db.get_data('Olivier')
assert olivier_data['id'] == 1
assert olivier_data['name'] == 'Olivier'

Exécuter les tests en parallèle

Le paquet suivant est nécessaire:

1
pip install pytest-xdist

Puis pour exécuter les tests en parallèle, il faut spécifier l’option suivante au module pytest:

1
2
3
python -m pytest -v tests/ -n auto

# ou python -m pytest -v tests/ -n 2

Ajouter du code coverage

Installer le paquet suivant:

1
pip install pytest-cov

Puis exécuter la commande:

1
python -m pytest -v --cov=path_to_analyze_coverage

Configuration files

  • pytest.ini (permet par exemple de définir le rootdir des tests)

  • conftest.py (exécuté automatiquement, c’est un bon endroit pour écrire des fixtures)

Useful commands:

Run last failing test:

1
2
3
python -m pytest -v --lf

# ou pytest -v --lf

Display print statements:

1
2
3
python -v -s

# ou pytest -v -s

Run one specific test:

1
2
3
4
5
6
7
8
9
python -m pytest -v -k "nom_du_test_complet_ou_regex"

# ou pytest -v -k "nom_du_test_complet_ou_regex"

# Or est également possible
# ou pytest -v -k "regex1 or regex2"

# And est également possible
# ou pytest -v -k "regex1 and regex2"

Run one specific test in a particular file:

1
2
3
python -m pytest -v mon_fichier_de_test::nom_du_test

# ou pytest -v mon_fichier_de_test::nom_du_test

Run one test file:

1
2
3
python -m pytest -v tests/votre_fichier_de_test.py

# ou pytest -v tests/votre_fichier_de_test.py

Stopper l’exécution des tests dès la première failure:

1
2
3
python -m pytest -v -x

# ou pytest -v -x

Stopper l’exécution des tests après x failed tests:

1
2
3
python -m pytest -v --maxfail=2

# ou pytest -v --maxfail=2

Voir les commandes disponibles: https://docs.pytest.org/en/latest/usage.html

Cool Pytest plugins

PLUGIN DESCRIPTION
pytest-server-fixtures Extensible server-running framework with a suite of well-known databases and webservices included: mongodb, redis, rethinkd, Jenkins, Apache httpd, Xvfb
pytest-shutil Unix shell and environment management tools
pytest-profiling Profiling plugin with tabular heat graph output and gprof support for C-Extensions
pytest-devpi-server DevPI server runnning fixture for testing package management code
pytest-pyramid-server Pyramid server fixture for running up servers in integration tests
pytest-webdriver Selenium webdriver fixture for testing web applications
pytest-virtualenv Create and teardown virtual environments, run tests and commands in the scope of the virtualenv
pytest-qt-app PyQT application fixture
pytest-listener TCP Listener/Reciever for testing remote systems
pytest-git Git repository fixture
pytest-svn SVN repository fixture
pytest-fixture-config Configuration tools for Py.test fixtures
pytest-verbose-parametrize Makes py.test’s parametrize output a little more verbose

Tricks

Créer et importer des helper functions dans les tests sans créer de package dans le dossier tests

Par exemple, vous voulez créer ceci:

1
2
3
4
5
6
7
8
9
# Dans le fichier common.py
def assert_nimporte_quoi_entre_deux_proprietes(x, y):
assert ...


# Dans tests/my_test.py
def test_something_with(x):
some_value = some_function_of_(x)
assert_nimporte_quoi_entre_deux_proprietes(x, some_value)

Créer un dossier helpers dans le répertoire tests et ajouter le path de ce dernier via pythonpath dans le fichier conftest.py.

1
2
3
4
5
6
tests/
helpers/
utils.py
...
conftest.py
setup.cfg

Dans le fichier conftest.py:

1
2
3
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), 'helpers'))

Dans le fichier setup.cfg:

1
2
[pytest]
norecursedirs=tests/helpers

Ce module sera accessible via import utils.

Avoir des modules de tests qui ont le même nom

Pour ce faire, il faut ajouter un fichier __init__.py dans le dossier tests ainsi que dans ses sous-répertoires. (Le répertoire testsdevient donc un module):

1
2
3
4
5
6
7
8
9
10
11
setup.py
mypkg/
...
tests/
__init__.py
foo/
__init__.py
test_view.py
bar/
__init__.py
test_view.py

Maintenant pytest va charger les modules comme ceci: tests.foo.test_view et tests.bar.test_view
Cela permet d’avoir des modules qui ont le même nom.

Organiser un grand nombre de fixtures

On peut par exemple ajouter les lignes suivantes dans le fichier tests/unit/conftest.py:

1
2
3
pytest_plugins = [
"tests.unit.fixtures.some_stuff",
]

Et un fichier de fixture tests/unit/fixtures/some_stuff.py peut être défini ainsi:

1
2
3
4
5
import pytest

@pytest.fixture
def foo():
return 'foobar'

(Il faudra également créer un fichier __init__.py)

Outils