Solutions 18: Testing and Code Quality¶
Overview¶
Chapter 18 exercises cover writing unit tests with pytest and unittest, testing edge cases, using fixtures, measuring code coverage, writing docstring tests, and using code quality tools. This guide explains the reasoning behind each solution and highlights best practices for writing reliable, maintainable tests.
Notes Before Checking Solutions¶
Tests are not a chore — they are a tool that lets you change code confidently. A good test suite tells you immediately when a change breaks something. Write tests as you write code, not after.
Warm-up Exercise Solutions¶
Exercise 1: Write Unit Tests with pytest¶
calculator.py:
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
test_calculator.py:
import pytest
from calculator import add, subtract, multiply, divide
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
def test_subtract():
assert subtract(5, 3) == 2
assert subtract(0, 5) == -5
def test_multiply():
assert multiply(3, 4) == 12
assert multiply(-2, 3) == -6
assert multiply(0, 100) == 0
def test_divide():
assert divide(10, 2) == 5
assert divide(7, 2) == 3.5
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(10, 0)
Run:
pytest.raises(ValueError) asserts that the code inside the with block raises a ValueError. If no exception is raised, the test fails. If a different exception is raised, it propagates and the test errors.
Multiple assertions per test are fine when they test the same behavior. test_add() tests several inputs for the add() function. If you want to know which specific input failed, use pytest.mark.parametrize (see below).
Test function names must start with test_. pytest discovers tests by looking for functions and methods whose names start with test_.
Exercise 2: Write Tests with unittest¶
string_utils.py:
def reverse(text):
return text[::-1]
def is_palindrome(text):
text = text.lower().replace(" ", "")
return text == text[::-1]
def count_vowels(text):
return sum(1 for c in text.lower() if c in "aeiou")
test_string_utils.py:
import unittest
from string_utils import reverse, is_palindrome, count_vowels
class TestStringUtils(unittest.TestCase):
def test_reverse(self):
self.assertEqual(reverse("hello"), "olleh")
self.assertEqual(reverse(""), "")
self.assertEqual(reverse("a"), "a")
def test_is_palindrome(self):
self.assertTrue(is_palindrome("racecar"))
self.assertTrue(is_palindrome("A man a plan a canal Panama"))
self.assertFalse(is_palindrome("hello"))
def test_count_vowels(self):
self.assertEqual(count_vowels("hello"), 2)
self.assertEqual(count_vowels("aeiou"), 5)
self.assertEqual(count_vowels("xyz"), 0)
if __name__ == "__main__":
unittest.main()
pytest vs. unittest: pytest is simpler — plain assert statements, no class required. unittest is the built-in framework and is more verbose but familiar to developers from other languages. Both work; pytest is the modern standard for new projects.
Exercise 3: Test Edge Cases¶
validation.py:
def validate_email(email):
if "@" not in email or "." not in email:
return False
parts = email.split("@")
if len(parts) != 2:
return False
return len(parts[0]) > 0 and len(parts[1]) > 0
def validate_age(age):
try:
age = int(age)
return 0 <= age <= 150
except ValueError:
return False
def validate_password(password):
if len(password) < 8:
return False
has_upper = any(c.isupper() for c in password)
has_lower = any(c.islower() for c in password)
has_digit = any(c.isdigit() for c in password)
return has_upper and has_lower and has_digit
test_validation.py:
import pytest
from validation import validate_email, validate_age, validate_password
class TestValidateEmail:
def test_valid_email(self):
assert validate_email("user@example.com")
def test_missing_at(self):
assert not validate_email("userexample.com")
def test_missing_dot(self):
assert not validate_email("user@example")
def test_empty_local(self):
assert not validate_email("@example.com")
def test_empty_domain(self):
assert not validate_email("user@")
class TestValidateAge:
def test_valid_age(self):
assert validate_age(25)
assert validate_age(0)
assert validate_age(150)
def test_negative_age(self):
assert not validate_age(-1)
def test_too_old(self):
assert not validate_age(151)
def test_non_numeric(self):
assert not validate_age("abc")
Test edge cases explicitly. The most common bugs live at boundaries: zero, negative numbers, empty strings, the maximum allowed value. Test these explicitly rather than assuming they work.
Group related tests in a class. TestValidateEmail groups all email validation tests. This makes the test output easier to read and lets you share setup code with setUp() or fixtures.
Exercise 4: Use Code Linters¶
flake8 reports style violations (PEP 8) and potential errors. black automatically reformats code to a consistent style. Use both: black fixes formatting, flake8 catches issues black does not fix (like unused imports).
Configure flake8 with a .flake8 file to adjust line length or ignore specific rules:
Practice Exercise Solutions¶
Exercise 5: Test with Fixtures¶
database.py:
class Database:
def __init__(self):
self.data = {}
def add(self, key, value):
self.data[key] = value
def get(self, key):
if key not in self.data:
raise KeyError(f"Key '{key}' not found")
return self.data[key]
def delete(self, key):
if key not in self.data:
raise KeyError(f"Key '{key}' not found")
del self.data[key]
def clear(self):
self.data.clear()
test_database.py:
import pytest
from database import Database
@pytest.fixture
def db():
"""Create a fresh database for each test."""
database = Database()
yield database
database.clear()
def test_add_and_get(db):
db.add("name", "Alice")
assert db.get("name") == "Alice"
def test_get_nonexistent(db):
with pytest.raises(KeyError):
db.get("nonexistent")
def test_delete(db):
db.add("name", "Alice")
db.delete("name")
with pytest.raises(KeyError):
db.get("name")
def test_multiple_items(db):
db.add("a", 1)
db.add("b", 2)
assert db.get("a") == 1
assert db.get("b") == 2
Fixtures provide setup and teardown. The yield in the fixture separates setup (before yield) from teardown (after yield). Each test gets a fresh Database instance, so tests do not interfere with each other.
conftest.py is a special file where you can define fixtures shared across multiple test files. pytest discovers it automatically.
Exercise 6: Measure Code Coverage¶
The --cov-report=term-missing flag shows which lines are not covered by tests.
100% coverage does not mean bug-free. Coverage tells you which lines were executed, not whether the behavior is correct. A test that calls a function but does not assert anything gives 100% coverage with zero value.
Aim for high coverage on critical code. Focus on covering edge cases and error paths, not just the happy path.
Exercise 7: Write Docstring Tests¶
def add(a, b):
"""
Add two numbers.
>>> add(2, 3)
5
>>> add(-1, 1)
0
"""
return a + b
def reverse_string(text):
"""
Reverse a string.
>>> reverse_string("hello")
'olleh'
>>> reverse_string("")
''
"""
return text[::-1]
Run:
Doctest is good for simple examples in documentation. It ensures the examples in your docstrings actually work. For complex tests, use pytest — doctest is not a replacement for a full test suite.
Challenge Exercise Solutions¶
Challenge 1: Test a Complex Class¶
bank_account.py:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
self.transactions = []
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self.balance += amount
self.transactions.append(("deposit", amount))
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal must be positive")
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
self.transactions.append(("withdraw", amount))
def get_balance(self):
return self.balance
def get_transactions(self):
return self.transactions.copy()
test_bank_account.py:
import pytest
from bank_account import BankAccount
@pytest.fixture
def account():
return BankAccount("Alice", 1000)
def test_initial_balance(account):
assert account.get_balance() == 1000
def test_deposit(account):
account.deposit(500)
assert account.get_balance() == 1500
def test_withdraw(account):
account.withdraw(300)
assert account.get_balance() == 700
def test_deposit_negative(account):
with pytest.raises(ValueError):
account.deposit(-100)
def test_withdraw_more_than_balance(account):
with pytest.raises(ValueError):
account.withdraw(2000)
def test_transactions(account):
account.deposit(500)
account.withdraw(200)
transactions = account.get_transactions()
assert len(transactions) == 2
assert transactions[0] == ("deposit", 500)
assert transactions[1] == ("withdraw", 200)
get_transactions() returns a copy of the list. This prevents tests (or callers) from modifying the internal transaction history. Returning a copy is a good defensive practice for mutable internal state.
Challenge 2: Implement Continuous Integration¶
.github/workflows/tests.yml:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install dependencies
run: pip install pytest pytest-cov flake8
- name: Lint with flake8
run: flake8 .
- name: Run tests with coverage
run: pytest --cov
CI runs your tests automatically on every push and pull request. This catches regressions before they reach the main branch. GitHub Actions is free for public repositories.
Common Mistakes¶
Testing implementation details instead of behavior. Tests should verify what a function does, not how it does it. If you test internal state directly, refactoring the implementation breaks the tests even if the behavior is unchanged.
Not testing error cases. Many bugs live in error handling code. Always test that your functions raise the right exceptions with the right messages.
Shared state between tests. If one test modifies global state or a shared object, it can affect other tests. Use fixtures to create fresh state for each test.
Ignoring flake8 warnings. Style warnings often point to real issues. E711: comparison to None (use is None instead of == None) is a common one that can cause subtle bugs.
What to Review Next¶
- Review the matching handbook chapter if any exercise felt difficult.
- Revisit the matching exercise set and try solving it again without looking at the solution.
- Continue with the next handbook chapter: Chapter 19 - Type Hints