Testing With Tox

One of our goals for this course is to become comfortable with the practice of writing Python that is cross-compatible. This means we want to consistently write programs that will run equally well under Python 2 and Python 3. The foundation of such programming rests on testing. Without solid tests, you can never be sure that your code actually works. Or, as we have seen before:

Untested code is broken by design

—Surely Somebody

But it isn’t enough just to test our code in one version of Python. Just because something works when run in Python 2 is no guarantee that it will work in Python 3. We really need to run our tests consistently in both environments.

But this requirement introduces once again a complication that can make the distance between development and testing larger. We want to keep that distance as small as possible. That will help to prevent us from falling back to the old habits of writing tests last, if ever. To help close the gap, tox allows us to run tests in any number of different Python environments.

Tox is based on virtualenv. It allows us to configure a project to run in a number of different environments. When we execute the tox command, it builds a virtualenv for each of the configured environments and executes our tests in each. It reports the results as they happen, so we can see quickly the fruits of our labor.

Installation

We are going to work today on setting up tox to test our Ackermann Function project in both Python 2.7 and Python 3.5. By the end of the exercise, we’ll be able to assert with confidence that our code is compatible across both versions of Python.

We’ll begin by returning to the directory where we first created our Ackermann project. Then we will activate the virtualenv we’ve been working in this week:

Banks:~ cewing$ cd path/to/tdd-play
Banks:tdd-play cewing$ source bin/activate
[tdd-play]
Banks:tdd-play cewing$

Our next step is to install the tox package. We could do this directly with pip. But as we learned yesterday, it’s better for us to declare the dependencies of our packages in setup.py. That way, we can allow python packaging tools to install them for us.

Our package is certainly not going to depend on tox just to be installed. Really, tox is a dependency for our tests. Remember, we can add optional dependencies using the concept of setuptools extras. And we have alread created a test extra so we could depend on pytest and pytest-xdist. Let’s add tox as a new testing dependency for our distribution:

# in setup.py

extras_require={'test': ['pytest', 'pytest-xdist', 'tox']},

Now that we’ve updated the requirements for our test extra, let’s re-install our distribution with pip and allow it to resolve the new dependency:

[tdd-play]
Banks:tdd-play cewing$ pip install -e .[test]
Obtaining file:///Users/cewing/projects/training/codefellows/tests/tdd-play
...
Successfully installed ackermann-0.1 pluggy-0.3.1 tox-2.3.1 virtualenv-14.0.6
[tdd-play]
Banks:tdd-play cewing$

Great! Now we are ready to begin configuring our project to use tox.

Configuration

Tox uses ini-style configuration files to manage settings for testing. In Python, support for reading .ini files is provided by the configparser module (in Python 2, it’s called ConfigParser). The format supports settings specified in name = value pairs, one to a line. The settings may be organized in sections, which are delineated by [sectionname] in square brackets.

In order to configure our project to use tox, we must create a file called tox.ini at the top level of our project, adjacent to our setup.py file:

[tdd-play]
Banks:tdd-play cewing$ touch tox.ini

Global Configuration

Our first step in configuring tox is to tell it which versions of Python we will want to test. Tox can run tests in any version of Python 2 starting with 2.6, any version of Python 3 starting with 3.2, in jython (java-based Python interpreter) and pypy (python written in python). We want to use Python 2.7 and Python 3.5, so we add the following to our tox.ini file:

[tox]
envlist = py27, py35

In order for tox to function correctly when we do so, we must have access to Python executables for each named version. By default tox will look for executables named python2.7, python3.5 etc., but we can control that with the per-environment configuration settings <tox-config-perenv> described below.

Settings the [tox] section of tox.ini are global settings. They control the over-all operation of tox within our project. There are a number of other global settings available. But that will get us started for today.

Per-Environment Configuration

We must also set up configuration for the testing environments that will be built to run our tests. Configuration that applies to all testing environments listed in envlist goes in the [testenv] section. If you have any configuration that applies only to one of the environments, you can place it in a section called [testenv:<envname>]. The <envname> must match one of the environment names listed in envlist.

