Things to make writing tests easier

Chris Withers

Something to test

"Here's some data in a CSV..."

Name

Money Owed

Adam Alpha

100

Brian Beta

300

Cédric Cee

200

"...parse it and tell me who owes the most money!"

def most_owed(path):
    # read csv
    # return most maximum
    # log how long it took
    pass

here's the spec for the function

from unittest import TestCase

class Tests(TestCase):

    def test_one(self):
        most_owed('foo')
import tempfile
from unittest import TestCase

class Tests(TestCase):

    def test_parse(self):
        with tempfile.NamedTemporaryFile() as source:
            source.write(b'''\
Name,Money Owed
Adam Alpha,100
''')
            source.seek(0)
            self.assertEqual(most_owed(source.name), 'Adam Alpha')

test round one, we DON'T use csv writer:

  • too symmetrical
  • doesn't let us test malformed csv
  • what about line endings? bytes vs strings?
import csv

def most_owed(path):
    with open(path) as data:
        reader = csv.DictReader(data)
        for row in reader:
            return row['Name']

obvious problem! ...so lets round out with some more tests

    def test_max(self):
        with tempfile.NamedTemporaryFile() as source:
            source.write(b'''\
Name,Money Owed
Adam Alpha,100
Brian Beta, 300
''')
            source.seek(0)
            self.assertEqual(most_owed(source.name), 'Brian Beta')
    def test_unicode(self):
        with tempfile.NamedTemporaryFile() as source:
            source.write(bytes('''\
Name,Money Owed
C\xe9dric Cee,200
''', 'utf8'))
            source.seek(0)
            self.assertEqual(most_owed(source.name), 'C\xe9dric Cee')

Test doesn't work on Windows (re-open tempfile!)

    def test_whitespace(self):
        # data
        with tempfile.NamedTemporaryFile() as source:
            source.write(b'''\
Name,Money Owed
 Adam Alpha,\t100
''')
            source.seek(0)
            self.assertEqual(most_owed(source.name), 'Adam Alpha')
        # what about column headings?

what if we had to process a dir of files to get the answer?

import csv

def most_owed(path):

    with open(path) as data:
        reader = csv.DictReader(data)

        owed = 0
        name = None

        for row in reader:

            # what if this blows up?
            current = int(row['Money Owed'])

            if current > owed:
                owed = current
                name = row['Name']

        # how long did it take?
        return name.strip()

here's the code

  • what if we one saw zeros or negative numbers
  • what about whitespace in headers?
  • line coverage is only a start!
    def test_invalid_numbers(self):
        with tempfile.NamedTemporaryFile() as source:
            source.write(b'''\
Name,Money Owed
Adam Alpha,X
Brian Beta, 300
''')
            source.seek(0)
            self.assertEqual(most_owed(source.name), 'Brian Beta')
    def test_malformed(self):
        # should we raise our own exception?
        with tempfile.NamedTemporaryFile() as source:
            source.write(b'''\
Name,
Adam Alpha
''')
            source.seek(0)
            try:
                most_owed(source.name)
            except Exception as e:
                self.assertTrue(isinstance(e, KeyError))
                self.assertEqual(str(e), "'Money Owed'")
            else:
                self.fail('no exception raised')
import datetime
import csv, logging

log = logging.getLogger()

def most_owed(path):
    started = datetime.datetime.now()
    with open(path) as data:
        reader = csv.DictReader(data)
        owed = 0
        name = None
        for row in reader:
            value = row['Money Owed']
            try:
                current = int(value)
            except ValueError:
                log.warning(
                    'ignoring %r as not valid',
                    value
                    )
                continue
            if current > owed:
                owed = current
                name = row['Name']
    log.info('Processing took %s',
             datetime.datetime.now() - started)
    return name.strip()

what's still untested?

  • logging
  • what do the exceptions look like?

running the tests is now messy...

$ bin/python -m unittest discover
ignoring 'X' as not valid
......
-----------------------------------------------------
Ran 6 tests in 0.006s

OK
import logging
import tempfile
from unittest import TestCase

