Lab 4

De la WikiLabs
Jump to navigationJump to search

Lab 4 — Testing, Debugging and Software Quality

Objectives

This laboratory introduces software testing and quality practices for Python projects.

Students already know how to write C/C++ programs and have seen assertions, debugging and compiler diagnostics. The objective of this laboratory is to understand how these practices are applied in Python and how automated tests become part of the normal development workflow.

After completing this laboratory, you should be able to:

  • understand the role of automated tests;
  • distinguish between unit, integration and system tests;
  • write tests using pytest;
  • use assert statements effectively;
  • test expected exceptions;
  • organize tests in a dedicated tests/ directory;
  • use fixtures for reusable test setup;
  • use parametrized tests;
  • test file-based code using temporary directories;
  • understand basic mocking;
  • use logging instead of uncontrolled print() debugging;
  • use the Python debugger;
  • run static analysis and formatting tools;
  • use type checking as part of code-quality verification;
  • understand test coverage;
  • integrate linting and tests into a simple development workflow;
  • prepare the semester project for continuous integration.

Throughout this laboratory, Python practices are compared with familiar C/C++ testing and debugging concepts whenever appropriate.

1. Why automated testing?

A program that runs correctly once is not necessarily correct.

Automated tests allow us to repeatedly verify that known behavior still works after the code changes.

A typical development cycle becomes:

write code
    ↓
run tests
    ↓
change code
    ↓
run tests again
    ↓
refactor
    ↓
run tests again

This is particularly important in larger projects where changing one component may accidentally break another.

2. Manual testing versus automated testing

Consider a function:

def add(a, b):
    return a + b

Manual testing might look like:

print(add(2, 3))
print(add(-1, 1))
print(add(100, 200))

The programmer must inspect the output manually.

An automated test describes the expected result:

def test_add():
    assert add(2, 3) == 5

If the behavior changes incorrectly, the test fails automatically.

3. Assertions

C/C++ and Python both provide assertions.

C/C++ Python
#include <cassert>

int result = add(2, 3);

assert(result == 5);
result = add(2, 3)

assert result == 5

An assertion expresses:

This condition must be true at this point in the program.

4. Assertions are not input validation

Assertions should not normally be used to validate user input or external data.

For example, this is not a good validation strategy:

def set_grade(grade):
    assert 1 <= grade <= 10

Assertions may be disabled in optimized Python execution.

For input validation, use explicit exceptions:

def set_grade(grade):
    if not 1 <= grade <= 10:
        raise ValueError(
            "grade must be between 1 and 10"
        )

Assertions are useful for:

  • tests;
  • internal invariants;
  • assumptions that indicate programmer errors when violated.

5. Test levels

A useful simplified classification is:

Test level Purpose Example
unit test tests one small unit of logic test one function
integration test tests interaction between components storage + service layer
system test tests a complete application workflow run application with realistic input

For student projects, most automated tests should be unit tests, with several integration tests for important workflows.

6. pytest

Python's standard library contains unittest, but this laboratory uses pytest because it provides a concise and widely used testing style.

Install it inside the project's virtual environment:

python -m pip install pytest

Run all tests:

pytest

A more verbose run:

pytest -v

7. A first pytest test

Suppose:

# calculator.py

def add(a, b):
    return a + b

Create:

tests/
└── test_calculator.py

with:

from calculator import add


def test_add():
    assert add(2, 3) == 5

Run:

pytest

pytest discovers functions whose names begin with:

test_

8. C++ test framework versus pytest

The overall structure is similar to a C++ testing framework such as GoogleTest.

C++ / GoogleTest Python / pytest
TEST(CalculatorTest, Add)
{
    EXPECT_EQ(add(2, 3), 5);
}
def test_add():
    assert add(2, 3) == 5

pytest uses normal Python assert statements rather than a large family of assertion macros.

9. pytest assertion introspection

Suppose:

def test_add():
    assert add(2, 3) == 6

pytest can display the values involved in the failed expression.

This means tests can remain simple:

assert actual == expected

rather than requiring specialized assertion methods.