Our needs for this project are pretty simple. We don’t need anything particularly complex or different per environment. Let’s add the following to our tox.ini file:

[testenv]
commands = py.test
deps =
    pytest

The commands setting allows us to specify exactly the command we want to use to run our tests. The deps setting allows us to specify dependencies our tests will require. It is essentially the same as our [test] extra, but we don’t need to provide tox (because tox is running the tests) or pytest-xdist (since we are not doing TDD here).

Another potentially useful configuration setting for testing environments is basepython. This setting takes a name (which must be available in $PATH) or an absolute path to the Python executable which will be used for the specified environment. This setting should not be used in the [testenv] shared configuration section, but only in a [testenv:<envname>] section.

There are plenty more options available to use per testing environment, but these will get us started today.

The full tox.ini file for our project:

[tox]
envlist = py27, py35

[testenv]
commands = py.test
deps =
    pytest

Execution

Now that everything is set with our configuration, we can go ahead and run our tests. To do so, invoke the tox command, which should be available in our virtualenv. You will see significant output as tox builds the virtual environments for each testenv, installs requirements, and then runs the tests and reports the outcomes:

[tdd-play]
Banks:tdd-play cewing$ tox
GLOB sdist-make: /Users/cewing/projects/training/codefellows/tests/tdd-play/setup.py
py27 create: /Users/cewing/projects/training/codefellows/tests/tdd-play/.tox/py27
py27 installdeps: pytest
py27 inst: /Users/cewing/projects/training/codefellows/tests/tdd-play/.tox/dist/ackermann-0.1.zip
py27 installed: ackermann==0.1,py==1.4.31,pytest==2.8.7,wheel==0.29.0
py27 runtests: PYTHONHASHSEED='3870038194'
py27 runtests: commands[0] | py.test
======================================== test session starts ========================================
platform darwin -- Python 2.7.11, pytest-2.8.7, py-1.4.31, pluggy-0.3.1
rootdir: /Users/cewing/projects/training/codefellows/tests/tdd-play, inifile:
collected 21 items

src/test_ack.py .....................

===================================== 21 passed in 0.19 seconds =====================================
py35 create: /Users/cewing/projects/training/codefellows/tests/tdd-play/.tox/py35
py35 installdeps: pytest
py35 inst: /Users/cewing/projects/training/codefellows/tests/tdd-play/.tox/dist/ackermann-0.1.zip
py35 installed: ackermann==0.1,py==1.4.31,pytest==2.8.7,wheel==0.29.0
py35 runtests: PYTHONHASHSEED='3870038194'
py35 runtests: commands[0] | py.test
======================================== test session starts ========================================
platform darwin -- Python 3.5.1, pytest-2.8.7, py-1.4.31, pluggy-0.3.1
rootdir: /Users/cewing/projects/training/codefellows/tests/tdd-play, inifile:
collected 21 items

src/test_ack.py .....................

===================================== 21 passed in 0.20 seconds =====================================
______________________________________________ summary ______________________________________________
  py27: commands succeeded
  py35: commands succeeded
  congratulations :)
[tdd-play]
Banks:tdd-play cewing$

Make sure we see something like that in your terminal. If we see test failures in either version, we return to our package and update our code. We will use the TDD principles we established earlier in this module. Then we can re-run the tests with tox once we believe the fix to have been made. When all our tests are passing in all the environments we have specified, we can check our code in to GitHub and go home!

Caching

Tox saves time on running tests by creating a working directory where it keeps the virtualenvs it creates for running tests. This amounts to a simple cache of the packages installed for each environment. If you suspect that things are out-of-whack with the installed environments you can force them to be re-built using the --rebuild (or -r) argument to tox:

[tdd-play]
Banks:tdd-play cewing$ tox -r

Wrap-Up

We’ve learned a lot here.

  • We know how to install tox as a test dependency for our packages.
  • We know how to configure tox to run tests in multiple Python environments.
  • We know how to configure specific environments individually.
  • We know now to run our tests and to force a rebuilding of the test environments.

That’s about enough for now. You’ll be required to use this knowledge in your homework tonight (and going forward from here).