Day 22: Software Testing with pytest
🐍 Day 22: Software Testing with pytest
1. Learning Objectives
By the end of Day 22, you will be able to:
- Understand why testing matters and the different levels of testing
- Write unit tests using the
pytestframework - Use assertions to verify expected behaviour
- Organise tests with fixtures for reusable setup and teardown
- Test functions, classes, and database operations
- Apply the Arrange‑Act‑Assert pattern
- Appreciate the basics of Test‑Driven Development (TDD)
2. Concept Explanation
2.1 Why Testing? – Confidence and Correctness
Code without tests is like a bridge without load testing — it might hold, or it might collapse under unexpected weight.
| Without Tests | With Tests |
|---|---|
| Fear of breaking something when changing code | Refactor boldly — tests catch regressions |
| Bugs discovered by users | Bugs caught early during development |
| Manual testing is slow and error‑prone | Automated tests run in seconds |
| No documentation of expected behaviour | Tests serve as living documentation |
Testing is not optional. It is the hallmark of a professional developer.
2.2 Types of Testing
| Level | What It Tests | Who Writes It |
|---|---|---|
| Unit test | A single function or method in isolation | Developer |
| Integration test | How multiple units work together | Developer |
| System / End‑to‑end test | The entire application | QA / Developer |
| Acceptance test | Business requirements | Stakeholders |
Today we focus on unit tests — the foundation of a strong test suite.
2.3 Introducing pytest
pytest is the most popular Python testing framework. It’s simple, powerful, and has a rich ecosystem of plugins.
Installation:
pip install pytest
Your first test file: test_math.py
# test_math.py
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
Run it:
pytest test_math.py
Output:
========================== test session starts ===========================
collected 1 item
test_math.py . [100%]
=========================== 1 passed in 0.01s ===========================
A dot means success. An F means failure — pytest shows exactly what went wrong.
2.4 The assert Statement
assert is Python’s built‑in keyword. If the expression after it is True, nothing happens. If False, an AssertionError is raised with a helpful message.
def test_various():
assert 1 == 1
assert "hello" in "hello world"
assert isinstance(42, int)
assert [1, 2] == [1, 2]
# With custom message
assert len([1,2,3]) == 3, "List length should be 3"
In pytest, you don’t need to write self.assertEqual(...). Plain assert statements work perfectly because pytest rewrites them to give detailed failure reports.
2.5 Arrange‑Act‑Assert (AAA) Pattern
Every test should follow three clear steps:
- Arrange – set up the data and conditions.
- Act – call the function / method under test.
- Assert – verify the outcome is what you expect.
def test_multiply():
# Arrange
x = 4
y = 5
expected = 20
# Act
result = x * y
# Assert
assert result == expected
This pattern keeps tests readable and easy to debug.
2.6 Testing Functions That Raise Exceptions
Sometimes you expect a function to fail.
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
pytest.raises is a context manager that catches the exception. If it’s not raised, the test fails.
2.7 Fixtures – Reusable Setup and Teardown
Often multiple tests need the same initial state. Fixtures provide that state without code duplication.
import pytest
@pytest.fixture
def sample_list():
return [1, 2, 3, 4, 5]
def test_list_length(sample_list):
assert len(sample_list) == 5
def test_list_contains(sample_list):
assert 3 in sample_list
assert 6 not in sample_list
Fixtures can also perform cleanup after the test (using yield):
@pytest.fixture
def temp_file(tmp_path):
file = tmp_path / "test.txt"
file.write_text("hello")
yield file
# Teardown: delete the file (pytest's tmp_path handles this)
tmp_path is a built‑in fixture that provides a temporary directory.
2.8 Testing Database Operations
You can test functions that interact with SQLite (from Day 21) by using an in‑memory database.
import sqlite3
import pytest
@pytest.fixture
def db_connection():
"""Create an in‑memory database with a tasks table."""
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0
)
""")
yield conn
conn.close()
def insert_task(conn, title):
conn.execute("INSERT INTO tasks (title) VALUES (?)", (title,))
def get_all_tasks(conn):
return conn.execute("SELECT * FROM tasks").fetchall()
def test_insert_and_fetch(db_connection):
insert_task(db_connection, "Learn pytest")
rows = get_all_tasks(db_connection)
assert len(rows) == 1
assert rows[0][1] == "Learn pytest"
assert rows[0][2] == 0 # not completed
Using :memory: creates a temporary SQLite database that vanishes when the connection closes — perfect for fast, isolated testing.
2.9 Test-Driven Development (TDD) – Overview
TDD follows a rhythmic cycle:
- Red – Write a failing test.
- Green – Write the minimum code to make it pass.
- Refactor – Improve the code while tests stay green.
This ensures you only write code that has a purpose, and you always have a safety net.
3. Code Examples
Example 1: Testing a Calculator Module
# 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
def test_subtract():
assert subtract(10, 5) == 5
def test_multiply():
assert multiply(3, 7) == 21
def test_divide():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(10, 0)
Example 2: Class Testing
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
# test_bank.py
import pytest
from bank import BankAccount
@pytest.fixture
def account():
return BankAccount(100)
def test_initial_balance(account):
assert account.balance == 100
def test_deposit(account):
account.deposit(50)
assert account.balance == 150
def test_withdraw(account):
account.withdraw(30)
assert account.balance == 70
def test_withdraw_insufficient(account):
with pytest.raises(ValueError, match="Insufficient funds"):
account.withdraw(200)
def test_deposit_negative(account):
with pytest.raises(ValueError, match="Deposit must be positive"):
account.deposit(-10)
4. Hands-On Exercises
Exercise 1: Write Your First Test
Create a function is_palindrome(s) that returns True if a string reads the same forwards and backwards (ignore case and spaces). Write at least 3 tests using pytest.
Exercise 2: Test Exception Handling
Write a function divide_numbers(a, b) that divides a by b and raises ZeroDivisionError if b is 0. Write a test that verifies the exception is raised correctly.
Exercise 3: Fixture Practice
Create a fixture that returns a list of dictionaries (e.g., students with names and grades). Write tests that verify the length of the list and that a specific student exists.
Exercise 4: Test a File‑Reading Function
Write a function count_lines(filename) that returns the number of lines in a text file. Use tmp_path to create a temporary file and test that the function returns the correct count.
Exercise 5: Database Integration Test
Revisit the add_task / get_all_tasks example from the lesson. Add a function to mark a task as completed. Write a test that:
- Inserts two tasks
- Marks one as completed
- Asserts that
get_all_tasksreturns the correct number of completed and pending tasks.
5. Applied Challenge Task 🏗️
Test Suite for Personal Library Manager
Take the Personal Library Manager application you built on Day 21. Write a comprehensive test suite using pytest.
Requirements:
Create an in‑memory SQLite database fixture with the same schema.
Write tests for each function:
add_book(conn, isbn, title, author)– should raiseIntegrityErroron duplicate ISBN.get_all_books(conn)– returns correct number of books after insertions.update_status(conn, isbn, new_status)– changes status correctly; rejects invalid status.rate_book(conn, isbn, rating)– works with valid rating (1‑5); raisesValueErrorfor out‑of‑range ratings.search_by_author(conn, author)– returns matching books and empty list when no match.delete_book(conn, isbn)– removes the book; subsequent fetch returns nothing.
Use fixtures to keep tests DRY.
Use
pytest.raisesfor expected exceptions.Run all tests and ensure they pass.
Stretch goals:
- Add test coverage measurement (install
pytest-covand runpytest --cov=library --cov-report=term-missing). - Write a test that proves the SQL injection prevention: try inserting a malicious string and ensure it doesn’t delete the table.
- Implement a basic CI workflow description (GitHub Actions) that would run these tests automatically.
6. Brief Review Summary
| Concept | Key Points |
|---|---|
| Testing importance | Prevents regressions, documents behaviour, enables refactoring |
| pytest | Lightweight, powerful; use assert directly |
| AAA Pattern | Arrange, Act, Assert — makes tests clear |
| Fixtures | Reusable setup with @pytest.fixture |
| Exception testing | pytest.raises(ExceptionType) |
| In‑memory DB | sqlite3.connect(":memory:") for fast, isolated tests |
| TDD | Red → Green → Refactor — write test first |
7. Preview of Next Topic — Day 23
We’ll complete Phase 3 with a Weekly Mini‑Project:
- CLI Application with Database Integration
- Combine: OOP, file handling, exception handling, SQLite, testing
- Build a real‑world console tool (e.g., expense tracker, notes app, student management)
- Write unit tests for the core logic
🎯 Your Action Items for Day 22:
- ✅ Complete all 5 exercises
- ✅ Build the Test Suite for the Library Manager
- ✅ Run
pytest --tb=shorton a failing test to see the traceback formatting - ✅ Try TDD on a small function: write the test first, then the code
Comments
Post a Comment
Leave us your comments here...