Advanced Python Programming (File I/O, Modules, Exceptions)

Chapter 11
Testing File, Module, and Exception Behavior

Diploma in Programming Languages (Java & Python)
Advanced Python Programming (File I/O, Modules, Exceptions)   •   Diploma

Testing File, Module, and Exception Behavior

Introduction

Reliable Python software is not established merely by writing code that appears to work. It is established by systematically demonstrating that the code behaves correctly under normal, boundary, and failure conditions. Testing is particularly important when programs read and write files, depend on modules, or handle exceptions. These features interact with the operating system and external resources, so defects may remain hidden until deployment.

This chapter presents practical testing techniques using the Python standard library's unittest framework and concepts commonly associated with pytest. It explains unit and integration testing, temporary resources, fixtures, mocking, exception assertions, test organization, coverage measurement, and automated quality checks. The central principle is that tests should be deterministic, focused, readable, and capable of detecting meaningful failures.

Learning Objectives

By the end of this chapter, you should be able to:

  • distinguish unit tests from integration tests;
  • test file-processing functions without damaging real user data;
  • create and clean up temporary files and directories;
  • test modules through their public interfaces;
  • assert that expected exceptions are raised;
  • use fixtures and mocks appropriately;
  • measure test coverage and interpret its limitations;
  • design an automated quality-checking workflow;
  • evaluate the reliability and maintainability of a test suite.

1. The Role of Testing in Python Development

A test is executable evidence about expected behaviour. A useful test identifies an input, invokes a program component, and checks an observable result. The result may be a returned value, a changed file, a raised exception, or an interaction with a dependency.

Testing is not the same as debugging. Debugging investigates and corrects a known or suspected defect; testing repeatedly checks whether specified behaviour is preserved. A test suite also acts as a regression safety net: after code is changed, previously passing tests should continue to pass.

1.1 Unit and integration tests

A unit test evaluates a small component in isolation, usually a function or class method. External dependencies are replaced with controlled test doubles when isolation is important. For example, a function that parses text can be tested with an in-memory string rather than a physical file.

An integration test evaluates cooperation between components. A file-import integration test might create a temporary file, call the import service, and verify that records are stored correctly. Integration tests provide stronger evidence about real interactions but are normally slower and more sensitive to environmental conditions.

Test typePrimary targetTypical speedExample
UnitOne function or classFastParse one line of CSV text
IntegrationSeveral componentsModerateRead a file and write processed output
SystemComplete applicationSlowestRun a command-line import workflow

A balanced test strategy uses many fast unit tests and a smaller number of carefully selected integration tests.

1.2 The arrange–act–assert pattern

Most tests can be structured as follows:

  1. Arrange: prepare inputs, fixtures, and dependencies.
  2. Act: invoke the behaviour under test.
  3. Assert: compare actual behaviour with the expected result.

This structure makes a test's purpose visible. A test containing many unrelated actions is often testing too much and becomes difficult to diagnose when it fails.

2. Testing File Input and Output

File I/O introduces state outside the Python process. Tests that use a developer's home directory, a fixed filename, or a shared project folder are unsafe because they can overwrite data, depend on previous runs, or behave differently on another operating system.

A better test uses a temporary directory. Python's tempfile module provides temporary resources designed for this purpose. TemporaryDirectory creates a directory and removes it when the context ends.

from pathlib import Path
from tempfile import TemporaryDirectory


def count_nonempty_lines(path: Path) -> int:
    with path.open("r", encoding="utf-8") as file:
        return sum(1 for line in file if line.strip())


def test_count_nonempty_lines():
    with TemporaryDirectory() as folder:
        path = Path(folder) / "records.txt"
        path.write_text("Ada\n\nGrace\n", encoding="utf-8")

        result = count_nonempty_lines(path)

        assert result == 2

The temporary directory is isolated and automatically removed, even when an exception occurs inside the context manager. Path is preferable to manual string concatenation because it expresses filesystem operations clearly and is portable across operating systems.

2.1 Testing write operations

A writing function should be tested for both content and side effects. For example:

def save_report(path: Path, lines: list[str]) -> None:
    with path.open("w", encoding="utf-8", newline="") as file:
        file.write("\n".join(lines))
        file.write("\n")