10. Naming tests

Test names should describe behavior.

Prefer:

def test_average_returns_mean_of_values():
    ...

over:

def test1():
    ...

A useful naming style is:

test_<behavior>_<condition>

For example:

def test_withdraw_rejects_negative_amount():
    ...

11. Arrange, Act, Assert

A useful test structure is:

Arrange
Act
Assert

Example:

def test_bank_account_deposit():
    # Arrange
    account = BankAccount("Alice")

    # Act
    account.deposit(100)

    # Assert
    assert account.balance == 100

This is not mandatory syntax. It is a way to keep tests readable.

12. Testing multiple conditions

A test can contain more than one assertion when they describe the same behavior.

def test_new_student():
    student = Student(
        name="Alice",
        grade=9.5,
    )

    assert student.name == "Alice"
    assert student.grade == 9.5

However, unrelated behaviors should generally be tested separately.

13. Testing floating-point values

Direct floating-point equality can be unreliable.

C++ testing frameworks commonly provide approximate comparison helpers.

pytest provides pytest.approx().

C++ / GoogleTest Python / pytest
EXPECT_NEAR(
    result,
    expected,
    1e-6
);
import pytest


assert result == pytest.approx(
    expected
)

Example:

def test_average():
    result = average([1.0, 2.0, 3.0])

    assert result == pytest.approx(2.0)

14. Testing exceptions

Suppose:

def divide(a, b):
    if b == 0:
        raise ValueError(
            "division by zero"
        )

    return a / b

pytest can verify that an exception is raised:

import pytest


def test_divide_rejects_zero():
    with pytest.raises(ValueError):
        divide(10, 0)

15. Testing exception messages

The exception object can also be inspected.

def test_divide_error_message():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)

    assert "division by zero" in str(
        exc_info.value
    )

Only test exact error messages when the wording itself is part of the required interface.

16. C++ exception testing versus pytest

C++ / GoogleTest Python / pytest
EXPECT_THROW(
    divide(10, 0),
    std::invalid_argument
);
with pytest.raises(ValueError):
    divide(10, 0)

17. Test organization

A useful project layout is:

project/
├── pyproject.toml
├── src/
│   └── project/
│       ├── __init__.py
│       ├── models.py
│       └── services.py
└── tests/
    ├── test_models.py
    └── test_services.py

Test files should generally mirror the logical organization of the source code.

18. One behavior per test

Avoid tests that try to verify the entire application at once.

Less useful:

def test_everything():
    # Create students.
    # Save file.
    # Load file.
    # Calculate average.
    # Modify student.
    # Delete file.
    # Check all values.
    ...

Prefer focused tests:

def test_student_creation():
    ...


def test_save_students():
    ...


def test_load_students():
    ...


def test_average():
    ...

Focused tests make failures easier to diagnose.

19. Fixtures

A fixture provides reusable setup for tests.

Suppose many tests need the same student list.

Without a fixture:

def test_average():
    students = [
        Student("Alice", 9.0),
        Student("Bob", 7.0),
    ]

    ...


def test_highest_grade():
    students = [
        Student("Alice", 9.0),
        Student("Bob", 7.0),
    ]

    ...

With a fixture:

import pytest


@pytest.fixture
def students():
    return [
        Student("Alice", 9.0),
        Student("Bob", 7.0),
    ]

20. Using fixtures

A test requests a fixture by using its name as a parameter.

def test_average(students):
    assert average(students) == 8.0


def test_highest_grade(students):
    student = highest_grade(students)

    assert student.name == "Alice"

pytest resolves the fixture automatically.

21. Fixture setup and cleanup

Fixtures can perform both setup and cleanup.

import pytest


@pytest.fixture
def resource():
    resource = create_resource()

    yield resource

    resource.close()

Everything before yield is setup.

Everything after yield is cleanup.

This is conceptually similar to structured resource setup and teardown in C++ tests.

22. Fixture scope

Fixtures can have different scopes.

@pytest.fixture(scope="function")
def item():
    ...

Common scopes include:

