Unit 14 · lesson

Testing Real Applications

Some functions are easy to test because they receive values and return values.

Other code opens files, waits for keyboard input, prints to a terminal, calls a network service, reads the clock, or mutates shared state.

That does not make the second category “untestable.” It means you need to see the boundaries.

Start with code that mixes everything

def get_user_age():
    age_text = input("Enter your age: ")
    age = int(age_text)

    if age >= 18:
        print("Access granted")
    else:
        print("Access denied")

This function has several jobs:

interactive input
conversion
business rule
output formatting
terminal output

A human can test it manually. An automated unit test has to deal with the interactive I/O even if the only rule you care about is age >= 18.

Pull the rule away from the interface

Separate the decision:

def check_access(age):
    if age >= 18:
        return "Access granted"
    return "Access denied"

Then connect it to the user interface:

def main():
    age_text = input("Enter your age: ")
    age = int(age_text)
    result = check_access(age)
    print(result)

Now the core rule can be exercised directly:

def test_age_18_is_granted():
    assert check_access(18) == "Access granted"


def test_age_17_is_denied():
    assert check_access(17) == "Access denied"
Controlled vs Live
Controlled vs Live

Diagrams open at a readable shape-aware scale. Zoom or expand when you need more detail.

The interface still needs testing, but you no longer need the entire interface just to prove the rule.

Testability exposes architecture

The refactor created a boundary:

INPUT / OUTPUT LAYER
reads and displays

LOGIC LAYER
makes the access decision

That is not only a testing trick. It clarifies responsibility.

The same idea works with files:

Hard to isolate:

def average_from_file():
    with open("scores.txt") as file:
        scores = [int(line) for line in file]
    return sum(scores) / len(scores)

Separated responsibilities:

def calculate_average(scores):
    if not scores:
        raise ValueError("scores cannot be empty")
    return sum(scores) / len(scores)


def load_scores(path):
    with open(path, encoding="utf-8") as file:
        return [int(line.strip()) for line in file if line.strip()]

Now the calculation can be tested without touching the filesystem:

def test_calculate_average():
    assert calculate_average([80, 90, 100]) == 90

The file-reading function can receive a separate integration test.

Unit evidence and integration evidence answer different questions

A unit test usually focuses on a small behavior boundary in controlled conditions.

An integration test checks whether multiple real parts work together.

For the score example:

UNIT TEST
Does calculate_average([80, 90, 100]) return 90?

INTEGRATION TEST
Can load_scores(path) read a real test file and produce the expected values?

APPLICATION CHECK
Can the user run the workflow and see the correct report?
Unit vs Integration
Unit vs Integration

Diagrams open at a readable shape-aware scale. Zoom or expand when you need more detail.

None of those evidence types automatically replaces the others.

Controlled dependencies make failures easier to interpret

Consider Unit 13's API dashboard.

If the analysis function accepts already-decoded data:

def analyze_todos(todo_list):
    completed = sum(1 for todo in todo_list if todo["completed"])
    return {
        "total": len(todo_list),
        "completed": completed,
    }

then you can test it with controlled data:

def test_analyze_todos_counts_completed_items():
    todos = [
        {"title": "A", "completed": True},
        {"title": "B", "completed": False},
    ]

    result = analyze_todos(todos)

    assert result == {"total": 2, "completed": 1}

That test says nothing about Wi-Fi, DNS, or the remote server. Good. It is trying to prove the analysis rule, not the whole internet.

A separate test/check can exercise acquisition.

When a bug appears, freeze it into a case

Suppose you discover that an exact threshold of 20 was treated as safe even though the requirement says 20 or below is low battery.

Before repairing the code, write the failing behavior as a test:

def test_battery_20_is_low():
    assert is_low_battery(20) is True

Confirm the test fails.

Then repair:

def is_low_battery(level):
    return level <= 20

Run the suite again.

Bug Becomes Test
Bug Becomes Test

Diagrams open at a readable shape-aware scale. Zoom or expand when you need more detail.

Now the project carries memory of that failure.

Test-driven development is one workflow, not a religion

You may hear TDD, or test-driven development, described as a cycle where a test is written before the implementation, the test fails, code is added, and the test passes.

That can be useful when the desired behavior is clear enough to state first.

It is not the only legitimate development workflow, and writing a weak test first does not magically produce good design.

The durable habit for this course is simpler:

Important behavior should become explicit, executable evidence early enough that later changes can challenge it.

Refactor one difficult-to-test function

Find a function in your project that mixes at least two responsibilities, such as:

  • input + calculation;
  • file reading + analysis;
  • API acquisition + reporting;
  • data mutation + printing.

Draw the current boundary.

Then refactor so one core behavior can be tested using direct values.

Your evidence should include:

BEFORE responsibility map
AFTER responsibility map
one automated test that became easier
one integration behavior that still needs separate evidence

That is the bridge from “I can write tests” to “I can design software that is verifiable.”

Vocabulary lab

Flip the idea, not just the card

Explain the term before you reveal the back. Then compare your explanation with the definition, example, and warning.

1 / 5
Read all terms without animation
Testable Design
Program structure that exposes important behavior through boundaries that can be exercised with controlled inputs and observable results. Example: Separating calculate_average from file loading. Do not confuse it with: Adding tests without changing tightly mixed responsibilities.
Unit Test
A test focused on a small behavior boundary under controlled conditions. Example: Calling calculate_average with an in-memory list. Do not confuse it with: A check of the full application workflow.
Integration Test
A test that checks cooperation between multiple real components or boundaries. Example: Reading a real temporary file and parsing its contents. Do not confuse it with: A small calculation test with no external dependency.
Regression Test
A retained test that reproduces behavior from a previously discovered bug so later changes challenge the same failure condition. Example: Exact battery threshold 20 remains classified as low. Do not confuse it with: A one-time manual verification that is never rerun.
I/O Boundary
A point where program logic interacts with the outside world such as terminal input, files, networks, or displays. Example: load_scores reading a file path. Do not confuse it with: A pure calculation over values already in memory.