def test_save_report_writes_expected_content():
    with TemporaryDirectory() as folder:
        path = Path(folder) / "report.txt"

        save_report(path, ["total=4", "status=ok"])

        assert path.exists()
        assert path.read_text(encoding="utf-8") == "total=4\nstatus=ok\n"

The test checks existence and exact content. Exact comparisons are appropriate when whitespace and line endings are part of the file contract. If formatting is intentionally flexible, tests should compare a normalized representation instead.

2.2 Boundary and failure cases

File tests should include cases such as:

  • an empty file;
  • a file containing blank lines;
  • malformed encoding or invalid data;
  • a missing path;
  • a path referring to a directory rather than a file;
  • insufficient permission, where such a condition can be reproduced safely;
  • very large input, when performance matters.

Tests should not rely on permission manipulation alone, because operating-system permissions differ and a privileged process may bypass them. It is often more reliable to test the application's response to an injected exception or a deliberately invalid path.

3. Testing with unittest

unittest is included in the Python standard library. A test class inherits from unittest.TestCase, and test methods normally begin with test_.

import unittest
from pathlib import Path
from tempfile import TemporaryDirectory


class TestReports(unittest.TestCase):
    def test_count_nonempty_lines(self):
        with TemporaryDirectory() as folder:
            path = Path(folder) / "input.txt"
            path.write_text("one\n\ntwo\n", encoding="utf-8")

            self.assertEqual(count_nonempty_lines(path), 2)

    def test_missing_file_raises_error(self):
        with self.assertRaises(FileNotFoundError):
            count_nonempty_lines(Path("does-not-exist.txt"))


if __name__ == "__main__":
    unittest.main()

Important assertion methods include assertEqual, assertNotEqual, assertTrue, assertFalse, assertIsNone, assertIn, and assertRaises. Assertions should communicate the intended contract rather than merely confirm that execution reached the end of a function.

3.1 Expected exceptions

An exception assertion verifies both that an error occurs and that it is the correct error type.

with self.assertRaises(ValueError):
    parse_percentage("not-a-number")

When the exception message is part of the public contract, a regular expression can be supplied with assertRaisesRegex. Message assertions should not be overused: checking the exact wording of internal messages can make harmless refactoring unnecessarily difficult.

A test that catches every exception and passes is defective:

try:
    operation()
except Exception:
    pass

This hides defects. Tests should fail unless the documented exception is raised.

4. Fixtures and Test Isolation

A fixture is controlled setup data or infrastructure used by a test. Examples include a temporary directory, a sample configuration, a database connection, or a reusable object. Good fixtures make tests independent: one test must not depend on the order or side effects of another.

In unittest, setUp runs before each test and tearDown runs afterward. Class-level methods, setUpClass and tearDownClass, can manage expensive shared resources, but shared mutable state should be used cautiously.

class TestParser(unittest.TestCase):
    def setUp(self):
        self.valid_text = "name,score\nLin,91\n"

    def test_header_is_present(self):
        self.assertTrue(self.valid_text.startswith("name,score"))

In pytest, a fixture is commonly declared with @pytest.fixture:

import pytest


@pytest.fixture
def sample_file(tmp_path):
    path = tmp_path / "data.txt"
    path.write_text("alpha\nbeta\n", encoding="utf-8")
    return path


def test_file_contains_two_lines(sample_file):
    assert sample_file.read_text(encoding="utf-8").splitlines() == ["alpha", "beta"]

The tmp_path fixture creates a unique temporary directory for the test. Pytest also supports parametrization, which permits the same test logic to run against several inputs:

@pytest.mark.parametrize(
    "text, expected", [("", 0), ("x", 1), ("x\ny", 2)]
)
def test_line_count(text, expected):
    assert len(text.splitlines()) == expected

Fixtures should remain appropriately scoped. Function-scoped fixtures maximize isolation; session-scoped fixtures may improve performance but increase the risk of shared-state contamination.

5. Testing Modules and Public Interfaces

A Python module is tested through its public behaviour rather than its implementation details. If a module exposes load_settings(path), tests should call that function and check its documented result. Tests that inspect private variables or depend on the exact sequence of internal helper calls are brittle.

A practical project layout is:

project/
    app/
        __init__.py
        settings.py
        reports.py
    tests/
        test_settings.py
        test_reports.py