class Tests(TestCase):

    def setUp(self):
        self.logger = logging.getLogger()
        self.original = self.logger.level
        self.logger.setLevel(1000)

    def tearDown(self):
        self.logger.setLevel(self.original)

    def test_parse(self):
        with tempfile.NamedTemporaryFile() as source:
            source.write(b'''\
Name,Money Owed
Adam Alpha,100
''')
            source.seek(0)
            self.assertEqual(most_owed(source.name), 'Adam Alpha')

    def test_max(self):
        with tempfile.NamedTemporaryFile() as source:
            source.write(b'''\
Name,Money Owed
Adam Alpha,100
Brian Beta, 300
''')
            source.seek(0)
            self.assertEqual(most_owed(source.name), 'Brian Beta')

    def test_unicode(self):
        with tempfile.NamedTemporaryFile() as source:
            source.write(bytes('''\
Name,Money Owed
C\xe9dric Cee,200
''', 'utf8'))
            source.seek(0)
            self.assertEqual(most_owed(source.name), 'C\xe9dric Cee')

    def test_whitespace(self):
        # data
        with tempfile.NamedTemporaryFile() as source:
            source.write(b'''\
Name,Money Owed
 Adam Alpha,\t100
''')
            source.seek(0)
            self.assertEqual(most_owed(source.name), 'Adam Alpha')
        # what about column headings?

    def test_invalid_numbers(self):
        with tempfile.NamedTemporaryFile() as source:
            source.write(b'''\
Name,Money Owed
Adam Alpha,X
Brian Beta, 300
''')
            source.seek(0)
            self.assertEqual(most_owed(source.name), 'Brian Beta')

    def test_malformed(self):
        # should we raise our own exception?
        with tempfile.NamedTemporaryFile() as source:
            source.write(b'''\
Name,
Adam Alpha
''')
            source.seek(0)
            try:
                most_owed(source.name)
            except Exception as e:
                self.assertTrue(isinstance(e, KeyError))
                self.assertEqual(str(e), "'Money Owed'")
            else:
                self.fail('no exception raised')

copy and paste -> abstract -> tools

...but then need to test those

not only is it shorter, but it tests more!

and all the fixtures are tested

from testfixtures import (
    TempDirectory, LogCapture, Replacer, ShouldRaise, test_datetime
    )
from unittest import TestCase

class Tests(TestCase):

    def setUp(self):
        self.dir = TempDirectory()
        self.log = LogCapture()
        self.r = Replacer()
        self.r.replace('datetime.datetime', test_datetime())

    def tearDown(self):
        self.r.restore()
        self.log.uninstall()
        self.dir.cleanup()

    def test_parse(self):
        path = self.dir.write('test.csv', b'''\
Name,Money Owed
Adam Alpha,100
''')
        self.assertEqual(most_owed(path), 'Adam Alpha')
        self.log.check(('root', 'INFO', 'Processing took 0:00:10'))

    def test_max(self):
        path = self.dir.write('test.csv', b'''\
Name,Money Owed
Adam Alpha,100
Brian Beta, 300
''')
        self.assertEqual(most_owed(path), 'Brian Beta')

    def test_unicode(self):
        path = self.dir.write('test.csv', '''\
Name,Money Owed
C\xe9dric Cee,200
''', 'utf8')
        self.assertEqual(most_owed(path), 'C\xe9dric Cee')

    def test_whitespace(self):
        path = self.dir.write('test.csv', b'''\
Name,Money Owed
 Adam Alpha,\t100
''')
        self.assertEqual(most_owed(path), 'Adam Alpha')

    def test_invalid_numbers(self):
        path = self.dir.write('test.csv', b'''\
Name,Money Owed
Adam Alpha,X
Brian Beta, 300
''')
        self.assertEqual(most_owed(path), 'Brian Beta')
        self.log.check(
            ('root', 'WARNING', "ignoring 'X' as not valid"),
            ('root', 'INFO', 'Processing took 0:00:10')
            )

    def test_malformed(self):
        path = self.dir.write('test.csv', b'''\
Name,
Adam Alpha
''')
        with ShouldRaise(KeyError('Money Owed')):
            most_owed(path)

stop time, log capture, directory

mention cleanups as another way, 3.1+ though!

finally test how long it took, should do on all tests?

check error logging

7 minutes

Standard Library vs Other Libraries

Standard Library

Other Libraries

  • freedom from python version, consistent tools
  • mock/unittest2 merged in

Comparing things

class MyTests(TestCase):

    def test_something(self):
        self.assertEqual(..., ...)

still not quite there

  • rich comparision
  • register your own comparison function

Rich comparison

>>> from testfixtures import compare
>>> compare(1, 2)
Traceback (most recent call last):
 ...
AssertionError: 1 != 2
>>> compare("1234567891011", "1234567789")
Traceback (most recent call last):
...
AssertionError:
'1234567891011'
!=
'1234567789'

Rich comparison: long strings

>>> from testfixtures import compare
>>> compare("""
...         This is line 1
...         This is line 2
...         This is line 3
...         """,
...         """
...         This is line 1
...         This is another line
...         This is line 3
...         """)
Traceback (most recent call last):
 ...
