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:

  1. Read the statement and the expected output of each exercise, and write your solution before looking at anything else.
  2. If you have been stuck for more than 10 minutes, open the hint.
  3. Before the solutions, try to predict what the “What’s wrong here?” snippets do.
  4. Compare your code with the commented solution: what matters is understanding why it is done that way.

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:

  1. Find the valid and invalid equivalence classes.
  2. Choose a representative value for each class.
  3. Identify the boundary values of each boundary.
  4. Write the suite with unittest (and an implementation of validate_deposit so 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 raises ValueError and 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

ClassWhat it groupsTest valueResult
EC1amount > 0 and total ≤ 100 000250 with balance 1000True
EC2amount = 00 with balance 1000False
EC3amount < 0−50ValueError
EC4balance < 0balance −10ValueError
EC5total > 100 0005000 with balance 99 000ValueError
EC6non-numeric amount'100'TypeError
EC7non-numeric balanceNoneTypeError
Boundaries0.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_balance fails, you know exactly which validation broke. A single test with all three ValueErrors inside would just tell you “something fails”.
  • '100' as a non-numeric amount is a trap: it looks like a number. If the function did amount < 0 without checking the type, it would compare text with a number and raise TypeError by 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: True is an int and 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 > 0 returns True or False directly: that line is only reached with amounts of 0 or more, so 0 gives False and any other gives True.

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)
ok, FAIL and ERRORSix methods: two pass, one FAILs, two ERROR and one never runs because it does not start with test_.The classmate's tests, one by onetest_compare_balancesokpasses (though the assert is ugly)test_correct_depositokpasses, but tests three thingstest_correct_withdrawalFAILan assert does not hold: 300 != 200test_insufficient_balanceERRORan unexpected exception is raisedtest_invalid_holderERRORValueError in the constructorcreate_account—no test_: unittest does not even see itRan 5 tests · FAILED (failures=1, errors=2)
FAIL: the code did something else. ERROR: the test never even got to check anything.

Only five tests run: test A does not exist for unittest. Test by test:

TestProblemKindFix
A create_accountDoes not start with test: never runsNametest_a_create_account
B test_correct_depositThree deposits in one test: if it fails, you do not know whichToo many thingsOne deposit and its result: 500 + 200 = 700
C test_correct_withdrawal500 − 200 is 300, not 200: FAILExpected valueassertEqual(account.balance, 300)
D test_invalid_holderThe constructor raises ValueError and the test ends in ERROR; assertFalse(account) makes no senseWrong assertwith self.assertRaises(ValueError):
E test_insufficient_balanceExpects a balance of −100, but the specification says ValueError: ERRORSpec misreadassertRaises and check the balance did not change
F test_compare_balancesassertEqual(a == b, True): passes, but if it fails it says “False != True”Unclear assertassertEqual(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)
  • deposit and withdraw validate before touching __balance: if something fails, the balance stays as it was.
  • transfer does not repeat the validation: it calls withdraw (which checks amount and funds) and then deposit (which checks the target’s limit). If withdraw raises the exception, deposit never runs. That is why test_i9_transfer_without_funds_changes_nothing passes: 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 with account.balance = -50.
  • setUp creates both accounts for every test. test_i4_deposit leaves 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.

Question 1Single choice

What is the goal of testing a program?

  1. Finding defects: all tests passing doesn’t prove none are left
  2. Proving the program has no errors
  3. Measuring how fast the program is
  4. 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.

Question 2Fill in the gaps

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.

Question 3Order

Put these kinds of testing in the order they happen during a project:

  1. Unit tests: each function or class on its own
  2. System tests, beta phase: real users from outside the team
  3. System tests, alpha phase: the whole program, tested by the team
  4. Integration tests: several pieces working together
Solution

Correct order:

  1. Unit tests: each function or class on its own
  2. Integration tests: several pieces working together
  3. System tests, alpha phase: the whole program, tested by the team
  4. 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).

