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 pytest framework
  • 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 TestsWith Tests
Fear of breaking something when changing codeRefactor boldly — tests catch regressions
Bugs discovered by usersBugs caught early during development
Manual testing is slow and error‑proneAutomated tests run in seconds
No documentation of expected behaviourTests serve as living documentation

Testing is not optional. It is the hallmark of a professional developer.


2.2 Types of Testing

LevelWhat It TestsWho Writes It
Unit testA single function or method in isolationDeveloper
Integration testHow multiple units work togetherDeveloper
System / End‑to‑end testThe entire applicationQA / Developer
Acceptance testBusiness requirementsStakeholders

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:

  1. Arrange – set up the data and conditions.
  2. Act – call the function / method under test.
  3. 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:

  1. Red – Write a failing test.
  2. Green – Write the minimum code to make it pass.
  3. 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_tasks returns 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:

  1. Create an in‑memory SQLite database fixture with the same schema.

  2. Write tests for each function:

    • add_book(conn, isbn, title, author) – should raise IntegrityError on 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); raises ValueError for 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.
  3. Use fixtures to keep tests DRY.

  4. Use pytest.raises for expected exceptions.

  5. Run all tests and ensure they pass.

Stretch goals:

  • Add test coverage measurement (install pytest-cov and run pytest --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

ConceptKey Points
Testing importancePrevents regressions, documents behaviour, enables refactoring
pytestLightweight, powerful; use assert directly
AAA PatternArrange, Act, Assert — makes tests clear
FixturesReusable setup with @pytest.fixture
Exception testingpytest.raises(ExceptionType)
In‑memory DBsqlite3.connect(":memory:") for fast, isolated tests
TDDRed → 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:

  1. ✅ Complete all 5 exercises
  2. ✅ Build the Test Suite for the Library Manager
  3. ✅ Run pytest --tb=short on a failing test to see the traceback formatting
  4. ✅ Try TDD on a small function: write the test first, then the code

Comments

Popular posts from this blog

Day 1: Welcome to Python — Your Journey Begins

Python: Your Gateway to Coding Adventures

Day 11: List Comprehensions & Lambda Functions

Earn From the Comfort of Your Home

Build the skills to work comfortably from home, on your own terms.

Get Started Today