Getting started with pytest

Pytest is my preferred Python testing library. It makes simple tests
incredibly easy to write, and is full of advanced features (and tons of plugins)
that help with more advanced testing scenarios.

To demonstrate the basics, I’m going to walk through how I’d solve the first
couple cryptopals challenges in a test-driven style, using py.test.

Spoiler alert:

I’m going to spoil the first challenge, and maybe a bit of the second,
below. If you want to work through them yourself, do that before reading the
rest of this post.

Installation and a first test

Installation is typical:

$ pip install pytest

Note

I’m using Python 3.5, and I’m doing all this in a virtualenv. If you don’t
have Python installed, or don’t already know how to use pip and
virtualenv, check out The Hitchiker’s Guide to Python for a good
installation guide.

The first challenge asks us to convert a hex-encoded string to base64. I’ll
start by writing a test to represent the challenge. py.test by default looks
for tests in a files named something like test_whatever.py, so I’ll make a
test_set1.py and write my test there:

import base64

def test_challenge1():
    given = "49276d206b696c6c696e6720796f757220627261696e206c696b65206120706f69736f6e6f7573206d757368726f6f6d"
    expected = b"SSdtIGtpbGxpbmcgeW91ciBicmFpbiBsaWtlIGEgcG9pc29ub3VzIG11c2hyb29t"
    assert base64.b64encode(bytes.fromhex(given)) == expected

Did I get it right?

$ py.test
=============================================== test session starts ================================================
platform darwin -- Python 3.5.2, pytest-3.0.4, py-1.4.31, pluggy-0.4.0
rootdir: /Users/jacobkaplan-moss/c/pytest-blog, inifile:
collected 1 items

test_set1.py .

Yes, I haven’t really written any code yet: the first challenge is super-simple
in Python, which can parse a hex-encoded string to a bytestring using
bytes.fromhex, and has base64 encoding build-in as the base64 module.

However, this demonstrates the “simple” part of pytest: tests are just simple
functions named test_whatever(), and rather than having than a bunch of
assert methods (assertEqual, assertNotEqual, assertAlmostEqual), you
just write simple assert statements.

A second, more realistic testing situation

To see a more complete example, I’ll solve the second challenge, which asks to
implement a function that XORs two fixed-length buffers. In test-driven style,
I’ll write the test first:

from cryptopals import fixed_xor

def test_challenge2():
    bs1 = bytes.fromhex("1c0111001f010100061a024b53535009181c")
    bs2 = bytes.fromhex("686974207468652062756c6c277320657965")
    assert fixed_xor(bs1, bs2).hex() == "746865206b696420646f6e277420706c6179"

In typical test-driven style, I’ll now immediately run tests:

$ py.test

====================================================== ERRORS ======================================================
__________________________________________ ERROR collecting test_set1.py ___________________________________________
ImportError while importing test module '/Users/jacobkaplan-moss/c/pytest-blog/test_set1.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
test_set1.py:2: in <module>
    from cryptopals import fixed_xor
E   ImportError: No module named 'cryptopals'
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 errors during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
1 error in 0.12 seconds

As expected, I get an error — I haven’t created the cryptopals module
the test tries to import. This error looks different from a failed test
because this is happening during what pytest calls the “collection” phase.
This is when pytest walks through your files, looking for test modules
(files named test_whatever.py) and test functions (def test_whatever()).
For more on how pytest discovers tests (and how to customize it),
see conventions for Python test discovery.

I’ll now stub out this function, mostly to demonstrate what test failure looks
like. In cryptopals.py, I wrote:

def fixed_xor(bs1, bs2):
    return b''

And, as expected, I get a failure:

$ py.test -q
.F
===================================================== FAILURES =====================================================
_________________________________________________ test_challenge2 __________________________________________________

    def test_challenge2():
        bs1 = bytes.fromhex("1c0111001f010100061a024b53535009181c")
        bs2 = bytes.fromhex("686974207468652062756c6c277320657965")