Question 4Match

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.

Question 5Match

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 == b
  • assertTrue(x) → bool(x) is True
  • assertIn(a, b) → a is inside b
  • assertAlmostEqual(a, b) → a and b match when rounded to 7 decimal places
  • assertIsNone(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.

Question 6Single choice

How do you check in a test that root(-1) raises ValueError?

  1. with self.assertRaises(ValueError): root(-1)
  2. self.assertEqual(root(-1), ValueError)
  3. self.assertRaises(ValueError, root(-1))
  4. 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.

Question 7True or false

About unittest, true or false?

  1. Only methods whose name starts with test run as tests.
  2. setUp runs only once, before all the tests in the class.
  3. A test can rely on another one having run before it.
  4. unittest.main() finds and runs the tests in the module.
  5. self.assertEqual(0.1 + 0.2, 0.3) passes.
Solution
  • T — Only methods whose name starts with test run as tests.
  • F — setUp runs 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.

Question 8Single choice

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()
  1. ...F
  2. F...
  3. ..F.
  4. ...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.

Question 9Single choice

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()
  1. E
  2. F
  3. .
  4. 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.

Question 10Multiple · wrong ticks subtract

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.)

  1. 2024: multiple of 4
  2. 2023: not a multiple of 4
  3. 1900: multiple of 100 but not of 400
  4. 2000: multiple of 400
  5. 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.

Question 11Short answer

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).

Question 12Single choice

Why do you use assertAlmostEqual instead of assertEqual for float results?

  1. Because decimals are stored in binary with small rounding errors
  2. Because assertEqual doesn’t accept float
  3. Because it’s faster
  4. 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.

Question 13Short answer

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:

  1. In the global frame, total shows as 3.3000000000000003: that is the value assertEqual received.
  2. total == 3.30 gives False even though “by hand” it is the same sum.
  3. difference is a tiny number (around 4 × 10⁻¹⁶): comparing with a margin, as assertAlmostEqual does, solves the problem.
  4. round(total, 7) is literally what assertAlmostEqual does 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…CodeResult
To check a valueself.assertEqual(f(30), 5)ok or FAIL: 4 != 5
To check decimalsself.assertAlmostEqual(3 * 1.1, 3.3)Compares to 7 decimal places
To check an exceptionwith self.assertRaises(ValueError): f(-1)ok if it is raised
To check a booleanself.assertTrue(x) / self.assertFalse(x)—
Several cases in one testfor x in data: with self.subTest(x=x): …The report says which one failed
To prepare every testdef 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 detailunittest.main(verbosity=2) or python3 -m unittest -vOne line per test

Templates from the exercises:

TechniqueTemplateResult
Invalid classwith self.assertRaises(ValueError): validate_deposit(-50, 1000)ok
Boundary valueself.assertTrue(validate_deposit(1, 99_999)) and assertRaises with (2, 99_999)Both sides
Failing operation with no side effectswith self.assertRaises(...): a.withdraw(200) + assertEqual(a.balance, 100)Balance untouched
Ordered TDDdef test_i4_deposit(self): …The report follows the iterations
V(G)edges − nodes + 2 = predicates + 1Minimum number of paths
SymptomLikely cause
Ran 0 tests and OKThe methods do not start with test
FAIL: 3.3000000000000003 != 3.3assertEqual with decimals
ERROR in an exception testassertRaises(Error, f(x)) with brackets
A test passes alone and fails with the othersData shared between tests (class attribute)
ValueError not raisedThe code does not validate what the test expects (TDD red)
Everything ok first time, no surprisesYou only test the happy path: boundaries and invalid cases are missing

Before handing in, ask yourself:

  1. Do I have a test for every equivalence class, valid and invalid?
  2. Have I tested both sides of every boundary?
  3. Does every test start clean (setUp) and work in any order?
  4. Have I seen every test red at least once?
Keep goingAll Python articles

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *