Getting Started Testing your Python

Ned Batchelder

@nedbat

http://nedbatchelder/text/starttest.html

Goals

Why test?

Common testing mindset

Most developers' #1 thought about testing:

I AM BAD

Reality

Starting from first principles

Evolving tests

Stock portfolio class

A simple system under test:

    # portfolio1.py

    class Portfolio(object):
        """A simple stock portfolio"""
        def __init__(self):
            # stocks is a list of lists:
            #   [[name, shares, price], ...]
            self.stocks = []

        def buy(self, name, shares, price):
            """Buy `name`: `shares` shares at `price`."""
            self.stocks.append([name, shares, price])

        def cost(self):
            """What was the total cost of this portfolio?"""
            amt = 0.0
            for name, shares, price in self.stocks:
                amt += shares * price
            return amt
    

First test: interactive

    $ python
    Python 2.7.2 (default, Jun 12 2011, 15:08:59) 
    >>> from portfolio1 import Portfolio
    >>> p = Portfolio()
    >>> p.cost()
    0.0

    >>> p.buy("IBM", 100, 176.48)
    >>> p.cost()
    17648.0

    >>> p.buy("HPQ", 100, 36.15)
    >>> p.cost()
    21263.0

    

Second test: standalone

    # porttest1.py
    from portfolio1 import Portfolio

    p = Portfolio()
    print "Empty portfolio cost: %s" % p.cost()
    p.buy("IBM", 100, 176.48)
    print "With 100 IBM @ 176.48: %s" % p.cost()
    p.buy("HPQ", 100, 36.15)
    print "With 100 HPQ @ 36.15: %s" % p.cost()
    
    $ python porttest1.py
    Empty portfolio cost: 0.0
    With 100 IBM @ 176.48: 17648.0
    With 100 HPQ @ 36.15: 21263.0
    

Third test: expected results

    # porttest2.py
    from portfolio1 import Portfolio

    p = Portfolio()
    print "Empty portfolio cost: %s, should be 0.0" % p.cost()
    p.buy("IBM", 100, 176.48)
    print "With 100 IBM @ 176.48: %s, should be 17648.0" % p.cost()
    p.buy("HPQ", 100, 36.15)
    print "With 100 HPQ @ 36.15: %s, should be 21263.0" % p.cost()
    
    $ python porttest2.py
    Empty portfolio cost: 0.0, should be 0.0
    With 100 IBM @ 176.48: 17648.0, should be 17648.0
    With 100 HPQ @ 36.15: 21263.0, should be 21263.0
    

Fourth test: check results automatically

    # porttest3.py
    from portfolio1 import Portfolio

    p = Portfolio()
    print "Empty portfolio cost: %s, should be 0.0" % p.cost()
    assert p.cost() == 0.0
    p.buy("IBM", 100, 176.48)
    print "With 100 IBM @ 176.48: %s, should be 17648.0" % p.cost()
    assert p.cost() == 17648.0
    p.buy("HPQ", 100, 36.15)
    print "With 100 HPQ @ 36.15: %s, should be 21263.0" % p.cost()
    assert p.cost() == 21263.0
    
    $ python porttest3.py
    Empty portfolio cost: 0.0, should be 0.0
    With 100 IBM @ 176.48: 17648.0, should be 17648.0
    With 100 HPQ @ 36.15: 21263.0, should be 21263.0
    

Fourth test: what failure looks like

    $ python porttest3_broken.py
    Empty portfolio cost: 0.0, should be 0.0
    With 100 IBM @ 176.48: 17600.0, should be 17648.0
    Traceback (most recent call last):
      File "porttest3_broken.py", line 9, in <module>
        assert p.cost() == 17648.0
    AssertionError
    

This is starting to get complicated!

unittest

Writing tests

unittest

Test classes

A simple unit test

    # test_port1.py

    import unittest
    from portfolio1 import Portfolio

    class PortfolioTest(unittest.TestCase):
        def test_ibm(self):
            p = Portfolio()
            p.buy("IBM", 100, 176.48)
            assert p.cost() == 17648.0

    if __name__ == '__main__':
        unittest.main()
    
    $ python test_port1.py
    .
    ----------------------------------------------------------------------
    Ran 1 test in 0.000s

    OK
    

Under the covers

    # unittest runs the tests as if I had written:
    testcase = PortfolioTest()
    try:
        testcase.test_ibm()
    except:
        [record failure]
    else:
        [record success]
    

Add more tests

    class PortfolioTest(unittest.TestCase):
        def test_empty(self):
            p = Portfolio()
            assert p.cost() == 0.0

        def test_ibm(self):
            p = Portfolio()
            p.buy("IBM", 100, 176.48)
            assert p.cost() == 17648.0

        def test_ibm_hpq(self):
            p = Portfolio()
            p.buy("IBM", 100, 176.48)
            p.buy("HPQ", 100, 36.15)
            assert p.cost() == 21263.0
    $ python test_port2.py
    ...
    ----------------------------------------------------------------------
    Ran 3 tests in 0.000s

    OK
    

Under the covers

    # unittest runs the tests as if I had written:
    testcase = PortfolioTest()
    try:
        testcase.test_empty()
    except:
        [record failure]
    else:
        [record success]

    testcase = PortfolioTest()
    try:
        testcase.test_ibm()
    except:
        [record failure]
    else:
        [record success]
    
    testcase = PortfolioTest()
    try:
        testcase.test_ibm_hpq()
    except:
        [record failure]
    else:
        [record success]
    

Test isolation

What failure looks like

    $ python test_port2_broken.py
    .F.
    ======================================================================
    FAIL: test_ibm (__main__.PortfolioTest)
    ----------------------------------------------------------------------
    Traceback (most recent call last):
      File "test_port2_broken.py", line 14, in test_ibm
        assert p.cost() == 17648.0
    AssertionError

    ----------------------------------------------------------------------
    Ran 3 tests in 0.001s

    FAILED (failures=1)
    

unittest assert helpers

        def test_ibm(self):
            p = Portfolio()
            p.buy("IBM", 100, 176.48)
            self.assertEqual(p.cost(), 17648.0)
    $ python test_port3_broken.py
    .F.
    ======================================================================
    FAIL: test_ibm (__main__.PortfolioTest)
    ----------------------------------------------------------------------
    Traceback (most recent call last):
      File "test_port3_broken.py", line 14, in test_ibm
        self.assertEqual(p.cost(), 17648.0)
    AssertionError: 17600.0 != 17648.0

    ----------------------------------------------------------------------
    Ran 3 tests in 0.001s

    FAILED (failures=1)
    

unittest assert helpers

    assertEqual(first, second)
    assertNotEqual(first, second)
    assertTrue(expr)
    assertFalse(expr)
    assertIn(first, second)
    assertNotIn(first, second)
    assertAlmostEqual(first, second)
    assertGreater(first, second)
    assertLess(first, second)
    assertRegexMatches(text, regexp)
    .. etc ..
    

Major enhancements in 2.7

Three possible outcomes

    $ python test_port3_broken2.py
    .E.
    ======================================================================
    ERROR: test_ibm (__main__.PortfolioTest)
    ----------------------------------------------------------------------
    Traceback (most recent call last):
      File "test_port3_broken2.py", line 13, in test_ibm
        p.buyxxxxx("IBM", 100, 176.48)
    AttributeError: 'Portfolio' object has no attribute 'buyxxxxx'

    ----------------------------------------------------------------------
    Ran 3 tests in 0.000s

    FAILED (errors=1)
    

Under the covers

    testcase = PortfolioTest()
    try:
        testcase.test_method()
    except AssertionError:
        [record failure]
    except:
        [record error]
    else:
        [record success]
    

Testing for failure

    $ python
    Python 2.7.2 (default, Jun 12 2011, 15:08:59) 
    >>> from portfolio1 import Portfolio
    >>> p = Portfolio()
    >>> p.buy("IBM")
    Traceback (most recent call last):
      File "<console>", line 1, in <module>
    TypeError: buy() takes exactly 4 arguments (2 given)

    

Can't just call the function

        def test_bad_input(self):
            p = Portfolio()
            p.buy("IBM")
    $ python test_port4_broken.py
    E...
    ======================================================================
    ERROR: test_bad_input (__main__.PortfolioTest)
    ----------------------------------------------------------------------
    Traceback (most recent call last):
      File "test_port4_broken.py", line 24, in test_bad_input
        p.buy("IBM")
    TypeError: buy() takes exactly 4 arguments (2 given)

    ----------------------------------------------------------------------
    Ran 4 tests in 0.001s

    FAILED (errors=1)
    

assertRaises

        def test_bad_input(self):
            p = Portfolio()
            self.assertRaises(TypeError, p.buy, "IBM")
    $ python test_port4.py
    ....
    ----------------------------------------------------------------------
    Ran 4 tests in 0.000s

    OK
    

Nicer in 2.7:

    def test_bad_input(self):
        p = Portfolio()
        with self.assertRaises(TypeError):
            p.buy("IBM")
    

Add sell functionality

        def sell(self, name, shares):
            """Sell some number of shares of `name`."""
            for holding in self.stocks:
                if holding[0] == name:
                    if holding[1] < shares:
                        raise ValueError("Not enough shares")
                    holding[1] -= shares
                    break
            else:
                raise ValueError("You don't own that stock")

Testing sell

    class PortfolioSellTest(unittest.TestCase):
        def test_sell(self):
            p = Portfolio()
            p.buy("MSFT", 100, 27.0)
            p.buy("DELL", 100, 17.0)
            p.buy("ORCL", 100, 34.0)
            p.sell("MSFT", 50)
            self.assertEqual(p.cost(), 6450)

        def test_not_enough(self):
            p = Portfolio()
            p.buy("MSFT", 100, 27.0)
            p.buy("DELL", 100, 17.0)
            p.buy("ORCL", 100, 34.0)
            with self.assertRaises(ValueError):
                p.sell("MSFT", 200)

        def test_dont_own_it(self):
            p = Portfolio()
            p.buy("MSFT", 100, 27.0)
            p.buy("DELL", 100, 17.0)
            p.buy("ORCL", 100, 34.0)
            with self.assertRaises(ValueError):
                p.sell("IBM", 1)

Setting up a test

    class PortfolioSellTest(unittest.TestCase):
        def setUp(self):
            self.p = Portfolio()
            self.p.buy("MSFT", 100, 27.0)
            self.p.buy("DELL", 100, 17.0)
            self.p.buy("ORCL", 100, 34.0)

        def test_sell(self):
            self.p.sell("MSFT", 50)
            self.assertEqual(self.p.cost(), 6450)

        def test_not_enough(self):
            with self.assertRaises(ValueError):
                self.p.sell("MSFT", 200)

        def test_dont_own_it(self):
            with self.assertRaises(ValueError):
                self.p.sell("IBM", 1)

Under the covers

    testcase = PortfolioTest()
    try:
        testcase.setUp()
    except:
        [record error]
    else:
        try:
            testcase.test_method()
        except AssertionError:
            [record failure]
        except:
            [record error]
        else:
            [record success]
        finally:
            try:
                testcase.tearDown()
            except:
                [record error]
    

setUp and tearDown: isolation!

How NOT to do it

    class MyBadTestCase(unittest.TestCase):
        def test_a_thing(self):
            old_global = some_global_thing
            some_global_thing = new_test_value

            do_my_test_stuff()
            
            some_global_thing = old_global
    

The right way

    class MyGoodTestCase(unittest.TestCase):
        def setUp(self):
            self.old_global = some_global_thing
            some_global_thing = new_test_value

        def tearDown(self):
            some_global_thing = self.old_global

        def test_a_thing(self):
            do_my_test_stuff()
    

A better way

    def test_with_special_settings(self):
        with patch_settings(SOMETHING='special', ANOTHER='weird'):
            do_my_test_stuff()
    
    # From: http://stackoverflow.com/questions/913549
    from somewhere import MY_GLOBALS

    NO_SUCH_SETTING = object()

    @contextlib.contextmanager
    def patch_settings(**kwargs):
        old_settings = []
        for key, new_value in kwargs.items():
            old_value = getattr(MY_GLOBALS, key, NO_SUCH_SETTING)
            old_settings.append((key, old_value))
            setattr(MY_GLOBALS, key, new_value)

        yield

        for key, old_value in old_settings:
            if old_value is NO_SUCH_SETTING:
                delattr(MY_GLOBALS, key)
            else:
                setattr(MY_GLOBALS, key, old_value)
    

Tests are real code

Approaches

crafting tests

What to test?

How to start?

Test granularity

What should the code do?

Test what the code should do

Test what the code shouldn't do

Where to put them?

Good tests should be...

How much is enough?

Tests will fail

Test-Driven Development

Testability

nose and py.test

Running tests

Test runners

Test discovery

Test runners

Lots of options

Coverage

Testing tests

What code are you testing?

Coverage measurement

HTML report

Coverage can only tell you a few things

What coverage can't tell you

Test Doubles

Focusing tests

Testing small amounts of code

Dependencies are bad

Test Doubles

Real-time data!

        def current_prices(self):
            """Return a dict mapping names to current prices."""
            # http://download.finance.yahoo.com/d/quotes.csv?f=sl1&s=ibm,hpq
            # returns comma-separated values:
            #   "IBM",174.23
            #   "HPQ",35.13
            url = "http://download.finance.yahoo.com/d/quotes.csv?f=sl1&s="
            names = [name for name, shares, price in self.stocks]
            url += ",".join(sorted(names))
            data = urllib.urlopen(url)
            prices = dict((sym, float(last)) for sym, last in csv.reader(data))
            return prices

        def value(self):
            """Return the current value of the portfolio."""
            prices = self.current_prices()
            total = 0.0
            for name, shares, price in self.stocks:
                total += shares * prices[name]
            return total

It works great!

    $ python
    Python 2.7.2 (default, Jun 12 2011, 15:08:59) 
    >>> from portfolio3 import Portfolio
    >>> p = Portfolio()
    >>> p.buy("IBM", 100, 150.0)
    >>> p.buy("HPQ", 100, 30.0)

    >>> p.current_prices()
    {'HPQ': 35.61, 'IBM': 185.21}

    >>> p.value()
    22082.0

    

But how to test it?

Fake implementation of current_prices

    # Replace Portfolio.current_prices with a stub implementation.
    # This avoids the web, but also skips all our current_prices
    # code.
    class PortfolioValueTest(unittest.TestCase):
        def fake_current_prices(self):
            return {'IBM': 140.0, 'HPQ': 32.0}

        def setUp(self):
            self.p = Portfolio()
            self.p.buy("IBM", 100, 120.0)
            self.p.buy("HPQ", 100, 30.0)
            self.p.current_prices = self.fake_current_prices

        def test_value(self):
            self.assertEqual(self.p.value(), 17200)

But some code isn't tested!

    $ coverage run test_port7.py
    ........
    ----------------------------------------------------------------------
    Ran 8 tests in 0.000s

    OK
    $ coverage report -m
    Name         Stmts   Miss  Cover   Missing
    ------------------------------------------
    portfolio3      33      6    82%   57-62
    test_port7      45      0   100%   
    ------------------------------------------
    TOTAL           78      6    92%   
    
        def current_prices(self):
            """Return a dict mapping names to current prices."""
            # http://download.finance.yahoo.com/d/quotes.csv?f=sl1&s=ibm,hpq
            # returns comma-separated values:
            #   "IBM",174.23
            #   "HPQ",35.13
            url = "http://download.finance.yahoo.com/d/quotes.csv?f=sl1&s="
            names = [name for name, shares, price in self.stocks]
            url += ",".join(sorted(names))
            data = urllib.urlopen(url)
            prices = dict((sym, float(last)) for sym, last in csv.reader(data))
            return prices

Fake urllib.urlopen instead

    # A simple fake for urllib that implements only one method,
    # and is only good for one request.  You can make this much
    # more complex for your own needs.
    class FakeUrllib(object):
        """An object that can stand in for the urllib module."""

        def urlopen(self, url):
            """A stub urllib.urlopen() implementation."""
            return StringIO('"IBM",140\n"HPQ",32\n')


    class PortfolioValueTest(unittest.TestCase):
        def setUp(self):
            self.old_urllib = portfolio3.urllib
            portfolio3.urllib = FakeUrllib()

            self.p = Portfolio()
            self.p.buy("IBM", 100, 120.0)
            self.p.buy("HPQ", 100, 30.0)

        def tearDown(self):
            portfolio3.urllib = self.old_urllib

        def test_value(self):
            self.assertEqual(self.p.value(), 17200)

All of our code is executed

    $ coverage run test_port8.py
    ........
    ----------------------------------------------------------------------
    Ran 8 tests in 0.001s

    OK
    $ coverage report -m
    Name         Stmts   Miss  Cover   Missing
    ------------------------------------------
    portfolio3      33      0   100%   
    test_port8      51      0   100%   
    ------------------------------------------
    TOTAL           84      0   100%   
    

Even better: mock objects

Automatic chameleons

    class PortfolioValueTest(unittest.TestCase):
        def setUp(self):
            self.p = Portfolio()
            self.p.buy("IBM", 100, 120.0)
            self.p.buy("HPQ", 100, 30.0)

        def test_value(self):
            # Create a mock urllib.urlopen
            with mock.patch('urllib.urlopen') as urlopen:

                # When called, it will return this value
                urlopen.return_value = StringIO('"IBM",140\n"HPQ",32\n')

                # Run the test!
                self.assertEqual(self.p.value(), 17200)

                # We can ask the mock what its arguments were
                urlopen.assert_called_with(
                    "http://download.finance.yahoo.com/d/quotes.csv"
                    "?f=sl1&s=HPQ,IBM"
                    )

Test doubles

Summing up

Testing is...

Resources

Thank You

http://nedbatchelder.com/text/starttest.html

@nedbat

Made with Cog, Slippy, and Fontin.