Growing A Project With Tests¶
Let’s test some real code. Note that some of these code examples are courtesty Ned Batchelder.
Add a file to your “sample project” within
sampleproject/sample/portfolio.py
. Use the following contents:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | # portfolio3.py
import urllib
import csv
class Portfolio(object):
"""A simple stock 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
"""
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
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")
#(((value)))
def current_prices(self):
"""Return a dict mapping names to current prices."""
url = "http://finance.yahoo.com/d/quotes.csv?f=sl1&s="
url += ",".join(sorted(s[0] for s in self.stocks))
data = urllib.urlopen(url)
return { sym: float(last) for sym, last in csv.reader(data) }
def value(self):
"""Return the current value of the portfolio."""
prices = self.current_prices()
total = 0.0
for name, shares, _ in self.stocks:
total += shares * prices[name]
return total
#(((end)))
|
Code Description¶
Our code under test is a simple stock portfolio class. It simply stores the lots of stocks purchased: each is a stock name, a number of shares, and the price it was bought at. We have a method to buy a stock, and a method that tells us the total cost of the portfolio.
How do we test it?
Let’s try it interactively. Invoke python
and do the following:
>>> from sample.portfolio 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
We can see that an empty portfolio has a cost of zero. We buy oen stock and the cost is the price times the shares. Then we buy another, and the cost has risen as it should.
Good:
- We’re testing the code.
Bad:
- Not repeatable
- Labor intensive
- Is the result right?
We could do mre adhoc testing, and we could even script our testing into a simple script that makes assertions about the output of the methods we’re calling.
1 2 3 4 5 6 7 8 9 10 11 | from sample.portfolio 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
|
That would be OK. But it would mean that if the tests started to fail, they
would stop on the first error. For example, if we broke the code for the
cost
method, we might run our test script and see the following:
Empty portfolio cost: 0.0, should be 0.0
With 100 IBM @ 176.48: 17648.0, should be 17600.0
Traceback (most recent call last):
File "porttest3_broken.py", line 9, in <module>
assert p.cost() == 17600.0
AssertionError
That’s not great, because as we add more tests, we won’t want things to stop at the first error. Instead, we’ll want all of our tests to run, and we’ll want the system to display all of the errors it finds without stopping at the first one.
Let’s use what we learned earlier about running unit tests to fix the bad parts better.
Adding Unit Tests¶
Add a file to the sampleproject/sample/tests
directory named
test_portfolio.py
with the following contents:
1 2 3 4 5 6 7 8 | import unittest
from sample.portfolio import Portfolio
class PortfolioTest(unittest.TestCase):
def test_buy_one_stock(self):
p = Portfolio()
p.buy("IBM", 100, 176.48)
assert p.cost() == 17648.0
|
Now let’s use what we learned about the mechanics of running tests to run this test case against our codebase.
$ python setup.py test -q
Here’s the output we should see:
running test
running egg_info
writing requirements to sample.egg-info/requires.txt
writing sample.egg-info/PKG-INFO
writing top-level names to sample.egg-info/top_level.txt
writing dependency_links to sample.egg-info/dependency_links.txt
writing entry points to sample.egg-info/entry_points.txt
reading manifest file 'sample.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
writing manifest file 'sample.egg-info/SOURCES.txt'
running build_ext
F.
======================================================================
FAIL: test_failure (sample.tests.test_simple.TestSimple)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/chrism/projects/carscom/unittest_training/realworld/sampleproject/sample/tests/test_simple.py", line 9, in test_failure
self.assertTrue(False)
AssertionError: False is not true
----------------------------------------------------------------------
Ran 2 tests in 0.000s
FAILED (failures=1)
What?! A failing test?! Oh, it’s just the test_simple.py file we forgot to
remove. We don’t need that anymore. Remove
sampleproject/sample/tests/test_simple.py
(and its accompanying .pyc file
that is created) and rerun the test suite.:
$ rm sample/tests/sample.py{,c}
$ python setup.py test -q
running test
running egg_info
writing requirements to sample.egg-info/requires.txt
writing sample.egg-info/PKG-INFO
writing top-level names to sample.egg-info/top_level.txt
writing dependency_links to sample.egg-info/dependency_links.txt
writing entry points to sample.egg-info/entry_points.txt
reading manifest file 'sample.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
writing manifest file 'sample.egg-info/SOURCES.txt'
running build_ext
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
Much better. We now have a level of confidence that buying a single stock works as we expect.
Let’s add a few more tests for edge cases. Change the test_portfolio.py
file to look like this:
import unittest
from sample.portfolio import Portfolio
class PortfolioTest(unittest.TestCase):
def test_empty(self):
p = Portfolio()
self.assertEqual(p.cost(), 0.0)
def test_buy_one_stock(self):
p = Portfolio()
p.buy("IBM", 100, 176.48)
self.assertEqual(p.cost(), 17648.0)
def test_buy_two_stocks(self):
p = Portfolio()
p.buy("IBM", 100, 176.48)
p.buy("HPQ", 100, 36.15)
self.assertEqual(p.cost(), 21263.0)
Here we add a simpler test, test_empty, and a more complicated test, test_buy_two_stocks. Each test is another test method in our PortfolioTest class. Each one creates the portfolio object it needs, performs the manipulations it wants, and makes assertions about the outcome.
Let’s run the test suite again.
$ python setup.py test -q
running test
running egg_info
writing requirements to sample.egg-info/requires.txt
writing sample.egg-info/PKG-INFO
writing top-level names to sample.egg-info/top_level.txt
writing dependency_links to sample.egg-info/dependency_links.txt
writing entry points to sample.egg-info/entry_points.txt
reading manifest file 'sample.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
writing manifest file 'sample.egg-info/SOURCES.txt'
running build_ext
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK
Under The Covers¶
unittest
runs our test suite as if we had typed this:
testcase = PortfolioTest()
try:
testcase.test_buy_one_stock()
except AssertionError:
# [record failure]
else:
# [record success]
testcase = PortfolioTest()
try:
testcase.test_buy_two_stocks()
except AssertionError:
# [record failure]
else:
# [record success]
testcase = PortfolioTest()
try:
testcase.test_empty()
except AssertionError:
# [record failure]
else:
# [record success]
(Again, tests run in alphabetical order.)
A key thing to note here is that a new instance of PortfolioTest is created for each test method. This helps to guarantee an important property of good tests: isolation.
Test isolation means that each of your tests is unaffected by every other test. This is good because it makes your tests more repeatable, and they are clearer about what they are testing. It also means that if a test fails, you don’t have to think about all the conditions and data created by earlier tests: running just that one test will reproduce the failure.
Earlier we demonstrated a problem where one test failing prevented the other tests from running. Here unittest is running each test independently, so if one fails, the rest will run, and will run just as if the earlier test had succeeded.
What Failure Looks Like¶
Let’s break a test and see what happens. Change the test_portfolio.py file to look like this:
import unittest
from sample.portfolio import Portfolio
class PortfolioTest(unittest.TestCase):
def test_empty(self):
p = Portfolio()
assert p.cost() == 0.0
def test_buy_one_stock(self):
p = Portfolio()
p.buy("IBM", 100, 176) # this is wrong, to make the test fail!
assert p.cost() == 17648.0
def test_buy_two_stocks(self):
p = Portfolio()
p.buy("IBM", 100, 176.48)
p.buy("HPQ", 100, 36.15)
assert p.cost() == 21263.0
$ python setup.py test -q
F..
======================================================================
FAIL: test_buy_one_stock (sample.tests.test_portfolio.PortfolioTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/chrism/projects/carscom/unittest_training/realworld/sampleproject/sample/tests/test_portfolio.py", line 12, in test_buy_one_stock
assert p.cost() == 17648.0
AssertionError
----------------------------------------------------------------------
Ran 3 tests in 0.000s
FAILED (failures=1)
(I’ve omitted the boilerplate stuff emitted by setuptools at the top, I’ll keep omitting it from here on in).
Note that we see two dots and a failure represented by the “F”. The traceback shows us where the failure occured. Remember: when your tests pass, you don’t have to do anything, you can go on with other work, so passing tests, while a good thing, should not cause a lot of noise. It’s the failing tests we need to think about.
It’s great that the traceback shows what assertion failed, but notice that it doesn’t tell us what bad value was returned. We can see that we expected it to be 17648.0, but we don’t know what the actual value was.
Let’s use unittest helper methods to fix that.
Using Unittest Helper Methods¶
Change the test_portfolio.py file to look like this:
import unittest
from sample.portfolio import Portfolio
class PortfolioTest(unittest.TestCase):
def test_empty(self):
p = Portfolio()
self.assertEqual(p.cost(), 0.0)
def test_buy_one_stock(self):
p = Portfolio()
p.buy("IBM", 100, 176) # this is wrong, to make the test fail!
self.assertEqual(p.cost(), 17648.0)
def test_buy_two_stocks(self):
p = Portfolio()
p.buy("IBM", 100, 176.48)
p.buy("HPQ", 100, 36.15)
self.assertEqual(p.cost(), 21263.0)
In this test, we’ve used self.assertEqual instead of the built-in assert statement. The benefit of the method is that it can print both the actual and expected values if the test fails.
$ python setup.py test -q
F..
======================================================================
FAIL: test_buy_one_stock (sample.tests.test_portfolio.PortfolioTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/chrism/projects/carscom/unittest_training/realworld/sampleproject/sample/tests/test_portfolio.py", line 12, in test_buy_one_stock
self.assertEqual(p.cost(), 17648.0)
AssertionError: 17600.0 != 17648.0
----------------------------------------------------------------------
Ran 3 tests in 0.000s
FAILED (failures=1)
Here the assertion error message shows both values. The best tests will give you information you can use to diagnose and debug the problem. Here, when comparing two values for equality, we are told not only that they are not equal, but also what each value was.
Assert helpers:
assertEqual(first, second)
assertNotEqual(first, second)
assertTrue(expr)
assertFalse(expr)
assertIn(first, second)
assertNotIn(first, second)
assertIs(first, second)
assertIsNot(first, second)
assertAlmostEqual(first, second)
assertNotAlmostEqual(first, second)
assertGreater(first, second)
assertLess(first, second)
assertRegexpMatches(first, second)
assertRaises(exc_class, func, ...)
assertSequenceEqual(seq1, seq2)
assertItemsEqual(seq1, seq2)
# ... etc ...
The unittest.TestCase class provides many assertion methods for testing many conditions. The unittest documentation at http://docs.python.org/2/library/unittest.html shows all of them.
An additional benefit of using assertion methods rather than the assert
Pytyhon builtin: assert
statements can be compiled out in certain
circumstances (the -O
flag to Python) and may not even execute as a result
(a questionable, surprising feature which was added to Python early in its
lifetime).
To avoid needing to think about any of this, always use assertion methods
rather than the assert
builtin.
Seeing Errors¶
Let’s break our code in one additional way, by injecting an exception. We’ll
change the test_buy_one_stock
method such that we try to call a nonexistent
method named buyXX
instead of buy
.
import unittest
from sample.portfolio import Portfolio
class PortfolioTest(unittest.TestCase):
def test_empty(self):
p = Portfolio()
self.assertEqual(p.cost(), 0.0)
def test_buy_one_stock(self):
p = Portfolio()
p.buyXX("IBM", 100, 176.48) # this is wrong, to make the test fail!
self.assertEqual(p.cost(), 17648.0)
def test_buy_two_stocks(self):
p = Portfolio()
p.buy("IBM", 100, 176.48)
p.buy("HPQ", 100, 36.15)
self.assertEqual(p.cost(), 21263.0)
If we run the tests again we’ll see this output:
E..
======================================================================
ERROR: test_buy_one_stock (sample.tests.test_portfolio.PortfolioTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/chrism/projects/carscom/unittest_training/realworld/sampleproject/sample/tests/test_portfolio.py", line 11, in test_buy_one_stock
p.buyXX("IBM", 100, 176.48) # this is wrong, to make the test fail!
AttributeError: 'Portfolio' object has no attribute 'buyXX'
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (errors=1)
There are actually three possible outcomes for a test: passing, failure, and
error. Error means that the test raised an exception other than
AssertionError
. Well-written tests should either succeed, or should fail in
an assertion. If another exception happens, it could either mean that the test
is broken, or that the code is broken, but either way, it isn’t what you
intended. This condition is marked with an “E”, and the error stacktrace is
displayed.
assertRaises¶
The assertRaises helper method is a bit complex. The easiest way to use it is
via a with
statement.
Add the following to the PortfolioTest class:
def test_bad_input(self):
p = Portfolio()
with self.assertRaises(TypeError):
p.buy("IBM")
This test will pass. It asserts that the buy
method will raise a TypeError
when called with too few arguments. This is normal Python behvaior, and
doesn’t really need to be tested; this is just an example for purposes of
explaining the helper method.
Adding Coverage Reporting¶
We can use the coverage
tool to add objective metrics that help us evaluate
the usefulness of our test suite.
To do so, we’ll switch our test runner. So far we’ve been using the built-in
setuptools
test runner. To easily get coverage statistics, instead, we’re
going to use the nose
test runner.
Let’s change our setup.py
to depend, in its test
extra, on nose
.
Change it from this:
extras_require={
'dev': ['check-manifest'],
'test': ['coverage'],
},
To this:
extras_require={
'dev': ['check-manifest'],
'test': ['coverage', 'nose'],
},
Then, we’ll change our setup.cfg
file to create an alias, so that when we
type setup.py dev
, it will install both nose and coverage for us. Add the
following stanza to setup.cfg
:
[aliases]
dev = develop easy_install sample[test]
Now that we’ve made those two changes, type python setup.py dev
in your
shell, and see that both nose
and coverage
get installed:
# ...
creating /home/chrism/projects/carscom/unittest_training/lib/python2.7/site-packages/coverage-4.0b2-py2.7-linux-x86_64.egg
Extracting coverage-4.0b2-py2.7-linux-x86_64.egg to /home/chrism/projects/carscom/unittest_training/lib/python2.7/site-packages
Adding coverage 4.0b2 to easy-install.pth file
Installing coverage2 script to /home/chrism/projects/carscom/unittest_training/bin
Installing coverage-2.7 script to /home/chrism/projects/carscom/unittest_training/bin
Installing coverage script to /home/chrism/projects/carscom/unittest_training/bin
Installed /home/chrism/projects/carscom/unittest_training/lib/python2.7/site-packages/coverage-4.0b2-py2.7-linux-x86_64.egg
# ...
Running Tests via nosetests
¶
Now that we have nose
installed, we can use its features.
Let’s first run python setup.py nosetests
. See that it produces output
similar to that of running python setup.py test
.
Once we verify that, we’ll run it with the --with-coverage
flag:
python setup.py nosetests --with-coverage --cover-package=sample
.F
======================================================================
FAIL: test_failure (sample.tests.test_simple.TestSimple)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/chrism/projects/carscom/unittest_training/realworld/sampleproject/sample/tests/test_simple.py", line 9, in test_failure
self.assertTrue(False)
AssertionError: False is not true
Name Stmts Miss Cover Missing
---------------------------------------------------
sample.py 2 1 50% 4
sample/portfolio.py 31 16 48% 40-47, 52-55, 59-63
---------------------------------------------------
TOTAL 33 17 48%
----------------------------------------------------------------------
Ran 2 tests in 0.011s
FAILED (failures=1)
(Your output will differ slightly).
The things we want to pay attention to are the Cover
column and the
Missing
column.
Cover
is a percentage of statements in the module covered. A “statement”
for purposes of this discussion is a single line of code.
Missing
is a list of lines that were not executed when the tests ran.
Above, the sample.py
file actually means the __init__.py
file in the
sample
project. Its line 4 was not executed. The sample/portfolio.py
file had unexecuted lines 40-47, 52-55, 59-63.
Our goal will be to get rid of all the output in the Missing
column and
make the Cover
column display 100%
for each module.
Testing the Portfolio.sell()
method¶
Let’s test the Portfolio.sell()
method in real time, using setup.py
nosetests
to check our work. Coverage helps us see what we missed.
- How many test methods should we use to try to test it (hint: it has a couple of conditions in it)?
- What does
for: else:
mean? pragma: no cover
.--cover-html
- Extra credit: branch coverage (
--cover-branches
).
Uh Oh. Code That Integrates With Another System¶
The code in our Portfolio.current_prices()
method calls urllib.urlopen
,
asking finance.yahoo.com
what the value is of each stock that we own. The
Portfolio.value()
method calls the current_prices()
method so it
depends on the same external system.
- What happens when
finance.yahoo.com
is unavailable? Maybe it’s not even down; maybe you’re on a plane. - Should we be able to test this code without the yahoo site being available? Yes!
- Do we care that the yahoo site actually works in this context? No!
- Unit tests test units of code. In this case we’re concerned about the code
in the
value
andcurrent_prices
methods. We are only concerned about testing that code, no other code. In our little unit testing world, we will assume that urllib.urlopen works, we assume that finance.yahoo.com works as requested and that it returns something that can be parsed by the CSV module. - Another reason to think of things this way: the yahoo site might be slow, which will make your tests slow, which makes it hard to write lots of tests that will be run frequently.
- External dependencies are also unpredictable, which makes testing code which depends on one hard. For instance, in our case, the finance site might return values that differ every few minutes.
Solution? Test doubles.
Types of doubles:
- Stubs.
- Mocks.
Stubs¶
We’ll add a stub in a new test case to help us avoid depending on the “real”
current_prices
implementation. Add this class to the test_portfolio.py
module:
class PortfolioValueTest(unittest.TestCase):
def stub_current_prices(self):
return {'IBM':140.0, 'HPQ':32.0}
def _makePortfolio(self):
p = Portfolio()
p.buy('IBM', 100, 120.0)
p.buy('HPQ', 100, 30.0)
p.current_prices = self.stub_current_prices
return p
def test_value(self):
p = self._makePortfolio()
self.assertEqual(p.value(), 17200)
The magic of Python allows us to replace the method named current_prices
on
the portfolio we return to the test_value` method with one which always
return a static dictionary, therefore the external system is avoided.
But we can see if we run our coverage tests again that we haven’t tested all the code. How do we fix that?
Stubbing urllib
¶
Change portfolio.py to add the following line to the class near the top:
urllib = urllib
And change the code in current_price
to use self.urllib
instead of
urllib
.
import urllib
# ....
class Portfolio(object):
# ...
urllib = urllib
# ...
def current_prices(self):
"""Return a dict mapping names to current prices."""
url = "http://finance.yahoo.com/d/quotes.csv?f=sl1&s="
url += ",".join(sorted(s[0] for s in self.stocks))
data = self.urllib.urlopen(url)
return { sym: float(last) for sym, last in csv.reader(data) }
Then in our test code we can change it so we do this:
from StringIO import StringIO
class FakeUrllib(object):
def urlopen(self, url):
return StringIO('"IBM",140\n"HPQ",32\n')
class PortfolioValueTest(unittest.TestCase):
def _makePortfolio(self):
p = Portfolio()
p.buy('IBM', 100, 120.0)
p.buy('HPQ', 100, 30.0)
p.urllib = FakeUrllib()
return p
def test_value(self):
p = self._makePortfolio()
self.assertEqual(p.value(), 17200)
Now most of our code is tested as we can see from coverage reports.
But we needed to change our code to be able to stubify it. Let’s try it with a
concept named mocks
instead.
Mocks¶
Get rid of the urllib = urllib
line in the Portfolio class and change
things back so that we do data = urllib.urlopen..
instead of data=
self.urllib.urlopen
. Now let’s use a mock in our test.
import mock
from sample.portfolio import Portfolio
class PortfolioValueTest(unittest.TestCase):
def _makePortfolio(self):
p = Portfolio()
p.buy('IBM', 100, 120.0)
p.buy('HPQ', 100, 30.0)
return p
def test_value(self):
p = self._makePortfolio()
with mock.patch('urllib.urlopen') as urlopen:
fake_yahoo = StringIO('"IBM",140\n"HPQ",32\n')
urlopen.return_value = fake_yahoo
self.assertEqual(p.value(), 17200)
urlopen.assert_called_with('http://finance.yahoo.com/d/quotes.csv?f=sl1&s=HPQ,IBM')
Now we didn’t need to mutate our code to make it testable. We were also able to make assertions about the value that was passed to urlopen pretty easily.
Mocks or Stubs?¶
- Yes.
- Mocks have some downsides that we’ll cover in a future section. In particular, they can make the reader of the testing code need to understand obscure bits of the Mock API, making the tests less understandbale.
- Stubs have the downside that you often need to change your code to use them, and writing them isn’t much fun.
Discussion¶
- What happens when code starts using
requests
instead ofurllib
? - What kind of tradeoff could we choose to make in order to make our tests less fragile?
Coda: setUp and tearDown¶
unittest.TestCase
subclasses can have setUp and TearDown methods. The code
in each of these runs before/after each test that runs. I don’t use these much
because they’re often used to manage globals, and I try not to have globals.
Another thing they’re used for is to get rid of boilerplate.
- An example which gets rid of
p = Portfolio()
in each test by addingself.p = Portfolio()
to setUp.
Under the covers of setUp and tearDown:
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 ]
- A helper method,
_makePortfolio()
instead. - I like the helper method instead.