Scope Lifetime
function one test function
class one test class
module one test module
session entire pytest run

Use the smallest appropriate scope.

23. Parametrized tests

Suppose the same behavior should be tested for many inputs.

Without parametrization:

def test_square_2():
    assert square(2) == 4


def test_square_3():
    assert square(3) == 9


def test_square_4():
    assert square(4) == 16

With pytest parametrization:

import pytest


@pytest.mark.parametrize(
    "value, expected",
    [
        (2, 4),
        (3, 9),
        (4, 16),
    ],
)
def test_square(value, expected):
    assert square(value) == expected

24. Parametrization versus repeated C++ tests

Parameterized tests exist in C++ frameworks as well.

The conceptual mapping is:

C++ test framework pytest
define test cases
instantiate parameter set
run same test logic
@pytest.mark.parametrize(
    "input_value, expected",
    [
        (..., ...),
        (..., ...),
    ],
)
def test_behavior(
    input_value,
    expected,
):
    ...

25. Parametrizing invalid inputs

Parametrization is also useful for validating input rules.

@pytest.mark.parametrize(
    "grade",
    [
        -1,
        0,
        10.1,
        100,
    ],
)
def test_invalid_grades(grade):
    with pytest.raises(ValueError):
        Student(
            name="Alice",
            grade=grade,
        )

26. Testing dataclasses

Dataclasses often make tests concise because they provide value-based equality.

from dataclasses import dataclass


@dataclass
class Student:
    name: str
    grade: float

Test:

def test_student_equality():
    a = Student("Alice", 9.0)
    b = Student("Alice", 9.0)

    assert a == b

27. Testing collections

pytest assertions work naturally with lists, dictionaries, sets and tuples.

def test_filter_passed_students():
    result = filter_passed_students(
        [
            Student("Alice", 9.0),
            Student("Bob", 4.0),
        ]
    )

    assert result == [
        Student("Alice", 9.0),
    ]

For sets:

assert result == {"Alice", "Carol"}

28. Testing file-based code

Tests should not normally depend on manually created files in arbitrary locations.

pytest provides temporary directories through the built-in tmp_path fixture.

def test_save_file(tmp_path):
    path = tmp_path / "output.txt"

    save_message(
        path,
        "hello",
    )

    assert path.read_text(
        encoding="utf-8"
    ) == "hello"

The temporary directory is managed automatically by pytest.

29. Testing JSON storage

Suppose:

def save_students(path, students):
    ...

A test can create a temporary file:

def test_save_and_load_students(tmp_path):
    path = tmp_path / "students.json"

    original = [
        Student("Alice", 9.0),
        Student("Bob", 8.0),
    ]

    save_students(path, original)

    loaded = load_students(path)

    assert loaded == original

This is an integration test because it verifies multiple storage operations together.

30. Testing missing files

Suppose load_students() should return an empty list when a file does not exist.

def test_missing_file_returns_empty_list(
    tmp_path,
):
    path = tmp_path / "missing.json"

    result = load_students(path)

    assert result == []

Tests should explicitly document such expected behavior.

31. Testing malformed input

Suppose malformed JSON should raise an exception.

import json
import pytest


def test_invalid_json(tmp_path):
    path = tmp_path / "students.json"

    path.write_text(
        "{invalid json",
        encoding="utf-8",
    )

    with pytest.raises(
        json.JSONDecodeError
    ):
        load_students(path)

32. Unit tests should minimize external dependencies

A unit test should ideally test one unit without requiring:

  • internet access;
  • a real database;
  • external hardware;
  • arbitrary filesystem state;
  • real user input;
  • another service.

Dependencies that are slow or uncontrollable should often be replaced with controlled test substitutes.

This leads to the concept of mocking.

33. What is mocking?

A mock replaces a dependency with a controlled object during a test.

Suppose:

def get_temperature(api_client):
    response = api_client.fetch()
    return response["temperature"]

The test does not need to access a real remote API.

A controlled fake object can be used instead.

34. A simple fake object

Before using a mocking framework, a simple fake class is often enough.

class FakeApiClient:
    def fetch(self):
        return {
            "temperature": 23.5,
        }


def test_get_temperature():
    client = FakeApiClient()

    result = get_temperature(client)

    assert result == 23.5

This is often clearer than complex mocking.

35. unittest.mock

Python's standard library provides unittest.mock.

from unittest.mock import Mock


def test_get_temperature():
    client = Mock()

    client.fetch.return_value = {
        "temperature": 23.5,
    }

    result = get_temperature(client)

    assert result == 23.5

36. Verifying calls on a mock

Mocks can verify interactions.

from unittest.mock import Mock


def test_temperature_fetches_once():
    client = Mock()

    client.fetch.return_value = {
        "temperature": 23.5,
    }

    get_temperature(client)

    client.fetch.assert_called_once()

Use interaction assertions only when the interaction itself is important behavior.

37. Do not overuse mocks

A test that mocks every object can become tightly coupled to implementation details.

Prefer:

  • real simple objects;
  • small fake implementations;
  • temporary files;
  • dependency injection;

before introducing complex mocks.

Mock external or expensive dependencies when doing so makes the test more reliable and focused.

38. Dependency injection

Code is easier to test when dependencies are supplied from outside.

Less testable:

def load_remote_data():
    client = ApiClient()
    return client.fetch()

More testable:

def load_remote_data(client):
    return client.fetch()

The second version allows tests to provide a fake or mock client.

This principle is not specific to Python.

39. Debugging with print()

Temporary print debugging is common:

print("value =", value)
print("students =", students)

It can be useful for quick inspection, but uncontrolled print() statements should not remain throughout production code.

For structured diagnostics, use logging.

40. The logging module

Python provides the standard logging module.

import logging


logging.basicConfig(
    level=logging.INFO
)

logging.info("Application started")
logging.warning("Configuration missing")
logging.error("Cannot load file")

41. Logging levels

Common logging levels are:

Level Typical use
DEBUG detailed diagnostic information
INFO normal application events
WARNING unexpected condition that does not stop execution
ERROR operation failed
CRITICAL severe failure

42. C++ logging idea versus Python logging

C++ idea Python
// Using a logging library:
//
// logger.info("Started");
// logger.error("Failed");
logger.info("Started")
logger.error("Failed")

The main principle is the same: diagnostic output should have severity levels and configurable destinations.

43. Module-level loggers

A common pattern is:

import logging


logger = logging.getLogger(__name__)


def load_data():
    logger.debug("Loading data")

Using __name__ identifies the module that produced the log message.

44. Logging exceptions

Inside an exception handler:

try:
    load_configuration()
except OSError:
    logger.exception(
        "Failed to load configuration"
    )

logger.exception() includes traceback information when used inside an exception handler.

45. The Python debugger

Python includes a debugger module called pdb.

A breakpoint can be inserted with:

breakpoint()

Example:

def calculate(values):
    total = sum(values)

    breakpoint()

    return total / len(values)

When execution reaches the breakpoint, the program enters an interactive debugger.

46. Useful debugger commands

Common commands include:

Command Meaning
n execute next line
s step into function
c continue execution
p expression print expression
pp expression pretty-print expression
l list source code
q quit debugger

Modern IDEs such as VS Code and PyCharm also provide graphical debugger interfaces.

47. Debugging a failing pytest test

pytest can stop in the debugger when a test fails:

pytest --pdb

Or a specific test can be executed:

pytest -v tests/test_services.py

A specific test function:

pytest -v \
    tests/test_services.py::test_average

48. Static analysis

Tests verify behavior for selected inputs.

Static analysis examines source code without executing it.

Useful tools can detect:

  • unused imports;
  • undefined names;
  • suspicious constructs;
  • style problems;
  • some type errors.

These tools complement tests rather than replace them.

49. Ruff

ruff is a fast Python linter and formatter.

Install:

python -m pip install ruff

Check the project:

ruff check .

Automatically fix supported issues:

ruff check . --fix

Format code:

ruff format .

50. Example lint issue

Consider:

import json
import os


def add(a, b):
    return a + b

If os is unused, a linter can report it.

This is similar to enabling compiler warnings in C/C++.

C/C++ Python
g++ -Wall -Wextra ...
ruff check .

51. Compiler warnings versus linting

C/C++ compilers naturally perform significant static checking because the language is statically typed.

Python requires separate tools for many similar checks.

A useful mental mapping is:

C/C++ workflow Python workflow
compiler diagnostics interpreter + linter
type checking by compiler optional static type checker
formatter such as clang-format formatter such as Ruff
GoogleTest / Catch2 pytest

52. Type checking with mypy

Install:

python -m pip install mypy

Run:

mypy src

Example:

def add(a: int, b: int) -> int:
    return a + b


result = add("hello", 3)

A static type checker can detect the incompatible argument.

53. Tests and type checking solve different problems

Consider:

def divide(a: float, b: float) -> float:
    return a / b

A type checker can detect:

divide("hello", 2)

but it does not prove that:

divide(10, 0)

is safe.

Tests and runtime validation are still necessary.

54. Code formatting

Consistent formatting reduces irrelevant differences between programmers' code.

For example:

ruff format .

A formatter decides details such as:

  • whitespace;
  • line wrapping;
  • indentation;
  • consistent layout.

Students should not spend project time manually debating formatting details that can be automated.

55. Test coverage

Coverage measures which lines or branches were executed while tests ran.

Install:

python -m pip install pytest-cov

Run:

pytest --cov=src

A terminal report:

pytest \
    --cov=src \
    --cov-report=term-missing

56. Coverage is not correctness

A project can have:

100% line coverage

and still contain bugs.

Coverage only tells us whether code was executed by tests.

It does not tell us whether:

  • the correct assertions were made;
  • important edge cases were tested;
  • the specification is correct.

Use coverage to find untested areas, not as the sole quality metric.

57. Edge cases

Tests should include normal behavior and important boundary conditions.

For a grade validator:

def valid_grade(grade):
    return 1 <= grade <= 10

Relevant cases include:

1
10
0.999
10.001
negative values
unexpected types

Boundary values frequently reveal defects.

58. Regression tests

When a bug is discovered:

  1. reproduce the bug;
  2. write a test that fails because of the bug;
  3. fix the implementation;
  4. verify that the test now passes.

The new test becomes a regression test.

It prevents the same defect from being reintroduced later.

59. Testing pure functions

Pure functions are particularly easy to test.

Example:

def average(values):
    return sum(values) / len(values)

The output depends only on the input.

Test:

def test_average():
    assert average([2, 4, 6]) == 4

No filesystem, network or global state is involved.

60. Side effects make testing harder

Compare:

def calculate_and_print(values):
    result = sum(values) / len(values)
    print(result)

with:

def calculate_average(values):
    return sum(values) / len(values)

The second function is easier to test.

Printing can be performed separately:

result = calculate_average(values)
print(result)

Separating computation from input/output improves testability.

61. Testing standard output

Sometimes output itself is behavior that should be tested.

pytest provides the capsys fixture.

def greet(name):
    print(f"Hello, {name}")


def test_greet(capsys):
    greet("Alice")

    captured = capsys.readouterr()

    assert captured.out == "Hello, Alice\n"

Use this when console output is part of the intended interface.

62. Testing user input

Code that directly calls input() is less convenient to test.

Less testable:

def ask_grade():
    value = input("Grade: ")
    return float(value)

A better design often separates input from parsing:

def parse_grade(text):
    return float(text)


text = input("Grade: ")
grade = parse_grade(text)

Now parse_grade() can be tested independently.

63. Designing for testability

Testable code often has these properties:

  • small functions;
  • explicit inputs;
  • explicit return values;
  • limited global state;
  • dependencies passed as parameters;
  • input/output separated from logic;
  • clear error behavior.

These are also generally good software-design properties.

64. Global state and testing

Global mutable state makes tests influence one another.

Problematic:

students = []


def add_student(student):
    students.append(student)

One test may leave data behind for another test.

Prefer explicit objects:

class Course:
    def __init__(self):
        self.students = []

    def add_student(self, student):
        self.students.append(student)

Each test can create a fresh Course.

65. Tests must be independent

A test should not rely on another test running first.

Do not design:

test_1 creates file
test_2 assumes file exists
test_3 deletes file

Each test should set up its own required state.

pytest does not guarantee that tests should be treated as an ordered sequence.

66. Deterministic tests

A good test should produce the same result every time under the same conditions.

Avoid uncontrolled dependencies on:

  • current time;
  • random numbers;
  • network state;
  • external services;
  • execution order.

If randomness is required, control it.

For example:

import random


random.seed(1234)

Better still, inject the random-number generator or test deterministic subcomponents when possible.

67. Testing time-dependent code

Code that directly reads the current time is harder to test.

Less testable:

from datetime import datetime


def greeting():
    hour = datetime.now().hour

    if hour < 12:
        return "Good morning"

    return "Good afternoon"

More testable:

def greeting(hour):
    if hour < 12:
        return "Good morning"

    return "Good afternoon"

The system clock interaction can be kept in a thin outer layer.

68. Test-driven development

One possible workflow is Test-Driven Development (TDD):

1. Write a failing test
2. Implement the minimum code to pass
3. Refactor
4. Repeat

TDD is not mandatory for this course.

However, writing tests before fixing a known bug is strongly recommended.

69. Refactoring

Refactoring means improving the internal structure of code without intentionally changing its external behavior.

Examples include:

  • splitting large functions;
  • renaming unclear variables;
  • removing duplication;
  • extracting classes;
  • simplifying conditionals;
  • separating I/O from logic.

Tests make refactoring safer because they detect accidental behavior changes.

70. Example refactoring

Before:

def process(students):
    total = 0

    for student in students:
        total += student.grade

    average = total / len(students)

    for student in students:
        if student.grade >= average:
            print(student.name)

After:

def average(students):
    return sum(
        student.grade
        for student in students
    ) / len(students)


def above_average(students):
    threshold = average(students)

    return [
        student
        for student in students
        if student.grade >= threshold
    ]

The second design separates calculation from output and is easier to test.

71. Testing the refactored code

def test_average():
    students = [
        Student("Alice", 10),
        Student("Bob", 8),
    ]

    assert average(students) == 9


def test_above_average():
    students = [
        Student("Alice", 10),
        Student("Bob", 8),
    ]

    assert above_average(students) == [
        Student("Alice", 10),
    ]

72. Code quality workflow

A useful local workflow is:

write code
   ↓
ruff format .
   ↓
ruff check .
   ↓
mypy src
   ↓
pytest
   ↓
commit

Not every project must use every tool, but automated checks should become normal development habits.

73. A simple quality command sequence

For the semester project:

ruff format .
ruff check .
mypy src
pytest -v

Before submitting a milestone, all commands should complete successfully.

74. Configuration in pyproject.toml

Many Python development tools can be configured in pyproject.toml.

Example:

[project]
name = "student-project"
version = "0.1.0"
requires-python = ">=3.11"

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
line-length = 88

Centralized configuration avoids unnecessary tool-specific configuration files.

75. Development dependencies

Testing and development tools are dependencies for developers, not necessarily for application users.

Typical development tools include:

pytest
pytest-cov
ruff
mypy

For a simple student project they can be installed with:

python -m pip install \
    pytest \
    pytest-cov \
    ruff \
    mypy

76. Continuous integration

Continuous Integration (CI) automatically runs project checks when code is pushed to a repository.

A simple pipeline might perform:

checkout repository
        ↓
install Python
        ↓
install dependencies
        ↓
run linter
        ↓
run type checker
        ↓
run tests
        ↓
report success/failure

This prevents code that only works on one developer's machine from being accepted unnoticed.

77. Example CI idea

A CI system might execute:

python -m pip install -e .
python -m pip install pytest ruff mypy

ruff check .
mypy src
pytest

The exact CI configuration depends on the Git hosting platform and will not be the focus of this laboratory.

78. Definition of done