AssertionError:
@@ -1,5 +1,5 @@

         This is line 1
-        This is line 2
+        This is another line
         This is line 3

whitespace/line endings options: see docs!

Rich comparison: sets

>>> from testfixtures import compare
>>> compare(set([1, 2]), set([2, 3]))
Traceback (most recent call last):
 ...
AssertionError: set not as expected:

in first but not second:
[1]

in second but not first:
[3]

Rich comparison: dicts

>>> from testfixtures import compare
>>> compare(dict(x=1, y=2, a=4), dict(x=1, z=3, a=5))
Traceback (most recent call last):
 ...
AssertionError: dict not as expected:

same:
['x']

in first but not second:
'y': 2

in second but not first:
'z': 3

values differ:
'a': 4 != 5

Rich comparison: sequences

>>> from testfixtures import compare
>>> compare([1, 2, 3], [1, 2, 4])
Traceback (most recent call last):
 ...
AssertionError: Sequence not as expected:

same:
[1, 2]

first:
[3]

second:
[4]

Comparison Helpers

  • some do, whether you like it or not!
class SomeModel:
    def __eq__(self, other):
        if isinstance(other, SomeModel):
            return True
        return False

talk through putting helper first, gets first bite of cherry

expected -> actual

Rich comparison: generators/iterators

>>> def my_gen(t):
...     i = 0
...     while i<t:
...         i += 1
...         yield i

if you unwind to tuple, was it important that it was a generator

>>> from testfixtures import generator
>>> compare(generator(1, 2, 3), my_gen(2))
Traceback (most recent call last):
 ...
AssertionError: Sequence not as expected:

same:
(1, 2)

first:
(3,)

second:
()

talk through generator helper

Unfriendly strings

from testfixtures import compare, StringComparison as S

compare(S('Starting thread \d+'),'Starting thread 132356')

Objects that don't support comparison

class SomeClass:
   def __init__(self, x, y):
       self.x, self.y = x, y
>>> from testfixtures import Comparison as C
>>> C('modue.SomeClass') == SomeClass(1, 2)
True
>>> C(SomeClass) == SomeClass(1, 2)
True
>>> C(SomeClass, x=1, y=2) == SomeClass(1, 2)
True

useful post-comparison representation:

>>> compare(C(SomeClass, x=2), SomeClass(1, 2))
Traceback (most recent call last):
 ...
AssertionError:
  <C(failed):...SomeClass>
  x:2 != 1
  y:2 not in Comparison
  </C> != <...SomeClass...>

You don't have to compare all attributes:

>>> C(SomeClass, x=1, strict=False) == SomeClass(1, 2)
True

comparisons can also be nested

Registering your own comparers

DataRow = namedtuple('DataRow', ('x', 'y', 'z'))
>>> from testfixtures.comparison import register, compare_sequence
>>> register(DataRow, compare_sequence)
>>> compare(DataRow(1, 2, 3), DataRow(1, 2, 4))
Traceback (most recent call last):
 ...
AssertionError: Sequence not as expected:

same:
(1, 2)

first:
(3,)

second:
(4,)

Standard library has better support in 3.1+:

    def test_different(self):
        self.addTypeEqualityFunc(DataRow, self.assertSequenceEqual)
        self.assertEqual(DataRow(1, 2, 3), DataRow(1, 2, 4))

Strict comparison

>>> TypeA = namedtuple('A', 'x')
>>> TypeB = namedtuple('B', 'x')
>>> compare(TypeA(1), TypeB(1))
<identity>
>>> compare(TypeA(1), TypeB(1), strict=True)
Traceback (most recent call last):
 ...
AssertionError:
A(x=1) (<class '__main__.A'>)!= B(x=1) (<class '__main__.B'>)

What about some context?

>>> compare(1, 2, prefix='wrong number of orders')
Traceback (most recent call last):
 ...
AssertionError: wrong number of orders: 1 != 2

13 minutes

Things that print

def myfunction()
    # code under test
    print("Hello!")
    print("Something bad happened!", file=sys.stderr)
from testfixtures import OutputCapture
with OutputCapture() as output:
    myfunction()

output.compare('''
Hello!
Something bad happened!
''')
output.captured

also a way to clear up poor code under test

Exceptions

from testfixtures import ShouldRaise
with ShouldRaise():
    ...
with ShouldRaise(ValueError):
    ...
with ShouldRaise(ValueError('Something went wrong!')) as s:
    ...
compare(str(s.raised), 'Something went wrong!')