>       assert fixed_xor(bs1, bs2).hex() == "746865206b696420646f6e277420706c6179"
E       assert '' == '746865206b696420646f6e277420706c6179'
E         + 746865206b696420646f6e277420706c6179

test_set1.py:12: AssertionError
1 failed, 1 passed in 0.04 seconds

(I’m using -q — short for --quiet — to get slightly less output.)

I love the way that this highlights the line where the test failed on, and
shows me the values of what that assert statement ran on. pytest is
doing some tremendously dark black magic to make this happen, and the
result is super-great.

Once I write the correct code (omitted here in the spirit of keeping these
challenges challenging), I should see the following:

$ py.test -q
..
2 passed in 0.01 seconds

A taste of more advanced pytest: parameterized test functions

For a final example, as a way of looking at a slightly more complex use of
pytest, I want to write a few more test to check what happens when
my fixed_xor function gets fed bytestrings of different lengths. The
challenge only says that the function should “takes two equal-length buffers”,
but doesn’t specify what happens when those buffers aren’t the same length.
So, I made the decision that the result should be the length of the shortest
bytestring (mostly because that makes the function easier to write).

To test this properly, I should test against a few different scenarios:
bs1 being shorter than bs2, len(bs2) < len(bs1), and where
either bs1 or bs2 are empty — corner cases are always where bugs lurk!
I could write four more test functions, but that’s repetitive. So I’ll
turn to parameterized test functions:

import pytest

@pytest.mark.parametrize("in1, in2, expected", [
    ("1c011100", "686974207468652062756c6c277320657965", "74686520"),
    ("1c0111001f010100061a024b53535009181c", "68697420", "74686520"),
    ("", "68697420", ""),
    ("1c011100", "", ""),
    ("", "", "")
])
def test_challenge2_mismatching_lengths(in1, in2, expected):
    bs1 = bytes.fromhex(in1)
    bs2 = bytes.fromhex(in2)
    assert fixed_xor(bs1, bs2) == bytes.fromhex(expected)

This is the general pattern for doing more advanced work in pytest:
use a decorator to somehow annotate or modify the test function to do
something special. Here, the parametrize decorator lets me specify a list
of arguments to be passed to the test function; the test function will then
be run once for each set of parameters. Notice what happens when I run the
tests:

$ py.test -q
.......
7 passed in 0.01 seconds

Rather than just showing test_challenge2_mismatching_lengths as a single
test, we see five tests — one for each example. Because each set of
parameters shows up as a separate case, if I add another example designed
to deliberately fail, I’ll just see that one failure, and know exactly what
it was:

$ py.test -q
.......F
===================================================== FAILURES =====================================================
_________________________ test_challenge2_mismatching_lengths[1c011100-68697420-12345678] __________________________

in1 = '1c011100', in2 = '68697420', expected = '12345678'

    @pytest.mark.parametrize("in1, in2, expected", [
        ("1c011100", "686974207468652062756c6c277320657965", "74686520"),
        ("", "68697420", ""),
        ("", "68697420", ""),
        ("1c011100", "", ""),
        ("", "", ""),
        ("1c011100", "68697420", "12345678"),
    ])
    def test_challenge2_mismatching_lengths(in1, in2, expected):
        bs1 = bytes.fromhex(in1)
        bs2 = bytes.fromhex(in2)
>       assert fixed_xor(bs1, bs2) == bytes.fromhex(expected)
E       assert b'the ' == b'x124Vx'
E         At index 0 diff: 116 != 18
E         Full diff:
E         - b'the '
E         + b'x124Vx'

test_set1.py:26: AssertionError
1 failed, 7 passed in 0.05 seconds

pytest is full of niceties like this — different ways to easily manage
setup/teardown scenarios, ways to share resources between different test
modules, tons of options on how to organize and factor test code, ways
to group and mark tests, and so on. It’s a great library that really makes
writing test code easy and pleasant. I hope you’ll check it out!