For this course, a feature should not be considered complete merely because:

"It works on my machine."

A better definition is:

  • implementation completed;
  • expected errors handled;
  • automated tests added;
  • existing tests still pass;
  • linting passes;
  • public functions have reasonable type hints;
  • code is committed to Git;
  • relevant documentation is updated.

79. Example — testing the grade manager

Suppose Lab 3 produced:

grade_manager/
├── src/
│   └── grade_manager/
│       ├── models.py
│       ├── storage.py
│       └── services.py
└── tests/

models.py:

from dataclasses import dataclass


@dataclass
class Student:
    name: str
    grade: float

80. Testing the average function

Suppose:

def average(students):
    if not students:
        raise EmptyStudentListError()

    return sum(
        student.grade
        for student in students
    ) / len(students)

Tests:

import pytest

from grade_manager.models import Student
from grade_manager.services import (
    EmptyStudentListError,
    average,
)


def test_average():
    students = [
        Student("Alice", 10),
        Student("Bob", 8),
    ]

    assert average(students) == 9


def test_average_rejects_empty_list():
    with pytest.raises(
        EmptyStudentListError
    ):
        average([])

81. Testing storage

Suppose:

def save_students(path, students):
    ...


def load_students(path):
    ...

Test:

def test_storage_round_trip(tmp_path):
    path = tmp_path / "students.json"

    students = [
        Student("Alice", 9.5),
        Student("Bob", 8.0),
    ]

    save_students(path, students)

    result = load_students(path)

    assert result == students

This test verifies a complete storage round trip.

82. Testing invalid grades

If the model validates grades:

@dataclass
class Student:
    name: str
    grade: float

    def __post_init__(self):
        if not 1 <= self.grade <= 10:
            raise InvalidGradeError()

Test:

@pytest.mark.parametrize(
    "grade",
    [
        -1,
        0,
        10.1,
        100,
    ],
)
def test_invalid_grade(grade):
    with pytest.raises(
        InvalidGradeError
    ):
        Student(
            "Alice",
            grade,
        )

83. What should be tested?

Prioritize tests for:

  • calculations;
  • validation rules;
  • edge cases;
  • error handling;
  • parsing;
  • storage;
  • important transformations;
  • previously discovered bugs.

Do not prioritize tests for trivial implementation details.

For example, testing that Python assignment works is unnecessary.

84. What should not be tested directly?

Avoid tests that merely confirm:

  • Python itself works;
  • third-party libraries behave exactly as documented;
  • private implementation details that callers should not depend on.

Test your application's behavior.

85. C/C++ habits to reconsider

C/C++ habit Typical Python approach
rely mainly on compiler errors combine interpreter, linting, type checking and tests
manual test executable pytest test suite
specialized assertion macros normal Python assert
manually create temporary test files use tmp_path
debug mainly with output statements use debugger and structured logging
test large workflows only prefer many focused unit tests plus selected integration tests
tightly construct dependencies inside functions inject dependencies when useful for testing
accept code because it runs once require repeatable automated checks

86. Exercise 1 — Basic pytest tests

Given:

def clamp(value, minimum, maximum):
    if value < minimum:
        return minimum

    if value > maximum:
        return maximum

    return value

Write tests for:

  • a value inside the range;
  • a value below the range;
  • a value above the range;
  • a value equal to the minimum;
  • a value equal to the maximum.

87. Exercise 2 — Exception testing

Given:

def validate_grade(grade):
    if not 1 <= grade <= 10:
        raise ValueError(
            "invalid grade"
        )

Write pytest tests that verify:

  • valid grades do not raise an exception;
  • invalid grades raise ValueError;
  • the exception message contains invalid grade.

Use parametrization for invalid values.

88. Exercise 3 — Fixtures

Create a fixture:

@pytest.fixture
def students():
    ...

containing at least four students.

Use the fixture to test:

  • average grade;
  • highest grade;
  • passed students;
  • students above the average.

Do not duplicate the same setup in every test.

89. Exercise 4 — Parametrization

Implement:

def is_valid_username(username):
    ...