A module-level test should consider successful imports, valid inputs, invalid inputs, configuration defaults, and interactions with imported dependencies. Import-time side effects should be minimized. A module that opens files or connects to a service merely because it is imported is difficult to test and difficult to reuse.

Use the conventional guard for executable behaviour:

if __name__ == "__main__":
    main()

This allows the module to be imported by tests without unexpectedly running the command-line program.

6. Mocks and Test Doubles

A test double replaces a real dependency with a controlled substitute. Common types include stubs, fakes, spies, and mocks. A mock can record calls and provide configured return values or side effects.

Python's unittest.mock.patch replaces an object temporarily. The most important rule is to patch the name where the code under test looks it up, not necessarily where the object was originally defined.

Suppose reports.py contains:

from pathlib import Path


def read_report(path):
    return Path(path).read_text(encoding="utf-8")

A test may patch app.reports.Path rather than pathlib.Path because reports.py has its own imported name. A simpler and often better design is dependency injection:

def load_text(path, opener=open):
    with opener(path, "r", encoding="utf-8") as file:
        return file.read()

The test can provide a fake opener without altering global state.

from io import StringIO


def test_load_text_uses_injected_opener():
    def fake_open(*args, **kwargs):
        return StringIO("controlled content")

    assert load_text("ignored.txt", opener=fake_open) == "controlled content"

Mocks are valuable when a dependency is slow, nondeterministic, costly, or unavailable. They should not replace every integration test. Excessive mocking can produce tests that pass while the real components are incompatible.

7. Testing Exception Design

Exceptions communicate failure categories. A useful API raises a specific built-in or custom exception and preserves the original cause when translating errors.

class ConfigurationError(Exception):
    """The application configuration is invalid."""


def read_port(path):
    try:
        value = Path(path).read_text(encoding="utf-8").strip()
        port = int(value)
    except (OSError, ValueError) as error:
        raise ConfigurationError("invalid port configuration") from error

    if not 1 <= port <= 65535:
        raise ConfigurationError("port is outside the valid range")
    return port

Tests should verify valid boundaries and invalid values:

import pytest


def test_port_accepts_boundary_values(tmp_path):
    path = tmp_path / "port.txt"
    path.write_text("65535", encoding="utf-8")
    assert read_port(path) == 65535


@pytest.mark.parametrize("value", ["0", "65536", "abc"])
def test_port_rejects_invalid_values(tmp_path, value):
    path = tmp_path / "port.txt"
    path.write_text(value, encoding="utf-8")
    with pytest.raises(ConfigurationError):
        read_port(path)

The test checks the abstraction exposed to callers, ConfigurationError, rather than requiring callers to understand whether the original failure was an OSError or ValueError. Exception chaining remains available for diagnosis through __cause__.

8. A Detailed Applied Case: Validating a Report Importer

Background

Consider an illustrative application that imports a text report containing one integer per line and writes a summary file. The application must reject malformed records, report a missing input file, and avoid leaving a partial output file when validation fails.

Problem

A first implementation reads and writes directly using fixed filenames. It works during a demonstration but overwrites previous output, is difficult to test repeatedly, and sometimes produces a partial report after malformed input.

Analysis

The design is separated into three responsibilities:

  1. parse_numbers(text) validates and converts input;
  2. summarize(values) computes the result;
  3. import_report(source, destination) coordinates file I/O.

This separation permits fast unit tests for parsing and summarization, plus an integration test for the complete file workflow.

class ReportFormatError(ValueError):
    pass


def parse_numbers(text):
    values = []
    for number, line in enumerate(text.splitlines(), start=1):
        if not line.strip():
            continue
        try:
            values.append(int(line.strip()))
        except ValueError as error:
            raise ReportFormatError(f"invalid integer on line {number}") from error
    return values


def summarize(values):
    if not values:
        raise ReportFormatError("report contains no values")
    return {"count": len(values), "total": sum(values)}


def import_report(source, destination):
    text = source.read_text(encoding="utf-8")
    summary = summarize(parse_numbers(text))
    destination.write_text(
        f"count={summary['count']}\ntotal={summary['total']}\n",
        encoding="utf-8",
    )

Test approach

Unit tests check valid parsing, blank lines, malformed records, and empty input. The integration test uses tmp_path to create both files and verifies the final output.