Standard library provides some of this in 3.1+:

with self.assertRaises(SomeException) as cm:
   ...

the_exception = cm.exception
self.assertEqual(the_exception.error_code, 3)

Files and Directories

from testfixtures import TempDirectory

class MyTests(TestCase):

    def setUp(self):
        self.dir = TempDirectory()

    def tearDown(self):
        self.dir.cleanup()

tempted to release this just for working with filesystems outside tests!

Writing files

with TempDirectory as dir:
    path = dir.write('foo.txt', b'bar')
with TempDirectory as dir:
    path = dir.write('foo.txt', 'bar', 'utf8')
with TempDirectory as dir:
    path = dir.write(('root', gethostname(), 'foo.txt'), 'bar')
with TempDirectory as dir:
    path = dir.write('my_folder/foo.txt', 'bar')

Okay, but it's my code that's writing!

with TempDirectory as dir:
    path = dir.getpath('foo.txt')
with TempDirectory as dir:
    path = dir.getpath(('root', gethostname(), 'foo.txt'))
with TempDirectory as dir:
    path = dir.getpath('my_folder/foo.txt')

Reading files

with TempDirectory as dir:
    ...
    compare(b'expected', dir.read('foo.txt'))
with TempDirectory as dir:
    ...
    compare('expected', dir.read('foo.txt', 'utf8'))
with TempDirectory as dir:
    actual = dir.read(('root', gethostname(), 'foo.txt'))
with TempDirectory as dir:
    actual = dir.read('my_folder/foo.txt')

Directories

with TempDirectory as dir:
    dir_path = dir.makedir('root/userfolder')
with TempDirectory as dir:
    dir_path = dir.makedir(('root', gethostname()))
with TempDirectory as dir:
    ...
    dir.check_dir('root/userfolder',
                  'user1.txt', 'user2.txt')

Directories

with TempDirectory as dir:
    ...
    dir.check_all('',
                  'root/',
                  'root/userfolder/',
                  'root/userfolder/user1.txt',
                  'root/userfolder/user2.txt')

Logging

Python has a great standard logging framework:

from logging import getLogger
logger = getLogger()

def process(block):
    logger.info('start of block number %i', block)
    try:
        raise RuntimeError('No code to run!')
    except:
        logger.error('error occurred', exc_info=True)
from testfixtures import LogCapture

with LogCapture() as log:
    process(1)

log.check(
    ('root', 'INFO', 'start of block number 1'),
    ('root', 'ERROR', 'error occurred'),
)
from testfixtures import (
    Comparison as C, LogCapture, compare
    )

with LogCapture() as log:
    process(1)

compare(C(RuntimeError('No code to run!')),
        log.records[-1].exc_info[1])

Only capturing certain logging

with LogCapture(level=logging.INFO) as log:
    logger= getLogger()
    logger.debug('junk')
    logger.info('what we care about')

log.check(('root', 'INFO', 'what we care about'))
with LogCapture('specific') as log:
    getLogger('something').info('junk')
    getLogger('specific').info('what we care about')
    getLogger().info('more junk')

log.check(('specific', 'INFO', 'what we care about'))

Only capturing certain logging

class JobTests(TestCase):

    def setUp(self):
        self.log = LogCapture(install=False)
        log.job = MyJob()
        self.log.install()

    def tearDown(self):
        self.log.uninstall()

    def test_1(self):
        ...

    def test_2(self):
        ...

    def test_2(self):
        self.log.uninstall()
        self.job.prepare()
        self.log.install()
        self.job.run()
        self.log.check(...)

could use two logcaptures?

debug means no silly default handler setup

Testing handler configuration

class LoggingConfigurationTests(TestCase):

    def setUp(self):
         self.logger = logging.getLogger()
         self.orig_handlers = self.logger.handlers
         self.logger.handlers = []
         self.level = self.logger.level

     def tearDown(self):
         self.logger.handlers = self.orig_handlers
         self.logger.level = self.level

     def test_basic_configuration(self):
         # do configuration
         logging.basicConfig(format='%(levelname)s %(message)s',
                             level=logging.INFO)
         # check results of configuration
         compare(self.logger.level, 20)
         compare([
             C('logging.StreamHandler',
               stream=sys.stderr,
               formatter=C('logging.Formatter',
                           _fmt='%(levelname)s %(message)s',
                           strict=False),
               level=logging.NOTSET,
               strict=False)
             ], self.logger.handlers)

# We mock out the handlers list for the logger we're # configuring in such a way that we have no handlers # configured at the start of the test and the handlers our # configuration installs are removed at the end of the test.