Assume a valid username:

  • is at least 3 characters long;
  • is at most 20 characters long;
  • contains only letters, digits and underscore;
  • begins with a letter.

Use @pytest.mark.parametrize to test multiple valid and invalid usernames.

90. Exercise 5 — Temporary files

Write:

def save_lines(
    path,
    lines,
):
    ...

and test it using tmp_path.

Verify:

  • the file is created;
  • every input line is written;
  • line endings are correct;
  • an empty list creates an empty file.

91. Exercise 6 — JSON round trip

Using the grade-manager code from Lab 3:

  1. create several Student objects;
  2. save them to a temporary JSON file;
  3. load them again;
  4. verify that the loaded objects equal the originals.

Do not use a permanent file in the project directory.

92. Exercise 7 — Simple fake dependency

Given:

def fetch_status(client):
    response = client.get_status()

    return response["status"]

Write a fake client class that returns:

{"status": "ok"}

Then test fetch_status() without using a real external service.

93. Exercise 8 — Logging

Modify a file-loading function so that it logs:

  • INFO when loading begins;
  • WARNING when the file does not exist;
  • ERROR when parsing fails.

Do not replace exception handling with logging. Logging describes what happened; exceptions still represent failures.

94. Exercise 9 — Refactoring for testability

Refactor:

def run():
    first = float(input("First: "))
    second = float(input("Second: "))

    result = first / second

    print(f"Result: {result}")

Separate:

  • parsing/input;
  • calculation;
  • output.

Write unit tests for the calculation without using input().

95. Exercise 10 — Quality tools

For the semester project:

  1. install pytest, ruff and mypy;
  2. run ruff format .;
  3. run ruff check .;
  4. run mypy src;
  5. run pytest -v;
  6. correct all reasonable issues;
  7. commit the resulting changes.

Record the commands in the project's README.md.

96. Exercise 11 — Coverage

Install:

python -m pip install pytest-cov

Run:

pytest \
    --cov=src \
    --cov-report=term-missing

Identify one important untested function.

Add tests for it and compare the coverage report before and after.

Do not attempt to maximize coverage by writing meaningless tests.

97. Exercise 12 — Project milestone

By the end of this laboratory, the semester project should contain:

project/
├── pyproject.toml
├── README.md
├── src/
│   └── project_name/
│       └── ...
└── tests/
    └── ...

Minimum requirements:

  • application starts successfully;
  • central functionality is implemented;
  • at least five meaningful automated tests exist;
  • at least one error condition is tested;
  • at least one fixture or parametrized test is used;
  • tests do not depend on execution order;
  • linting runs successfully;
  • type checking is attempted;
  • temporary files are used for file-based tests;
  • no virtual environment or __pycache__ directory is committed.

This represents the first project MVP quality checkpoint.

98. Summary

The main mappings introduced in this laboratory are:

C/C++ / general concept Python
assertion assert
GoogleTest / Catch2 pytest
assertion macro normal Python assertion expression
exception assertion pytest.raises()
parameterized test @pytest.mark.parametrize
setup / teardown pytest fixture
temporary test directory tmp_path
fake/mock dependency simple fake or unittest.mock
debugger breakpoint() / pdb
logging library standard logging module
compiler warnings linter such as ruff
static compiler type checks optional checker such as mypy
code formatter ruff format
test coverage tool pytest-cov
automated build checks CI pipeline

The central idea of this laboratory is:

Software quality is not a final step performed after implementation. Testing, static analysis, debugging and automated checks should be part of the normal development process.

99. Preparation for Lab 5

Before the next laboratory:

  1. complete the exercises from this laboratory;
  2. ensure the semester project has a working MVP;
  3. organize the test suite under tests/;
  4. add tests for the most important functionality;
  5. run linting and formatting;
  6. remove temporary debugging output;
  7. replace useful diagnostics with logging;
  8. document how to run the application and tests.

In Lab 5, the laboratory changes from primarily theory-oriented work to supervised project development. The focus will be architecture review, implementation quality, debugging and completing the main project functionality.