def test_import_report_end_to_end(tmp_path):
    source = tmp_path / "input.txt"
    destination = tmp_path / "summary.txt"
    source.write_text("4\n\n6\n", encoding="utf-8")

    import_report(source, destination)

    assert destination.read_text(encoding="utf-8") == "count=2\ntotal=10\n"

Outcome and learning points

The revised design is easier to test because pure transformations are separated from side effects. Temporary resources prevent damage to real data, custom exceptions provide a stable error contract, and the integration test confirms that the components cooperate. A production implementation might additionally write to a temporary output and rename it after successful completion, providing stronger protection against interruption during writing.

9. Coverage and Automated Quality Checks

Coverage tools estimate which parts of a program are executed by tests. With coverage.py, a typical command is:

coverage run -m pytest
coverage report -m
coverage html

A line coverage percentage can be expressed as:

line coverage = (executable lines executed by tests ÷ total executable lines) × 100%

The numerator and denominator refer to executable source lines included by the coverage configuration. The metric indicates execution, not correctness. A test may execute a line without asserting its result; therefore, 100% line coverage does not prove that software is defect-free.

Branch coverage provides additional information about decision outcomes. It is especially useful for exception handling and conditional file-processing logic. Coverage thresholds should be treated as quality signals, not targets achieved through meaningless tests.

A professional automated check may run:

python -m compileall app
pytest --strict-markers
coverage run -m pytest
coverage report --fail-under=85
ruff check .

These checks can run locally and in continuous integration whenever code is committed. A pipeline should fail when tests fail, coverage falls below the agreed threshold, or static analysis reports an error. Dependencies should be pinned or managed reproducibly, and tests should run in a clean environment.

10. Challenges, Ethics, and Best Practices

Tests can become unreliable because of clock time, random values, network access, operating-system differences, locale settings, encoding assumptions, or shared state. Such tests are often called flaky tests. They should be diagnosed rather than repeatedly rerun until they pass.

File tests must protect confidentiality. Do not copy production personal data into a repository merely to obtain realistic fixtures. Use synthetic or anonymized data, restrict temporary-file permissions where appropriate, and ensure that logs do not expose credentials or sensitive contents.

Recommended practices include:

  • give each test one clear purpose;
  • use deterministic inputs and explicit encodings;
  • isolate filesystem state with temporary directories;
  • test public contracts rather than private implementation details;
  • assert both successful and failure behaviour;
  • preserve exception causes when translating errors;
  • use mocks selectively and retain integration tests for real boundaries;
  • keep tests fast enough to run frequently;
  • review coverage reports for meaningful gaps;
  • automate tests, formatting, linting, and security checks;
  • treat test code as production-quality code.

Artificial intelligence tools can help generate test cases or identify boundary conditions, but generated tests require human review. A tool may reproduce the implementation's assumptions, miss security risks, or assert an incorrect expected result. Developers remain responsible for test validity, privacy, and licensing of any generated material.

Chapter Summary

Testing provides executable evidence that Python programs satisfy their behavioural contracts. Unit tests isolate functions and classes, while integration tests verify cooperation across file, module, and application boundaries. Temporary directories make file tests safe and repeatable. Fixtures organize reusable setup, and mocks control expensive or nondeterministic dependencies; however, excessive mocking can conceal integration defects. unittest and pytest both support clear assertions, parametrization concepts, and expected-exception testing. Custom exceptions provide stable interfaces for callers, while exception chaining preserves diagnostic information. Coverage measures execution rather than correctness, so it should be combined with meaningful assertions, code review, linting, and continuous integration. Reliable testing also requires attention to determinism, privacy, portability, and responsible use of automation.

Key Terms

Unit test: A test of a small component in relative isolation.

Integration test: A test of interactions between multiple components.

Assertion: A check comparing observed behaviour with an expected condition.

Fixture: Controlled data or setup infrastructure used by tests.

Temporary directory: An isolated filesystem location created for short-lived test resources.

Mock: A programmable test double that can return values and record interactions.

Stub: A test substitute that provides predetermined responses.

Fake: A simplified working implementation used in testing.

Patch: Temporary replacement of an object or dependency during a test.

Regression test: A test that protects behaviour against future changes.

Exception contract: The documented conditions and exception types exposed by code.

Exception chaining: Preservation of an original exception as the cause of a new exception.

