How to: gluetool
tests¶
This text is a (hopefully complete) list of best practices, dos and don’ts and tips when it comes to writing
tests for gluetool
APIs, modules and other code. When writing - or reviewing - gluetool
tests, please
adhere to these rules whenever possible.
Note
These rules are not cast in stone - when we find out some are standing in our way to the most readable and usable documentation, let’s just discuss the change and change what must be changed.
py.test¶
gluetool
uses py.test
framework for its test and tox to automate the running of the tests. If you’re not
familiar with these tools, please see following links to get some idea:
Also inspecting existing tests and tox.ini
is a good way to find out how to do something, e.g. add new coverage
for your module.
How to run tests?¶
Static analysis is using coala in docker, so for full test, you need to have docker daemon running.
You can run all tests using tox
:
tox -e py27
If you want to skip coala analysis so you don’t need docker, you can run
tox -e 'py-{unit-tests,static-analysis,doctest}'
Tox also accept additional options:
python setup.py test -a "--option1 --option2=value"
tox -e py27 -- --option1 --option2=value
How to see code coverage?¶
By default, coverage measurement is disabled. To enable it, pass following options to the test runner of your choice:
--cov=gluetool --cov-report=html:coverage-report
With these options, coverage will be enabled and when test run finishes, the coverage report (in HTML) will be created
in coverage-report
directory. Simply open coverage-report/index.html
in your browser then.
Note
Coverage data are stored in .coverage
file - if you’d like to use coverage
utility to create additional
reports or filter the output to better suit your needs, feel free to do so, nothing stands in your way :)
Module tests should be in the same file¶
Tests dealing with a single module should be packed in the same file.
Test function tests one thing/code path¶
Avoid the temptation to put more different tests into a single test function. Test function should test a single feature or a code path. If you’re concerned about repeating setup/teardown code a lot, learn about fixtures bellow.
Use assert
¶
py.test
prefers to use assert
keyword to actually test values, and it promotes its use by providing really
nice and helpful formatting of failures, with pointers to places where the actual values differ from expected ones.
Sometimes it’s very useful to create a helper function that checks complex response, data or object state, using
multiple lower-level assert
instances.
Use fixtures¶
The purpose of test fixtures is to provide a fixed baseline upon which tests can reliably and repeatedly execute. pytest fixtures offer dramatic improvements over the classic xUnit style of setup/teardown functions.
—py.test documentation
They don’t lie, it’s definitely worth the effort. Pretty much every test of a module’s code begins with “get a fresh instance of a module-under-test”. You can call some function to create this instance, or you can use a fixture and simply accept this instance as a argument of your test function. And so on.
# every test function gets its own instance of gluetool.glue and the module it's testing
from . import create_module
@pytest.fixture(name='module')
def fixture_module():
return create_module(gluetool.modules.helpers.ansible.Ansible)
def test_sanity(module, tmpdir):
glue, _ = module
assert glue.has_shared('run_playbook') is True
Session fixtures belong to tests/conftest.py
.
Check exception messages with match
¶
Use pytest.raises()
parameter match
to assert exception messages whenever possible:
with pytest.raises(Exception, match=r'dummy exception'):
foo()
Be aware that match
value is actually a regular expression used to match exception’s message, therefore
use Python’s raw strings, prefixed
with r
.
Don’t be afraid of monkeypatching¶
It helps a lot with failure injection, with observing whether your code calls other functions it’s expected to call, and other useful tricks. And all patches are undone when your test function returns.
# If OSEror pops up, run_command should raise GlueError and re-use message from the original exception
def faulty_popen_enoent(*args, **kwargs):
raise OSError(errno.ENOENT, '')
monkeypatch.setattr(subprocess, 'Popen', faulty_popen_enoent)
with pytest.raises(gluetool.GlueError, match=r"^Command '/bin/ls' not found$"):
run_command(['/bin/ls'])
When your attempts lead to messy tests, cosider refactoring of the tested code¶
This can happen very often - you’d like to test a method which is way too complex, and the result is huge pile of setup/teardown code, unreadable asserts and even more complicated ways to convince the tested function to take different path, e.g. when it comes to injecting errors into its flow. In such case, consider refactoring the tested code - it’s possible it could be rewritten to more separate pieces of code (main function & several helpers) which could greatly improve the list of options you have, and it may even lead to more readable code.
MagicMock
is very handy tool¶
Don’t be afraid to use MagicMock
- its return_value
and side_effect
parameters can help a lot when it comes
to mocking mocking functions returning prepared values or raising exceptions. E.g.
monkeypatch.setattr(library, 'library_function', MagicMock(side_effect=Exception))
when library.library_function
gets called, it will raise an exception. If you need to raise an exception
with specific arguments, pass a helper function as a side effect:
def throw(*args, **kwargs):
# pylint: disable=unused-argument
raise Exception('simply bad request')
monkeypath.setattr(library, 'library_function', MagicMock(side_effect=throw))
Instead of mocking a whole function, use MagicMock
‘s return_value
:
monkeypatch.setattr(foo, 'bar', MagicMock(return_value=some_known_object))
is way more readable than:
def foo():
return some_known_object
monkeypach.setattr(foo, 'bar', foo)
Should you need more action when it comes to returned value (computing it on the fly), patching with custom function is absolutely acceptable.