check level is set correctly

# Now we check the configuration is as expected:

19 minutes

Mocking

Where do you mock?

One place:

import datetime as dt

def my_code():
    if dt.datetime.now() > dt.datetime(2013, 3, 23, 14, 30):
        say_i_do()

Lots of places:

from datetime import datetime

def my_code():
    if datetime.now() > datetime(2013, 3, 23, 14, 30):
        say_i_do()

How do you mock?

Patch decorator from mock

>>> @patch('package.module.Class')
... def test(MockClass):
...     instance = MockClass.return_value
...     instance.method.return_value = 'foo'
...     from package.module import Class
...     assert Class() is instance
...     assert Class().method() == 'foo'

don't use asserts! (crap error messages)

How do you mock?

Replacer from testfixtures

def test_function():
    with Replacer() as r:
        mock_method = Mock()
        mock_class = Mock()
        r.replace('module.AClass.method', mock_method)
        r.replace('module.BClass', mock_class)
        from module import BClass
        x = AClass()
        y = BClass()
        self.assertTrue(x.method() is mock_method.return_value)
        self.assertTrue(y is mock_class.return_value)

can also be used manually and as a decorator

Mocking other things

someDict = dict(
    x='value',
    y=[1, 2, 3],
    )
with Replacer() as r:
    r.replace('module.someDict.x', 'foo')
    pprint(someDict)

{'x': 'foo', 'y': [1, 2, 3]}
with Replacer() as r:
    r.replace('module.someDict.y.1', 42)
    pprint(someDict)

{'x': 'value', 'y': [1, 42, 3]}

Removing things

from testfixtures import Replacer, not_there

with Replacer() as r:
    r.replace('module.someDict.x', not_there)
    print(hasattr(module, 'someDict')

False
from testfixtures import Replacer, not_there

with Replacer() as r:
    r.replace('module.someDict.y', not_there)
    pprint(someDict)

{'x': 'value'}

Things that might be there

try:
    from guppy import hpy
    guppy = True
except ImportError:
    guppy = False

def dump(path):
    if guppy:
        hpy().heap().stat.dump(path)
from mock import Mock, call
from testfixtures import replace

class Tests(unittest.TestCase):

    @replace('module.guppy',True)
    @replace('module.hpy', Mock(), strict=False)
    def test_method(self, hpy):

        dump('somepath')

        compare([
                 call(),
                 call().heap(),
                 call().heap().stat.dump('somepath')
                ], hpy.mock_calls)

What mock to use?

datetimes

from testfixtures import test_datetime
datetime = test_datetime()
print(datetime.now())
print(datetime.now())
print(datetime.now())

2001-01-01 00:00:00
2001-01-01 00:00:10
2001-01-01 00:00:30

datetimes

from testfixtures import test_datetime
datetime = test_datetime(None)
datetime.add(1978, 6, 13, 16, 0, 1)
datetime.add(2013, 3, 23, 14, 30)
print(datetime.now())
print(datetime.now())

1978-06-13 16:00:01
2013-03-23 14:30:00

remember to talk about .set()!

datetimes

from testfixtures import test_datetime
datetime = test_datetime(delta=2, delta_type='hours')
print(datetime.now())
print(datetime.now())

2001-01-01 00:00:00
2001-01-01 02:00:00

dates

from testfixtures import test_date
date = test_date(2013, 3, 23)
print(date.today())
print(date.today())
print(date.today())

2013-03-23
2013-03-24
2013-03-26

dates

from testfixtures import test_date
date = test_date(None)
date.add(1978, 6, 13)
date.add(2013, 3, 23)
print(date.today())
print(date.today())

1978-06-13
2013-03-23

dates

from testfixtures import test_date
date = test_date(delta=2, delta_type='days')
print(date.today())
print(date.today())

2001-01-01
2001-01-03

times

from testfixtures import test_time
time = test_time()
print(time())
print(time())
print(time())

978307200.0
978307201.0
978307203.0

times

from testfixtures import test_time
time = test_time(None)
time.add(1978, 6, 13, 16, 1)
time.add(2013, 3, 23, 14, 30)
print(time())
print(time())

266601660.0
1364049000.0

times

from testfixtures import test_time
time = test_time(delta=0.5, delta_type='seconds')
print(time())
print(time())

978307200.0
978307200.5

Common pitfalls

Symmetric testing

Excessive mocking

Modes of operation

TempDirectory, Replacer and LogCapture can all be used:

Questions

?

SpaceForward
Left, Down, Page DownNext slide
Right, Up, Page UpPrevious slide
POpen presenter console
HToggle this help