How to ensure that your tests run code that you think they are running, and how to measure your coverage over multiple tox
runs (in parallel!).
src
I used to scoff when I saw Python projects that put their packages into a separate src
directory. It seems unnecessary and reminded me of Java. But it also seemed to be dying out.
Imagine my surprise when I saw cryptography
– one of Python’s most modern projects – adopt a src
directory subsequently!
That got me thinking. Though I didn’t take action until I got a bug report that my tox
and coverage
setup works only by accident: my tests didn’t run against the version of my app that got installed into tox
’s virtualenv
s. They ran against the actual directory1.
In general, that’s not a big deal. In the end, it’s the same code. The only likely problems you could miss are packaging issues (although especially those tend to be frustrating to track down). It demonstrated to me though, that there’s likely more that can go wrong than I thought and that isolating the code into a separate – un-importable – directory might be a good idea.
To achieve that, you add a where
argument to find_packages()
and tell setup()
about it:
setup(
[...]
packages=find_packages(where="src"),
package_dir={"": "src"},
)
Coping with that rather minor issue exposed me to a more interesting problem: measuring coverage over multiple tox
runs.
Combined Coverage
The combo of running your tests against various versions and configurations using tox
and measuring the coverage is popular.
In my experience though, most projects only consider the coverage for one version (depending on where you stand on the Python 2 vs 3 debate) and either ignore the coverage for other versions or push them to 100% too using # pragma nocover
.
But it’s much nicer to have the combined coverage computed over all your tox
runs like the wonderful codecov will do for you. No more guessing and reasoning; you get an accurate report on which lines and branches have been executed and which not.
This feat is easily achieved if you run you tests directly against your source directory. You add two environments to your tox
configuration: one that erases left-over coverage data from the past run and one that will combine and report the current one2:
[tox]
envlist = coverage-clean,py27,py35,pypy,coverage-report
[testenv:coverage-clean]
deps = coverage
skip_install = true
commands = coverage erase
[testenv:coverage-report]
deps = coverage
skip_install = true
commands =
coverage combine
coverage report
Now run coverage
in parallel mode and you’re done:
coverage run --parallel -m pytest tests
It gets more complicated if your tests run against the installed version of your package though. That’s because now the paths of the actually executed modules look like
.tox/py35/lib/python3.5/site-packages/attr/__init__.py
So you end up with a very long coverage output with allsite-packages
of alltox
environments.
Fortunately there is a solution in coverage
which is the little known [paths]
configuration section. It allows you to tell coverage
which paths it should consider equivalent:
[run]
branch = True
source = attr
[paths]
source =
src/attr
.tox/*/lib/python*/site-packages/attr
.tox/pypy*/site-packages/attr
Now coverage combine
will fold the coverage data of these paths together and you get what you expect:
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
src/attr/__init__.py 17 0 0 0 100%
src/attr/_compat.py 15 0 2 0 100%
src/attr/_config.py 9 0 2 0 100%
src/attr/_funcs.py 35 0 18 0 100%
src/attr/_make.py 202 0 92 0 100%
src/attr/filters.py 15 0 3 0 100%
src/attr/validators.py 33 0 12 0 100%
--------------------------------------------------------------------
TOTAL 326 0 129 0 100%
Speeding Up With Parallelization
This approach works but it relies on the order in which the environments are ran.
Thus if you want to use something like detox
that runs your tox
environments in parallel, you’ll have to make sure that cleanup and reporting run separately.
To illustrate how much of a difference detox
makes: the modest structlog
test suite in all it’s variations takes about 50s serially and about 20s when parallelized using detox
. I think ~100% faster is pretty sweet and worth coming up with a solution3.
So I wrote myself a simple tool called detoxize
that reads a list of environments from stdin
(probably tox -l
output) and prints a command line that runs tox
with the first environment, then detox
with all environments except the first and the last, and finally tox
again with the last environment:
#!/usr/bin/env python3
import sys
if __name__ == "__main__":
envs = [env.strip() for env in sys.stdin.readlines()]
print(
"tox -e " + envs[0] + "; " +
"detox -e " + ",".join(env for env in envs[1:-1]) + "; "
"tox -e " + envs[-1]
)
In other words if you have:
[tox]
envlist = coverage-clean,py27,py35,pypy,coverage-report
in your tox.ini
and pipe tox -l
into detoxize
, it will print
tox -e coverage-clean; detox -e py27,py35,pypy; tox -e coverage-report
If you set up a tox
project like I’ve sketched out before, you can run
tox -l | detoxize | sh
and you get the benefits of parallel speed together with a combined coverage report.
Summary
You can have a look at the setup.py
, tox.ini
and .coveragerc
of attrs
if you want a simple yet complete and functional example.
Footnotes
This behavior is a consequence of
py.test
adding paths tosys.path
.pytest-cov
appears to handle this better but doesn’t expose allcoverage
features you may want to use. ↩Someone should really write a
tox
plugin to make this more elegant. ↩There’s an open issue on
tox
’s bug tracker about his. Feel free to vote! ↩