Tests with unittest
Problem
The discount that broke quietly
The shop's rule is: the discount is in percent, and on a purchase of €100 or more you add another 5%. You write the function and try it by hand:
def apply_discount(price, percent): # Discount in percent. From €100 up, add another 5%. if price > 100: percent = percent + 5 return price * (100 - percent) / 100
print(apply_discount(50, 10))print(apply_discount(200, 10))45.0170.0€50 with 10% off is €45, and €200 with 15% off is €170. It looks right, so you ship it. A week later a customer with a purchase of exactly €100 pays €90 instead of €85. The condition is price > 100, and it should be price >= 100.
Nobody tried exactly €100. And on every later change to the function, you have to try all the cases by hand again.
The idea
Code that checks code
Instead of reading the output with your own eyes, you write small methods that call your code and compare the result with the expected one. That is a test. You run all the tests with one click, after every change, and they tell you what broke.
| Kind | What it checks | Example |
|---|---|---|
| Unit test | one small piece, usually one function, apart from the rest | apply_discount(100, 10) returns 85 |
| Integration | how the pieces work together: with a database, a file, another service | the order is saved to the database and read back |
| End-to-end | the whole application, the way a person uses it | the customer adds to the cart, pays and gets an email |
Why it is worth it:
- you see the bug right away, while you still remember what you changed, not when the customer complains;
- the test shows how the code is used: which case returns what;
- you can clean up the code boldly. If the tests are still green after the change, the behavior has not changed.
More precisely, if you are curious
There are other kinds of tests too: functional (does the program meet the requirements), performance (how many requests it can handle), security (are there any holes) and user interface (the buttons, the forms). All of them build on unit tests: there are the most of them, and they are the fastest and the cheapest to maintain.
Besides unittest, you will often see pytest in Python: a separate library (pip install pytest) where tests are plain functions with assert. Here we use unittest, because it comes with Python itself.See it
The first test
apply_discount is in the file discount.py. Its tests are in a separate file, test_discount.py, in the class DiscountTest:import unittest
from discount import apply_discount
class DiscountTest(unittest.TestCase): def test_ten_percent_off(self): self.assertEqual(apply_discount(50, 10), 45.0)
def test_extra_five_percent_from_100(self): self.assertEqual(apply_discount(100, 10), 85.0)- The test class inherits from
unittest.TestCase. Every method whose name starts withtestis a test: unittest finds it on its own by the name. How exactly, you will see in the last lesson of the module. self.assertEqual(a, b)compares the two values. If they are different, the test fails and saysa != b. Here we write what the code returned first, and then the expected value.
We run the two tests against the code from the first card:
FAIL test_extra_five_percent_from_100: 90.0 != 85.0PASS test_ten_percent_offTests: 2, passed: 1, failed: 1The first test passes. The second one fails and says exactly what happened: it expected 85.0, and the function returned 90.0. You fix the condition to price >= 100, run them again, and both are green:
PASS test_extra_five_percent_from_100PASS test_ten_percent_offTests: 2, passed: 2, failed: 0Every test does three things: it prepares the values, calls the code and checks the result.
class DiscountTest(unittest.TestCase): def test_ten_percent_off(self): self.assertEqual(apply_discount(50, 10), 45.0)
def test_extra_five_percent_from_100(self): self.assertEqual(apply_discount(100, 10), 85.0)Step by step, in words
- DiscountTest has two tests. unittest runs each one on its own and lights a lamp next to it.
- Test 1, prepare: a price of €50 and 10% off.
- Act: apply_discount(50, 10) returns 45.0.
- Check: assertEqual compares the expected 45.0 with the returned 45.0. They are the same, so the test passes.
- Test 2: exactly €100 and 10%. We expect 85.0, because from €100 up there is another 5%.
- The condition in the function is price > 100, and 100 is not greater than 100. The function returns 90.0.
- Expected 85.0, returned 90.0: the test fails and says exactly that.
- You fix the condition to price >= 100 and run the tests again.
- Now 100 passes the condition and the function returns 85.0.
- Expected and returned match. Both tests are green.
More precisely, if you are curious
- In the project the code and the tests are files side by side. Test files start with
test, so unittest can find them:
discount.py the codetest_discount.py the tests for it- You run them from the terminal. Without
-v, unittest prints a dot for each test that passed andFfor each one that failed, and then the details of the failed ones:
python -m unittest finds test*.py and runs thempython -m unittest -v with a line for each testpython -m unittest test_discount.DiscountTest.test_ten_percent_off- Here, on the site, the code and the tests are in one file, and the tests are run through unittest's programming interface: a custom
TestResultcollects the result of each test, andrun_testsprints them sorted by name. Sorting keeps the output the same on every run:
def name_of(test): # The docstring of the test or the name of the method (with the subTest case). return test.shortDescription() or test.id().split(".", 2)[2]
class Report(unittest.TestResult): def __init__(self): super().__init__() self.lines = [] self.failed = 0 self.with_cases = set()
def passed(self, name): self.lines.append(f"PASS {name}")
def fell(self, name, err): self.failed += 1 self.lines.append(f"FAIL {name}: {err[1]}")
def addSuccess(self, test): if test.id() not in self.with_cases: self.passed(name_of(test))
def addFailure(self, test, err): self.fell(name_of(test), err)
addError = addFailure
def addSubTest(self, test, subtest, err): self.with_cases.add(test.id()) name = subtest.id().split(".", 2)[2] if err is None: self.passed(name) else: self.fell(name, err)
def run_tests(*tests): loader = unittest.TestLoader() suite = unittest.TestSuite() for t in tests: suite.addTest(t if isinstance(t, unittest.TestSuite) else loader.loadTestsFromTestCase(t)) report = Report() suite.run(report) for line in sorted(report.lines): print(line) total = len(report.lines) print(f"Tests: {total}, passed: {total - report.failed}, failed: {report.failed}")Try it
VAT with float
The price with VAT is the price times 1.2. Two tests:
What will the tests show?
def with_vat(price): return price * 1.2
class VatTest(unittest.TestCase): def test_coffee(self): self.assertEqual(with_vat(10), 12.0)
def test_tea(self): self.assertEqual(with_vat(3), 3.6)3 times 1.2 in a float is not exactly 3.6. Decimal numbers are stored in binary, and a tiny part gets lost in the math. assertEqual compares exactly, so the test fails:
FAIL test_tea: 3.5999999999999996 != 3.6PASS test_coffeeTests: 2, passed: 1, failed: 1assertAlmostEqual with an allowed difference: self.assertAlmostEqual(with_vat(3), 3.6, delta=0.001) passes, because the difference is under 0.001. And 12.0 != 12 would not fail: in Python 12.0 == 12 is true.The idea
Yes or no, and an expected exception
Besides equality, you often check a condition, or that the code refuses. Here is Account from the module on exceptions, with a new method, can_pay:
class InsufficientFundsError(Exception): def __init__(self, shortfall): super().__init__(f"Short by €{shortfall}")
class Account: def __init__(self, balance): self._balance = balance
@property def balance(self): return self._balance
def can_pay(self, amount): return amount <= self._balance
def withdraw(self, amount): if amount <= 0: raise ValueError("The amount must be positive") if amount > self._balance: raise InsufficientFundsError(amount - self._balance) self._balance -= amountimport unittest
from account import Account, InsufficientFundsError
class AccountTest(unittest.TestCase): def test_can_pay_what_is_there(self): account = Account(100) self.assertTrue(account.can_pay(100)) self.assertFalse(account.can_pay(101))
def test_withdraw_too_much(self): account = Account(100) with self.assertRaises(InsufficientFundsError) as caught: account.withdraw(130) self.assertEqual(str(caught.exception), "Short by €30") self.assertEqual(account.balance, 100)PASS test_can_pay_what_is_therePASS test_withdraw_too_muchTests: 2, passed: 2, failed: 0assertTrueandassertFalsecheck a condition.with self.assertRaises(Type):fails if the code in the block does not raise an exception of that type. Withas caughtyou get the exception itself:caught.exception, andstr()of it is the message.
The second test also checks something that is easy to forget: after the refusal the balance is the same.
The idea
Every test starts clean
Here the two tests share one account with €100:
class AccountTest(unittest.TestCase): account = Account(100)
def test_withdraw_40(self): """withdrawing €40 leaves €60""" self.account.withdraw(40) self.assertEqual(self.account.balance, 60)
def test_withdraw_70(self): """withdrawing €70 leaves €30""" self.account.withdraw(70) self.assertEqual(self.account.balance, 30)FAIL withdrawing €70 leaves €30: Short by €10PASS withdrawing €40 leaves €60Tests: 2, passed: 1, failed: 1account is a class attribute: one object for all the tests. unittest runs the tests in alphabetical order of their names, so the test for €40 is first. The second one has €60 left, and it withdraws €70. All it takes is for someone to rename a test or add a new one, and a different test fails. A test that depends on another test is a trap. So the account is created again before each test, in the setUp method:
import unittest
from account import Account
class AccountTest(unittest.TestCase): def setUp(self): self.account = Account(100)
def test_withdraw_40(self): """withdrawing €40 leaves €60""" self.account.withdraw(40) self.assertEqual(self.account.balance, 60)
def test_withdraw_70(self): """withdrawing €70 leaves €30""" self.account.withdraw(70) self.assertEqual(self.account.balance, 30)PASS withdrawing €40 leaves €60PASS withdrawing €70 leaves €30Tests: 2, passed: 2, failed: 0setUp before it, so self.account is a new account every time. The string under def (the docstring) describes the test in words: its first line is shown in the result instead of the method name.More precisely, if you are curious
setUp / tearDown before / after each test (cleanup)setUpClass / tearDownClass once for the whole class (@classmethod)@unittest.skip("...") the test is skipped for now@unittest.skipIf(condition, "...") skipped if the condition is true@unittest.expectedFailure it is expected to failwith self.subTest(...): a separate case inside one testself.addCleanup(function) cleanup after the test, even if it failedpython -m unittest -k withdraw only the tests with "withdraw" in the namepython -m unittest -f stops at the first failed testThe idea
One check, many cases
Four cases for the discount mean four almost identical methods. Instead, you write one method with a loop over the cases "price, percent, expected". with self.subTest(...) makes each case a separate check:
import unittest
from discount import apply_discount
class DiscountTest(unittest.TestCase): def test_apply(self): cases = [ (50, 10, 45.0), (99, 0, 99.0), (100, 10, 85.0), (200, 20, 150.0), ] for price, percent, expected in cases: with self.subTest(f"€{price} with {percent}% off = €{expected}"): self.assertEqual(apply_discount(price, percent), expected)PASS test_apply [€100 with 10% off = €85.0]PASS test_apply [€200 with 20% off = €150.0]PASS test_apply [€50 with 10% off = €45.0]PASS test_apply [€99 with 0% off = €99.0]Tests: 4, passed: 4, failed: 0
Each case has a separate result, and the text in subTest is its name. Without subTest, the first failed case stops the whole method, and you cannot tell whether the others pass. A new case is a new row in the list, not a new method.
The important cases are at the edges: exactly €100, €99, 0%.
Try it
Find the bug from the test
Delivery is €4, and free from €50 up. The test:
class DeliveryTest(unittest.TestCase): def test_price(self): for total, expected in [(20, 4), (50, 0), (80, 0)]: with self.subTest(f"order of €{total}: delivery €{expected}"): self.assertEqual(delivery_price(total), expected)FAIL test_price [order of €50: delivery €0]: 4 != 0PASS test_price [order of €20: delivery €4]PASS test_price [order of €80: delivery €0]Tests: 3, passed: 2, failed: 1Which line of the code is the bug on?
Pick the line with the bug.
The failed test says: for an order of exactly €50, delivery is €4, and it should be 0. So 50 does not pass the condition. total > 50 leaves out 50 itself. It should be total >= 50. The test points to the case, and the code shows the cause.
The idea
The test first
The checkout has to accept the promo code FALL10 for 10% off, whether it is in lowercase or uppercase. This time you write the tests first:
class PromoTest(unittest.TestCase): def test_known_code(self): self.assertEqual(promo_percent("FALL10"), 10)
def test_code_is_case_insensitive(self): self.assertEqual(promo_percent("fall10"), 10)
def test_unknown_code(self): self.assertEqual(promo_percent("NOPE"), 0)- Red. For now the function returns 0. The tests fail, and that is good: it means they really check something.
def promo_percent(code): return 0FAIL test_code_is_case_insensitive: 0 != 10FAIL test_known_code: 0 != 10PASS test_unknown_codeTests: 3, passed: 1, failed: 2- Green. You write the simplest code that makes them pass:
def promo_percent(code): if code.upper() == "FALL10": return 10 return 0PASS test_code_is_case_insensitivePASS test_known_codePASS test_unknown_codeTests: 3, passed: 3, failed: 0- Clean up. Now you can move the codes into a dictionary (dict), add new ones and clean up the function. The tests will tell you if you break something.
This is TDD (test-driven development). Writing the test first makes you decide what exactly the function has to do, before you think about how.
More precisely
Suites and fake objects
A suite (unittest.TestSuite) is a group of tests that run together, for example all the tests of the checkout. The function builds it from the three test classes, and TextTestRunner runs it:
def checkout_suite(): loader = unittest.TestLoader() suite = unittest.TestSuite() suite.addTests(loader.loadTestsFromTestCase(DiscountTest)) suite.addTests(loader.loadTestsFromTestCase(AccountTest)) suite.addTests(loader.loadTestsFromTestCase(DeliveryTest)) return suite
unittest.TextTestRunner().run(checkout_suite())PASS test_extra_five_percent_from_100PASS test_price [order of €20: delivery €4]PASS test_price [order of €50: delivery €0]PASS test_price [order of €80: delivery €0]PASS test_ten_percent_offPASS withdrawing €40 leaves €60PASS withdrawing €70 leaves €30Tests: 7, passed: 7, failed: 0The output is from the site's runner. python -m unittest does the same without a suite: it collects the tests from all the test*.py files on its own.
More precisely, if you are curious
- Sometimes the code depends on something you do not want to call in a test: an exchange rate from a bank, a payment, an email. Then you pass in a fake object (a mock) that returns whatever you tell it to. In Python it is in the standard library:
unittest.mock.Mock:
import unittestfrom unittest.mock import Mock
class Converter: def __init__(self, rates): self.rates = rates
def to_usd(self, eur): return eur * self.rates.eur_to("USD")
class PriceInUsdTest(unittest.TestCase): def test_converts_with_todays_rate(self): rates = Mock() rates.eur_to.return_value = 1.10
converter = Converter(rates) self.assertAlmostEqual(converter.to_usd(10), 11.0, delta=0.001) rates.eur_to.assert_called_once_with("USD")PASS test_converts_with_todays_rateTests: 1, passed: 1, failed: 0Mock() is an object that accepts any method. return_value says what eur_to returns, and assert_called_once_with checks that it was called exactly once, with "USD". You do not need an interface: Converter calls eur_to on whatever you pass it (duck typing).
- More checks besides assertEqual, assertTrue and assertRaises:
assertNotEqual(a, b) are they differentassertIs(a, b), assertIsNot(a, b) is it the same object (is), not just equalassertIsNone(x), assertIsNotNone(x)assertIn(x, items), assertNotIn(x, items)assertCountEqual(a, b) the same elements, in any orderassertListEqual(a, b) two lists with the same elements in the same orderassertRegex(text, pattern) the text matches a regular expressionassertGreater(a, b), assertLess(a, b)assertAlmostEqual(a, b, delta=0.01)self.fail("message") the test fails right awayExercise
Write the tests
Solve these in your editor, with unittest (it comes with Python itself).
1. The discount calculator. Write test_discount.py for apply_discount from this lesson. Cover these cases: €80 with 10% off is €72, €100 with 0% off is €95, €150 with 20% off is €112.50, €0 with 30% off is €0, and exactly €100 with 10% off is €85. Run them against the broken version with price > 100 too. Exactly the two cases with €100 should fail:
FAIL test_apply [€100 with 0% off = €95.0]: 100.0 != 95.0 within 0.001 delta (5.0 difference)FAIL test_exactly_100_gets_the_extra: 90.0 != 85.0 within 0.001 delta (5.0 difference)PASS test_apply [€0 with 30% off = €0.0]PASS test_apply [€150 with 20% off = €112.5]PASS test_apply [€80 with 10% off = €72.0]Tests: 5, passed: 3, failed: 22. The account. Write AccountTest for Account from the card "Yes or no, and an expected exception". Create the account with €100 in setUp. Check that:
- withdrawing €30 leaves €70;
- you can withdraw everything, and 0 is left;
- withdrawing €150 throws
InsufficientFundsErrorwith the message "Short by €50", and the balance stays €100; - 0 and -20 throw
ValueError(one test with subTest for the two amounts).
3. The bug in the average grade. The function looks fine:
def average(grades): total = 0 for grade in grades: total += grade return total // len(grades)FAIL test_average_with_a_fraction: 5 != 5.5 within 0.001 delta (0.5 difference)PASS test_whole_averageTests: 2, passed: 1, failed: 1Find the cause and fix the function, so that both pass.
Test