Testing in Python with unittest — exercises with black box, badly designed tests and a TDD bank account (and the test that always passed)
For a group project I wrote a test suite for the sports centre court bookings. All green, first time. Proudly, I showed it to a classmate, who asked: “and why does the report say 0 tests were run?”. I had called every method check_something, without the test prefix. unittest had not run any of them and, since nothing failed, it said OK. That mistake, and five others just as sneaky, are exercise 2 in this article.
You have already seen the theory and the practice. Now it is your turn, with three exercises on three levels: designing a black box suite, fixing some badly made tests and building a whole class with TDD. Then three tricky snippets, an exam-style quiz and the cheat sheet for the topic.
How to use this article:
- Read the statement and the expected output of each exercise, and write your solution before looking at anything else.
- If you have been stuck for more than 10 minutes, open the hint.
- Before the solutions, try to predict what the “What’s wrong here?” snippets do.
- Compare your code with the commented solution: what matters is understanding why it is done that way.
Table of Contents
Python testing exercises — Basic Level
Exercise 1 — Designing black box tests to validate a deposit
You cannot see this function’s code, only its specification:
validate_deposit(amount, current_balance)
Returns True if the deposit is valid and False if the amount is 0.
Raises TypeError if amount or current_balance are not numbers.
Raises ValueError if the amount is negative,
if the current balance is negative
or if the resulting balance would exceed 100 000 €.
Your task:
- Find the valid and invalid equivalence classes.
- Choose a representative value for each class.
- Identify the boundary values of each boundary.
- Write the suite with
unittest(and an implementation ofvalidate_depositso you can run it).
With the names from the solution, the output should be:
test_ec1_valid_amount (__main__.TestValidateDeposit.test_ec1_valid_amount) ... ok test_ec2_zero_amount (__main__.TestValidateDeposit.test_ec2_zero_amount) ... ok test_ec3_negative_amount (__main__.TestValidateDeposit.test_ec3_negative_amount) ... ok test_ec4_negative_balance (__main__.TestValidateDeposit.test_ec4_negative_balance) ... ok test_ec5_over_limit (__main__.TestValidateDeposit.test_ec5_over_limit) ... ok test_ec6_non_numeric_amount (__main__.TestValidateDeposit.test_ec6_non_numeric_amount) ... ok test_ec7_non_numeric_balance (__main__.TestValidateDeposit.test_ec7_non_numeric_balance) ... ok test_lim_amount_min (__main__.TestValidateDeposit.test_lim_amount_min) ... ok test_lim_balance_zero (__main__.TestValidateDeposit.test_lim_balance_zero) ... ok test_lim_total_100000 (__main__.TestValidateDeposit.test_lim_total_100000) ... ok test_lim_total_over (__main__.TestValidateDeposit.test_lim_total_over) ... ok ---------------------------------------------------------------------- Ran 11 tests in 0.000s OK
Count the different reasons why the function can complain: each one is an invalid class, and each one deserves its own test. There is a trap in the valid classes: an amount of 0 is not an error (it raises nothing), but it is not a deposit either. And the 100 000 € boundary is better tested with integers (99 999 + 1 and 99 999 + 2) than with decimals.
Python testing exercises — Intermediate Level
Exercise 2 — Identify and fix badly designed tests
A classmate wrote the tests for a BankAccount class with this specification:
BankAccount(holder, initial_balance=0)
holder: at least 2 characters; otherwise ValueError
initial_balance: cannot be negative; otherwise ValueError
deposit(amount) ValueError if amount <= 0 or if the balance would go over 100 000
withdraw(amount) ValueError if amount <= 0 or if there are not enough funds
balance, holder read-only properties
And these are their tests. There are six problems, one per test (from A to F):
class TestBankAccount(unittest.TestCase):
# TEST A
def create_account(self):
account = BankAccount('Sergio', 1000)
self.assertEqual(account.balance, 1000)
# TEST B
def test_correct_deposit(self):
account = BankAccount('Sergio', 500)
account.deposit(200)
account.deposit(300)
account.deposit(100)
self.assertEqual(account.balance, 1100)
# TEST C
def test_correct_withdrawal(self):
account = BankAccount('Sergio', 500)
account.withdraw(200)
self.assertEqual(account.balance, 200)
# TEST D
def test_invalid_holder(self):
account = BankAccount('A', 0)
self.assertFalse(account)
# TEST E
def test_insufficient_balance(self):
account = BankAccount('Sergio', 100)
account.withdraw(200)
self.assertEqual(account.balance, -100)
# TEST F
def test_compare_balances(self):
account1 = BankAccount('Sergio', 500)
account2 = BankAccount('María', 500)
self.assertEqual(account1.balance == account2.balance, True)
For each test, say whether it has a problem, what kind it is (name, expected value, wrong assert, too many things at once…) and how you would rewrite it. Before looking at the solution, predict what unittest’s report will say when they run against a correct BankAccount: how many tests, which are ok, which FAIL and which ERROR.
There are three families of problems: tests that never run, tests that expect something the specification does not say (a miscalculated value or an error that should be checked with assertRaises) and tests that pass but are badly written (too many operations, an assert that does not say what it checks).
Python testing exercises — Final Challenge
Exercise 3 — TDD for the complete BankAccount class
Build with TDD the BankAccount from exercise 2’s specification, plus three things:
__str__→'Account of María: 500€'.__repr__→"BankAccount('María', 500)".transfer(amount, target): moves money to another account; if there are no funds or the amount is not valid, it raisesValueErrorand changes neither account.
Follow this order of iterations (red → green → refactor in each) and name the tests with their number so the report comes out in that order: 1, constructor; 2, holder; 3, initial balance; 4, deposit; 5, deposit validation (zero and limit); 6, withdraw; 7, withdraw validation; 8, __str__ and __repr__; 9, transfer. Use setUp to always have two new accounts (Sergio with 1000 € and María with 500 €). The output should be:
test_i1_constructor (__main__.TestBankAccount.test_i1_constructor) ... ok test_i1_default_balance (__main__.TestBankAccount.test_i1_default_balance) ... ok test_i2_holder_ok_with_two (__main__.TestBankAccount.test_i2_holder_ok_with_two) ... ok test_i2_holder_too_short (__main__.TestBankAccount.test_i2_holder_too_short) ... ok test_i3_negative_initial_balance (__main__.TestBankAccount.test_i3_negative_initial_balance) ... ok test_i4_deposit (__main__.TestBankAccount.test_i4_deposit) ... ok test_i5_deposit_nothing (__main__.TestBankAccount.test_i5_deposit_nothing) ... ok test_i5_deposit_reaching_limit (__main__.TestBankAccount.test_i5_deposit_reaching_limit) ... ok test_i5_deposit_surpassing_limit (__main__.TestBankAccount.test_i5_deposit_surpassing_limit) ... ok test_i6_withdraw (__main__.TestBankAccount.test_i6_withdraw) ... ok test_i7_withdraw_excess (__main__.TestBankAccount.test_i7_withdraw_excess) ... ok test_i7_withdraw_negative (__main__.TestBankAccount.test_i7_withdraw_negative) ... ok test_i7_withdraw_total (__main__.TestBankAccount.test_i7_withdraw_total) ... ok test_i8_repr (__main__.TestBankAccount.test_i8_repr) ... ok test_i8_str (__main__.TestBankAccount.test_i8_str) ... ok test_i9_transfer (__main__.TestBankAccount.test_i9_transfer) ... ok test_i9_transfer_without_funds_changes_nothing (__main__.TestBankAccount.test_i9_transfer_without_funds_changes_nothing) ... ok ---------------------------------------------------------------------- Ran 17 tests in 0.001s OK
So that transfer never leaves the accounts half done, first do the operation that can fail (withdraw from the source) and only then deposit into the target. And in iteration 5, test both sides of the limit: 1000 + 99 000 = 100 000 (valid) and 1000 + 99 001 (not valid).
What’s wrong here?
Three short tests about things at the sports centre. Before reading the explanation, predict what unittest says about each.
Snippet 1: the spinning sum that did not add up
import unittest
class TestMonthlyFee(unittest.TestCase):
def test_three_sessions(self):
session_price = 1.10 # euros per spinning session
self.assertEqual(3 * session_price, 3.30)
unittest.main(verbosity=2)
Does it pass or fail?
test_three_sessions (__main__.TestMonthlyFee.test_three_sessions) ... FAIL
======================================================================
FAIL: test_three_sessions (__main__.TestMonthlyFee.test_three_sessions)
----------------------------------------------------------------------
Traceback (most recent call last):
File "decimals.py", line 7, in test_three_sessions
self.assertEqual(3 * session_price, 3.30)
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 3.3000000000000003 != 3.3
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
FAIL: 3 * 1.10 in Python is 3.3000000000000003. Decimals are stored in binary and 1.10 has no exact representation, just as 1/3 has none in decimal. The sum is “right”, but assertEqual compares bit by bit.
Using assertEqual with results of calculations with decimals. Use assertAlmostEqual, which rounds to 7 decimal places before comparing (or work in whole cents).
import unittest
class TestMonthlyFee(unittest.TestCase):
def test_three_sessions(self):
session_price = 1.10 # euros per spinning session
self.assertAlmostEqual(3 * session_price, 3.30) # 7 decimal places
unittest.main(verbosity=2)
test_three_sessions (__main__.TestMonthlyFee.test_three_sessions) ... ok ---------------------------------------------------------------------- Ran 1 test in 0.000s OK
Snippet 2: the assertRaises that does not watch
import unittest
def fee(age):
if age < 0:
raise ValueError('negative age')
return 2 if age < 18 else 5
class TestFee(unittest.TestCase):
def test_negative_age(self):
self.assertRaises(ValueError, fee(-1))
unittest.main(verbosity=2)
Does it pass? The function does raise ValueError with −1…
test_negative_age (__main__.TestFee.test_negative_age) ... ERROR
======================================================================
ERROR: test_negative_age (__main__.TestFee.test_negative_age)
----------------------------------------------------------------------
Traceback (most recent call last):
File "raises.py", line 12, in test_negative_age
self.assertRaises(ValueError, fee(-1))
~~~^^^^
File "raises.py", line 6, in fee
raise ValueError('negative age')
ValueError: negative age
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (errors=1)
ERROR, and the traceback explains it: the exception is raised on the test’s line, before assertRaises starts watching. Python evaluates the arguments before calling a function, so fee(-1) runs first, blows up, and assertRaises never gets anything. That is why it says ERROR and not ok.
There are two correct ways. With with self.assertRaises(ValueError): and the call inside the block (the clearest), or passing the function without brackets and its arguments separately: self.assertRaises(ValueError, fee, -1), so that assertRaises is the one calling it.
test_negative_age (__main__.TestFee.test_negative_age) ... ok ---------------------------------------------------------------------- Ran 1 test in 0.000s OK
Snippet 3: the list that did not start empty
import unittest
class TestBookings(unittest.TestCase):
bookings = [] # CLASS attribute: shared by every test
def test_add_booking(self):
self.bookings.append('Laura 18:00')
self.assertEqual(len(self.bookings), 1)
def test_starts_empty(self):
self.assertEqual(self.bookings, [])
unittest.main(verbosity=2)
Do both pass?
test_add_booking (__main__.TestBookings.test_add_booking) ... ok
test_starts_empty (__main__.TestBookings.test_starts_empty) ... FAIL
======================================================================
FAIL: test_starts_empty (__main__.TestBookings.test_starts_empty)
----------------------------------------------------------------------
Traceback (most recent call last):
File "order.py", line 12, in test_starts_empty
self.assertEqual(self.bookings, [])
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
AssertionError: Lists differ: ['Laura 18:00'] != []
First list contains 1 additional elements.
First extra element 0:
'Laura 18:00'
- ['Laura 18:00']
+ []
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
bookings = [] is written in the class body, not in a method: it is a class attribute, one single list shared by every test. Since unittest runs in alphabetical order, test_add_booking (a) runs before test_starts_empty (s), adds “Laura 18:00” and the second one finds a dirty list. If you renamed the first one test_zz_add, both would pass: a test that passes or fails depending on the order is a badly designed test.
Sharing data that gets modified between tests. Every test must start clean: create the objects in setUp, which runs before each one.
import unittest
class TestBookings(unittest.TestCase):
def setUp(self):
self.bookings = [] # a new list for EACH test
def test_add_booking(self):
self.bookings.append('Laura 18:00')
self.assertEqual(len(self.bookings), 1)
def test_starts_empty(self):
self.assertEqual(self.bookings, [])
unittest.main(verbosity=2)
test_add_booking (__main__.TestBookings.test_add_booking) ... ok test_starts_empty (__main__.TestBookings.test_starts_empty) ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.000s OK
Commented solutions
Solution Exercise 1
| Class | What it groups | Test value | Result |
|---|---|---|---|
| EC1 | amount > 0 and total ≤ 100 000 | 250 with balance 1000 | True |
| EC2 | amount = 0 | 0 with balance 1000 | False |
| EC3 | amount < 0 | −50 | ValueError |
| EC4 | balance < 0 | balance −10 | ValueError |
| EC5 | total > 100 000 | 5000 with balance 99 000 | ValueError |
| EC6 | non-numeric amount | '100' | TypeError |
| EC7 | non-numeric balance | None | TypeError |
| Boundaries | 0.01 with balance 0; 100 with balance 0; 99 999 + 1; 99 999 + 2 | — | True, True, True, ValueError |
import unittest
def validate_deposit(amount, current_balance):
"""True if the deposit is valid, False if the amount is 0.
TypeError if any value is not numeric; ValueError if the amount or the balance is negative
or if the resulting balance would exceed 100 000 €."""
for item in (amount, current_balance):
if isinstance(item, bool) or not isinstance(item, (int, float)):
raise TypeError('amounts must be numbers')
if amount < 0:
raise ValueError('the amount cannot be negative')
if current_balance < 0:
raise ValueError('the balance cannot be negative')
if current_balance + amount > 100_000:
raise ValueError('the 100 000 € limit would be exceeded')
return amount > 0 # 0 is not an error, but not a deposit either
class TestValidateDeposit(unittest.TestCase):
# Valid equivalence classes
def test_ec1_valid_amount(self):
self.assertTrue(validate_deposit(250, 1000))
def test_ec2_zero_amount(self):
self.assertFalse(validate_deposit(0, 1000))
# Invalid classes: one test per reason for an error
def test_ec3_negative_amount(self):
with self.assertRaises(ValueError):
validate_deposit(-50, 1000)
def test_ec4_negative_balance(self):
with self.assertRaises(ValueError):
validate_deposit(50, -10)
def test_ec5_over_limit(self):
with self.assertRaises(ValueError):
validate_deposit(5000, 99000)
def test_ec6_non_numeric_amount(self):
with self.assertRaises(TypeError):
validate_deposit('100', 1000)
def test_ec7_non_numeric_balance(self):
with self.assertRaises(TypeError):
validate_deposit(100, None)
# Boundary values
def test_lim_amount_min(self):
self.assertTrue(validate_deposit(0.01, 0))
def test_lim_balance_zero(self):
self.assertTrue(validate_deposit(100, 0))
def test_lim_total_100000(self):
self.assertTrue(validate_deposit(1, 99_999))
def test_lim_total_over(self):
with self.assertRaises(ValueError):
validate_deposit(2, 99_999)
unittest.main(verbosity=2)
Why it is done this way:
- One test per reason for an error, not one for all of them: if
test_ec4_negative_balancefails, you know exactly which validation broke. A single test with all threeValueErrors inside would just tell you “something fails”. '100'as a non-numeric amount is a trap: it looks like a number. If the function didamount < 0without checking the type, it would compare text with a number and raiseTypeErrorby accident, but with a confusing message. The explicit check makes it clear.isinstance(item, bool)goes first for the same reason as in the practice:Trueis anintand would pass as 1 €.- The 100 000 boundaries are tested with integers. With decimals, 99 999.99 + 0.01 may not give exactly 100 000 and the test would fail because of the binary representation (snippet 1), not because of the function.
return amount > 0returnsTrueorFalsedirectly: that line is only reached with amounts of 0 or more, so 0 givesFalseand any other givesTrue.
Solution Exercise 2 — The 6 problems
This is what unittest says about the classmate’s tests:
test_compare_balances (__main__.TestBankAccount.test_compare_balances) ... ok
test_correct_deposit (__main__.TestBankAccount.test_correct_deposit) ... ok
test_correct_withdrawal (__main__.TestBankAccount.test_correct_withdrawal) ... FAIL
test_insufficient_balance (__main__.TestBankAccount.test_insufficient_balance) ... ERROR
test_invalid_holder (__main__.TestBankAccount.test_invalid_holder) ... ERROR
======================================================================
ERROR: test_insufficient_balance (__main__.TestBankAccount.test_insufficient_balance)
----------------------------------------------------------------------
Traceback (most recent call last):
File "bad_tests.py", line 76, in test_insufficient_balance
account.withdraw(200)
~~~~~~~~~~~~~~~~^^^^^
File "bad_tests.py", line 34, in withdraw
raise ValueError('insufficient balance')
ValueError: insufficient balance
======================================================================
ERROR: test_invalid_holder (__main__.TestBankAccount.test_invalid_holder)
----------------------------------------------------------------------
Traceback (most recent call last):
File "bad_tests.py", line 70, in test_invalid_holder
account = BankAccount('A', 0)
File "bad_tests.py", line 9, in __init__
raise ValueError('the holder needs at least 2 characters')
ValueError: the holder needs at least 2 characters
======================================================================
FAIL: test_correct_withdrawal (__main__.TestBankAccount.test_correct_withdrawal)
----------------------------------------------------------------------
Traceback (most recent call last):
File "bad_tests.py", line 66, in test_correct_withdrawal
self.assertEqual(account.balance, 200)
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 300 != 200
----------------------------------------------------------------------
Ran 5 tests in 0.001s
FAILED (failures=1, errors=2)
Only five tests run: test A does not exist for unittest. Test by test:
| Test | Problem | Kind | Fix |
|---|---|---|---|
A create_account | Does not start with test: never runs | Name | test_a_create_account |
B test_correct_deposit | Three deposits in one test: if it fails, you do not know which | Too many things | One deposit and its result: 500 + 200 = 700 |
C test_correct_withdrawal | 500 − 200 is 300, not 200: FAIL | Expected value | assertEqual(account.balance, 300) |
D test_invalid_holder | The constructor raises ValueError and the test ends in ERROR; assertFalse(account) makes no sense | Wrong assert | with self.assertRaises(ValueError): |
E test_insufficient_balance | Expects a balance of −100, but the specification says ValueError: ERROR | Spec misread | assertRaises and check the balance did not change |
F test_compare_balances | assertEqual(a == b, True): passes, but if it fails it says “False != True” | Unclear assert | assertEqual(a.balance, b.balance) |
The fixed tests (with the letter in the name so the report follows the table’s order):
class TestBankAccount(unittest.TestCase):
def test_a_create_account(self): # A: with the test_ prefix
account = BankAccount('Sergio', 1000)
self.assertEqual(account.balance, 1000)
def test_b_deposit_adds_to_balance(self): # B: one operation, one result
account = BankAccount('Sergio', 500)
account.deposit(200)
self.assertEqual(account.balance, 700)
def test_c_withdraw_subtracts(self): # C: 500 - 200 = 300
account = BankAccount('Sergio', 500)
account.withdraw(200)
self.assertEqual(account.balance, 300)
def test_d_short_holder_raises_error(self): # D: the exception is expected, not suffered
with self.assertRaises(ValueError):
BankAccount('A', 0)
def test_e_withdraw_without_funds_raises(self): # E: and the balance does not change
account = BankAccount('Sergio', 100)
with self.assertRaises(ValueError):
account.withdraw(200)
self.assertEqual(account.balance, 100)
def test_f_equal_balances(self): # F: the assert that says what it checks
self.assertEqual(BankAccount('Sergio', 500).balance, BankAccount('María', 500).balance)
test_a_create_account (__main__.TestBankAccount.test_a_create_account) ... ok test_b_deposit_adds_to_balance (__main__.TestBankAccount.test_b_deposit_adds_to_balance) ... ok test_c_withdraw_subtracts (__main__.TestBankAccount.test_c_withdraw_subtracts) ... ok test_d_short_holder_raises_error (__main__.TestBankAccount.test_d_short_holder_raises_error) ... ok test_e_withdraw_without_funds_raises (__main__.TestBankAccount.test_e_withdraw_without_funds_raises) ... ok test_f_equal_balances (__main__.TestBankAccount.test_f_equal_balances) ... ok ---------------------------------------------------------------------- Ran 6 tests in 0.000s OK
Look at the fixed test E: besides checking the ValueError, it checks that the balance is still 100. An operation that fails must not leave the object half done.
Solution Exercise 3
import unittest
class BankAccount:
LIMIT = 100_000
def __init__(self, holder, initial_balance=0):
if not isinstance(holder, str) or len(holder) < 2:
raise ValueError('the holder needs at least 2 characters')
if initial_balance < 0:
raise ValueError('the initial balance cannot be negative')
self.__holder = holder
self.__balance = initial_balance
@property
def holder(self):
return self.__holder
@property
def balance(self):
return self.__balance
def deposit(self, amount):
if amount <= 0:
raise ValueError('the amount must be positive')
if self.__balance + amount > self.LIMIT:
raise ValueError('the account limit would be exceeded')
self.__balance += amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError('the amount must be positive')
if amount > self.__balance:
raise ValueError('insufficient balance')
self.__balance -= amount
def transfer(self, amount, target):
self.withdraw(amount) # if there are no funds, it fails here and the target is untouched
target.deposit(amount)
def __str__(self):
return f'Account of {self.__holder}: {self.__balance}€'
def __repr__(self):
return f'BankAccount({self.__holder!r}, {self.__balance})'
class TestBankAccount(unittest.TestCase):
def setUp(self): # new accounts in every test
self.account = BankAccount('Sergio', 1000)
self.other = BankAccount('María', 500)
# Iterations 1-3: constructor and validation
def test_i1_constructor(self):
self.assertEqual((self.account.holder, self.account.balance), ('Sergio', 1000))
def test_i1_default_balance(self):
self.assertEqual(BankAccount('Ana').balance, 0)
def test_i2_holder_ok_with_two(self):
self.assertEqual(BankAccount('Al').holder, 'Al')
def test_i2_holder_too_short(self):
with self.assertRaises(ValueError):
BankAccount('A')
def test_i3_negative_initial_balance(self):
with self.assertRaises(ValueError):
BankAccount('Ana', -1)
# Iterations 4-5: deposit
def test_i4_deposit(self):
self.account.deposit(250)
self.assertEqual(self.account.balance, 1250)
def test_i5_deposit_nothing(self):
with self.assertRaises(ValueError):
self.account.deposit(0)
def test_i5_deposit_reaching_limit(self):
self.account.deposit(99_000)
self.assertEqual(self.account.balance, 100_000)
def test_i5_deposit_surpassing_limit(self):
with self.assertRaises(ValueError):
self.account.deposit(99_001)
# Iterations 6-7: withdraw
def test_i6_withdraw(self):
self.account.withdraw(400)
self.assertEqual(self.account.balance, 600)
def test_i7_withdraw_total(self):
self.account.withdraw(1000)
self.assertEqual(self.account.balance, 0)
def test_i7_withdraw_excess(self):
with self.assertRaises(ValueError):
self.account.withdraw(1001)
def test_i7_withdraw_negative(self):
with self.assertRaises(ValueError):
self.account.withdraw(-5)
# Iteration 8: text
def test_i8_repr(self):
self.assertEqual(repr(self.other), "BankAccount('María', 500)")
def test_i8_str(self):
self.assertEqual(str(self.other), 'Account of María: 500€')
# Iteration 9: transfer
def test_i9_transfer(self):
self.account.transfer(300, self.other)
self.assertEqual((self.account.balance, self.other.balance), (700, 800))
def test_i9_transfer_without_funds_changes_nothing(self):
with self.assertRaises(ValueError):
self.other.transfer(600, self.account)
self.assertEqual((self.account.balance, self.other.balance), (1000, 500))
unittest.main(verbosity=2)
The design decisions that came out of the tests:
def deposit(self, amount):
if amount <= 0:
raise ValueError('the amount must be positive')
if self.__balance + amount > self.LIMIT:
raise ValueError('the account limit would be exceeded')
self.__balance += amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError('the amount must be positive')
if amount > self.__balance:
raise ValueError('insufficient balance')
self.__balance -= amount
def transfer(self, amount, target):
self.withdraw(amount) # if there are no funds, it fails here and the target is untouched
target.deposit(amount)
depositandwithdrawvalidate before touching__balance: if something fails, the balance stays as it was.transferdoes not repeat the validation: it callswithdraw(which checks amount and funds) and thendeposit(which checks the target’s limit). Ifwithdrawraises the exception,depositnever runs. That is whytest_i9_transfer_without_funds_changes_nothingpasses: María tries to send 600 € with 500 and both accounts stay the same.- The attributes are private (
__balance) and read through@property: that way nobody can skip the validation withaccount.balance = -50. setUpcreates both accounts for every test.test_i4_depositleaves Sergio’s account at 1250, but the next test finds it at 1000 again.
Checking the ones with sums by hand: 1000 + 250 = 1250; 1000 + 99 000 = 100 000 (exactly the limit); 1000 − 400 = 600; transfer of 300: 1000 − 300 = 700 and 500 + 300 = 800.
Exam-style quiz
Now test yourself as in the lab. FP2 quizzes are like this: about 10 questions in 10-15 minutes, single answer, multiple answer (wrong answers subtract), true or false, fill in the gaps, matching and “what does this code print?”. Here are 13 original questions on program testing. Answer without running anything, press Check and read the explanation of each one. These questions are also the bank for the FP2 theory mock exam, which will mix them with those of the other topics.
What is the goal of testing a program?
- Finding defects: all tests passing doesn’t prove none are left
- Proving the program has no errors
- Measuring how fast the program is
- Replacing the documentation
Solution
Answer: Finding defects: all tests passing doesn’t prove none are left
A test looks for a failure. If it finds one, it has done its job; if not, it increases your confidence, but doesn’t turn it into certainty.
To test discount(price, member) you decide that (100, True) must return 90. Complete:
That is a ____: (100, True) is the ____ and 90 is the ____. If the function returns something else, the test ____.
Words:
- passes
- test case
- expected result
- fails
- assert
- traceback
- input data
Solution
That is a test case: (100, True) is the input data and 90 is the expected result. If the function returns something else, the test fails.
Each test case is thought out before running anything: what data you give it and what should come out. Without an expected result there’s no way of knowing whether the output is right.
Put these kinds of testing in the order they happen during a project:
- Unit tests: each function or class on its own
- System tests, beta phase: real users from outside the team
- System tests, alpha phase: the whole program, tested by the team
- Integration tests: several pieces working together
Solution
Correct order:
- Unit tests: each function or class on its own
- Integration tests: several pieces working together
- System tests, alpha phase: the whole program, tested by the team
- System tests, beta phase: real users from outside the team
From small to big: first each piece (unittest is designed for this), then how they fit together, and finally the whole system, first in-house (alpha) and then with users (beta).
Match each kind of test with its description:
| Black box | |
| White box | |
| Regression | |
| Unit |
Definitions:
- Reruns the tests after a change to check nothing broke
- Tests a single function or method in isolation
- Designs cases by looking at the code (its branches and loops)
- Only looks at inputs and outputs without reading the code
- Measures how many users the server can handle at once
Solution
- Black box → Only looks at inputs and outputs without reading the code
- White box → Designs cases by looking at the code (its branches and loops)
- Regression → Reruns the tests after a change to check nothing broke
- Unit → Tests a single function or method in isolation
In black box testing you only know the interface (what it takes and what it returns); in white box testing you use the code so that every branch runs at least once. Rerunning your unittest tests after each change is a regression test.
Match each unittest.TestCase method with what it checks:
assertEqual(a, b) | |
assertTrue(x) | |
assertIn(a, b) | |
assertAlmostEqual(a, b) | |
assertIsNone(x) |
Definitions:
- x is None
- a == b
- a is inside b
- bool(x) is True
- a and b match when rounded to 7 decimal places
- a is not b
Solution
assertEqual(a, b)→ a == bassertTrue(x)→ bool(x) is TrueassertIn(a, b)→ a is inside bassertAlmostEqual(a, b)→ a and b match when rounded to 7 decimal placesassertIsNone(x)→ x is None
a is not b would be assertIsNot(a, b). Choosing the specific assert (and not always assertTrue) makes the failure message say which values didn’t match.
How do you check in a test that root(-1) raises ValueError?
with self.assertRaises(ValueError): root(-1)self.assertEqual(root(-1), ValueError)self.assertRaises(ValueError, root(-1))self.assertTrue(root(-1) is ValueError)
Solution
Answer: with self.assertRaises(ValueError): root(-1)
The clearest way is the context manager with self.assertRaises(...). If nothing is raised inside, or a different type is raised, the test fails.
About unittest, true or false?
- Only methods whose name starts with
testrun as tests. setUpruns only once, before all the tests in the class.- A test can rely on another one having run before it.
unittest.main()finds and runs the tests in the module.self.assertEqual(0.1 + 0.2, 0.3)passes.
Solution
- T — Only methods whose name starts with
testrun as tests. - F —
setUpruns only once, before all the tests in the class. - F — A test can rely on another one having run before it.
- T —
unittest.main()finds and runs the tests in the module. - F —
self.assertEqual(0.1 + 0.2, 0.3)passes.
setUp runs before each test, so that they all start from scratch (once for the whole class is setUpClass). That’s why tests mustn’t depend on their order. And 0.1 + 0.2 is 0.30000000000000004: for decimals, assertAlmostEqual.
When this file runs, what summary line does unittest write (one character per test)?
import unittest
def double(x):
return x * 2
class TestDouble(unittest.TestCase):
def test_1_zero(self):
self.assertEqual(double(0), 0)
def test_2_negative(self):
self.assertEqual(double(-3), -6)
def test_3_text(self):
self.assertEqual(double("ab"), "abab")
def test_4_one(self):
self.assertEqual(double(1), 3)
unittest.main()...FF.....F....E
Solution
Answer: ...F
Each test leaves one character: . if it passes, F (failure) if an assert doesn’t hold and E (error) if an exception nobody expected is raised. The methods run sorted alphabetically by name, not in file order: that’s why they’re numbered here.
What character does this test leave in the summary?
import unittest
def mean(marks):
return sum(marks) / len(marks)
class TestMean(unittest.TestCase):
def test_empty(self):
self.assertEqual(mean([]), 0)
unittest.main()EF.s
Solution
Answer: E
The difference between F and E tells you where to look: with F the code returns something, just not what was expected; with E the code doesn’t even get to return anything.
Black box tests for is_leap(year). Which cases add something new? (Leap: a multiple of 4, except multiples of 100 that aren’t multiples of 400.)
- 2024: multiple of 4
- 2023: not a multiple of 4
- 1900: multiple of 100 but not of 400
- 2000: multiple of 400
- 2028: another ordinary multiple of 4
Solution
Answer: 2024: multiple of 4 · 2023: not a multiple of 4 · 1900: multiple of 100 but not of 400 · 2000: multiple of 400
You choose one case per equivalence class (groups of data the program should treat the same way) and focus on the edges of the rule. Repeating cases from the same class makes testing longer without finding more failures.
What is the name of the TestCase method that runs before each test to set up the objects?
Solution
Answer: setUp
Its partner is tearDown, which runs after each test (to close files or delete temporary ones).
Why do you use assertAlmostEqual instead of assertEqual for float results?
- Because decimals are stored in binary with small rounding errors
- Because
assertEqualdoesn’t acceptfloat - Because it’s faster
- Because it always rounds to 2 decimal places
Solution
Answer: Because decimals are stored in binary with small rounding errors
assertAlmostEqual(a, b) checks round(a - b, 7) == 0. You can change it with places or delta.
What number does this program print (how many cases fail)?
def final_mark(theory, practice):
return round(theory * 0.4 + practice * 0.6, 1)
cases = [((10, 10), 10), ((5, 5), 5), ((7, 9), 8.4), ((0, 10), 6)]
failures = 0
for (t, p), expected in cases:
if final_mark(t, p) != expected:
failures += 1
print(failures)Solution
Answer: 1
10·0.4 + 10·0.6 = 10; 5·0.4 + 5·0.6 = 5; 0·0.4 + 10·0.6 = 6. The third: 7·0.4 + 9·0.6 = 2.8 + 5.4 = 8.2, not 8.4: it fails. Here what’s wrong is the test case (the expected value), not the function: that happens too.
Visualise it
Python Tutor does not run unittest, but it does let you see why snippet 1 failed. In Python Tutor, choose Python 3 and paste:
session_price = 1.10 total = 3 * session_price print(total) print(total == 3.30) difference = abs(total - 3.30) print(difference < 0.0000001) print(round(total, 7) == round(3.30, 7))
Output:
3.3000000000000003 False True True
What to watch:
- In the global frame,
totalshows as3.3000000000000003: that is the valueassertEqualreceived. total == 3.30givesFalseeven though “by hand” it is the same sum.differenceis a tiny number (around 4 × 10⁻¹⁶): comparing with a margin, asassertAlmostEqualdoes, solves the problem.round(total, 7)is literally whatassertAlmostEqualdoes inside: round to 7 decimal places and compare.
Cheat sheet: Python testing with unittest
The essentials in 30 seconds: a class inheriting from unittest.TestCase, methods starting with test, one assert… per idea and unittest.main(verbosity=2). Cases are chosen with black box (equivalence classes and boundary values) and white box (one path per unit of V(G)). In TDD, the test comes before the code: red, green, refactor.
| I want… | Code | Result |
|---|---|---|
| To check a value | self.assertEqual(f(30), 5) | ok or FAIL: 4 != 5 |
| To check decimals | self.assertAlmostEqual(3 * 1.1, 3.3) | Compares to 7 decimal places |
| To check an exception | with self.assertRaises(ValueError): f(-1) | ok if it is raised |
| To check a boolean | self.assertTrue(x) / self.assertFalse(x) | — |
| Several cases in one test | for x in data: with self.subTest(x=x): … | The report says which one failed |
| To prepare every test | def setUp(self): self.a = Account('Ana') | A new object in every test |
| To prepare once | @classmethod def setUpClass(cls): … | Before the first test |
| To run with detail | unittest.main(verbosity=2) or python3 -m unittest -v | One line per test |
Templates from the exercises:
| Technique | Template | Result |
|---|---|---|
| Invalid class | with self.assertRaises(ValueError): validate_deposit(-50, 1000) | ok |
| Boundary value | self.assertTrue(validate_deposit(1, 99_999)) and assertRaises with (2, 99_999) | Both sides |
| Failing operation with no side effects | with self.assertRaises(...): a.withdraw(200) + assertEqual(a.balance, 100) | Balance untouched |
| Ordered TDD | def test_i4_deposit(self): … | The report follows the iterations |
| V(G) | edges − nodes + 2 = predicates + 1 | Minimum number of paths |
| Symptom | Likely cause |
|---|---|
Ran 0 tests and OK | The methods do not start with test |
FAIL: 3.3000000000000003 != 3.3 | assertEqual with decimals |
ERROR in an exception test | assertRaises(Error, f(x)) with brackets |
| A test passes alone and fails with the others | Data shared between tests (class attribute) |
ValueError not raised | The code does not validate what the test expects (TDD red) |
Everything ok first time, no surprises | You only test the happy path: boundaries and invalid cases are missing |
Before handing in, ask yourself:
- Do I have a test for every equivalence class, valid and invalid?
- Have I tested both sides of every boundary?
- Does every test start clean (
setUp) and work in any order? - Have I seen every test red at least once?
Did this article help you? Here is how you can support SergioLearns →




The hardware that holds up the operating system — theory with my IC2 assignment on the printer, an interrupt that stops everything and a cache that hits (and the infinite loop that didn’t freeze my laptop)