6 minutes
Pytest tricks
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é”.
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)
def test_missing_conf_file():
with pytest.raises(InvalidConfig):
load_config('does-not-exist.json')
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)
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:
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:
====================================================================================================== 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
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:
@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()
:
@pytest.fixture(scope='module')
def user():
print("Creating user")
return User('Python', 'Awesome')
...
Tagger ses tests avec des fixtures
Exemple:
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:
pytest -v -m strings
Skipper un test (grâce à un tag)
Simple skip
Exemple:
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:
pytest -v
skipif
Exemple:
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:
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:
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:
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:
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.
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:
pip install pytest-xdist
Puis pour exécuter les tests en parallèle, il faut spécifier l’option suivante au module pytest:
python -m pytest -v tests/ -n auto
# ou python -m pytest -v tests/ -n 2
Ajouter du code coverage
Installer le paquet suivant:
pip install pytest-cov
Puis exécuter la commande:
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:
python -m pytest -v --lf
# ou pytest -v --lf
Display print statements:
python -v -s
# ou pytest -v -s
Run one specific test:
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:
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:
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:
python -m pytest -v -x
# ou pytest -v -x
Stopper l’exécution des tests après x failed tests:
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:
# 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
.
tests/
helpers/
utils.py
...
conftest.py
setup.cfg
Dans le fichier conftest.py
:
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), 'helpers'))
Dans le fichier setup.cfg
:
[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 tests
devient donc un module):
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
:
pytest_plugins = [
"tests.unit.fixtures.some_stuff",
]
Et un fichier de fixture tests/unit/fixtures/some_stuff.py
peut être défini ainsi:
import pytest
@pytest.fixture
def foo():
return 'foobar'
(Il faudra également créer un fichier __init__.py
)
Outils
- Reporting: Allure