Parametrization: Running one test structure with multiple input sets.

Test isolation: Independence of tests from one another and from external state.

Code coverage: A measurement of which source elements tests execute.

Branch coverage: Measurement of alternative decision paths exercised by tests.

Flaky test: A test whose result changes without a relevant code change.

Continuous integration: Automated building and testing of changes in a shared workflow.

Review Questions

Short Answer Questions

  1. What is the principal difference between a unit test and an integration test?
  2. Why are temporary directories preferable to fixed project filenames in file-I/O tests?
  3. What does assertRaises verify?
  4. Why should a module avoid performing substantial work during import?
  5. What is the main limitation of line coverage?

Long Answer Questions

  1. Explain the arrange–act–assert pattern and demonstrate how it improves test readability.
  2. Compare fixtures, mocks, stubs, and fakes, including an appropriate use for each.
  3. Explain how custom exceptions and exception chaining can create a stable API.
  4. Design a test strategy for a Python program that reads, validates, and rewrites a configuration file.
  5. Discuss why a high coverage percentage does not necessarily indicate high software quality.

Analytical / Application Questions

  1. A test suite passes locally but fails on another operating system because it assumes a particular path separator and encoding. Diagnose the causes and propose a portable redesign.
  2. A developer mocks every filesystem call and obtains 100% unit coverage, but deployment reveals that output files are incomplete. Explain how the test strategy failed and propose integration tests that address the problem.
  3. Design a continuous-integration quality gate for a Python file-processing project. Specify the test, coverage, linting, and security checks, and justify the order in which they should run.

Multiple Choice Questions with Answers

Question 1

Which practice provides the safest isolation for a test that creates files?

A. Writing into the developer's home directory

B. Using a fixed filename in the project root

C. Creating resources inside a temporary directory

D. Reusing a file created by another test

Correct Answer: C. Creating resources inside a temporary directory

Question 2

What does unittest.TestCase.assertRaises primarily verify?

A. That a function returns None

B. That a specified exception type is raised

C. That a file is automatically deleted

D. That a mock was called twice

Correct Answer: B. That a specified exception type is raised

Question 3

Where should a dependency normally be patched?

A. Only in the module where Python originally implemented it

B. At the name where the code under test looks it up

C. In the operating system kernel

D. In every imported module

Correct Answer: B. At the name where the code under test looks it up

Question 4

Which test most clearly represents an integration test?

A. Testing sum([2, 3]) in isolation

B. Checking that a parser converts one string to an integer

C. Creating an input file, running the importer, and verifying the output file

D. Checking whether a class has a method named run

Correct Answer: C. Creating an input file, running the importer, and verifying the output file

Question 5

Why is if __name__ == "__main__": useful in a module?

A. It prevents all exceptions

B. It allows executable code to run only when the module is executed directly

C. It automatically creates a test fixture

D. It increases line coverage

Correct Answer: B. It allows executable code to run only when the module is executed directly

Question 6

What is the main danger of catching every exception and allowing a test to pass?

A. It makes the test execute too quickly

B. It hides unexpected defects

C. It prevents temporary directories from being created

D. It converts exceptions into warnings

Correct Answer: B. It hides unexpected defects

Question 7

A coverage report shows 100% line coverage. What can legitimately be concluded?

A. The software contains no defects

B. Every possible input has been tested

C. The measured executable lines were executed by tests

D. All branches and exception paths were tested

Correct Answer: C. The measured executable lines were executed by tests

Question 8

Which design most directly improves testability of file-reading code?

A. Hard-coding a global filename

B. Opening files during module import

C. Injecting an opener or filesystem dependency

D. Catching and ignoring all I/O errors

Correct Answer: C. Injecting an opener or filesystem dependency

Question 9

What is a flaky test?

A. A test that always fails because of a syntax error

B. A test whose result changes unpredictably without a relevant code change

C. A test that uses a temporary directory

D. A test with more than one assertion

Correct Answer: B. A test whose result changes unpredictably without a relevant code change

Question 10

Why should a custom exception sometimes be raised with from error?

A. To remove all diagnostic information

B. To preserve the original exception as the cause

C. To convert the exception into a return value

D. To disable exception handling

Correct Answer: B. To preserve the original exception as the cause

Advanced Python Programming (File I/O, Modules, Exceptions)
Chapter 11 of 13