Layer 5 Session 31 Testing

Why We Test and What to Test

Layer 5 begins. Every session so far has been manually verified by reading printed output. Automated tests replace that manual check with something repeatable and reliable.

assert result == expected

A check that runs itself, every time, without being asked.

Estimated time: 35โ€“40 minutes  ยท  Pre-quiz โ†’ Concept โ†’ Lab โ†’ Commit โ†’ Post-quiz

1. Learning Objective

By the end of this session you will be able to:

  • Explain what an automated test verifies that manual print-checking does not
  • Distinguish testing behavior (what a function/method does) from testing implementation (how it does it)
  • Identify which parts of the Country Explorer project are worth testing first
  • Explain what NOT to test, and why over-testing has real costs
  • Recognise this as a concept-only session, mirroring Sessions 12, 20, and 27

2. Pre-Coding Quiz

Answer these before reading the concept explanation or writing any code. It is fine to get these wrong โ€” that is the point. You need 4/5 to proceed to the lab.

Question 1 of 5

You have manually run print(kenya.summary()) and visually confirmed it looks right, many times across many sessions. What does an automated test add that this process lacks?

Manual verification depends on you remembering to do it, correctly, every single time you change anything, anywhere in the project. A test encodes the check permanently and runs it consistently โ€” catching regressions you would not think to manually re-check.
Question 2 of 5

A good test for grow_population(amount) checks that self.population increases by amount. Should it also check that self.population is stored as a specific type of internal Python integer object?

Testing behavior means testing WHAT a piece of code does, observable from the outside โ€” the resulting value. Testing implementation means testing HOW it does it internally, which is fragile and tends to break tests unnecessarily when you refactor without changing actual behavior.
Question 3 of 5

Given everything built in Layers 1โ€“4, which is the best FIRST thing to write tests for?

Testing priority should follow risk and reuse: code that many other parts of the application depend on (Country's methods, validation logic, the repository) is the highest-value place to start, since a bug there has the widest blast radius.
Question 4 of 5

Why is testing literally everything, including trivial one-line getters with zero logic, often not worth it?

Every test is code you have to maintain. A test for a trivial getter with no logic (like a plain @property returning self._x) rarely catches a real bug and just adds upkeep cost โ€” testing effort is best spent where logic (and therefore risk of a bug) actually exists.
Question 5 of 5

Why does this session, like Sessions 12/20/27 before it, contain very little new syntax?

This is the fourth time the curriculum uses this structure: concept first (why does this practice exist, what should it apply to), then the concrete implementation next session โ€” building genuine understanding rather than memorized tool usage.

3. The Concept โ€” Why We Test

BEHAVIORobservable result(test this)IMPLEMENTATIONinternal how(usually skip)

Behavior is the observable result โ€” what a test should check. Implementation is the internal how โ€” fragile and usually not worth testing directly.

Manual verification does not scale

Every lab so far ended the same way: run the file, read the printed output, and eyeball whether it looks right. This works for a single session, but it does not scale โ€” nothing stops a later change from silently breaking Session 14's summary() while you are focused on Session 28's repository.

What an automated test actually gives you

A test is code that runs other code and checks the result automatically, every time, without a human needing to remember to look. If a change anywhere breaks an existing behavior, the test fails loudly and immediately โ€” instead of the bug silently shipping unnoticed.

# Conceptually โ€” this is what a test does, without any special tooling yet
def test_summary_format():
    k = Country(name="Kenya", region="Africa", population=54000000)
    result = k.summary()
    assert result == "Kenya (Africa): pop. 54,000,000"

test_summary_format()  # runs silently if it passes; raises AssertionError if not

Behavior vs implementation

A good test checks WHAT code does (its observable result), not HOW it does it internally. Testing implementation details makes tests fragile โ€” they break during harmless refactors that did not actually change any real behavior.

# Good โ€” tests behavior (the observable result)
def test_grow_population_increases_value():
    k = Country(name="Kenya", region="Africa", population=54000000)
    k.grow_population(1000000)
    assert k.population == 55000000

# Bad โ€” tests an implementation detail that could change for unrelated reasons
# def test_grow_population_uses_plus_equals_operator():
#     ... inspecting the actual bytecode or source of grow_population ...

What to prioritize testing in this project

High value: Country's validated methods (set_population, grow_population), CountryRepository's data-fetching and validation logic, and CountryExplorer's computed properties. Lower value: one-line trivial getters with no logic, and anything that is purely cosmetic printing.

Concept session: Like Sessions 12, 20, and 27 before it, the goal today is a correct mental model of testing before Session 32 introduces the concrete pytest tool.

4. Lab

Lab objective: Write plain assert-based checks (no pytest yet) for the highest-value, most logic-bearing parts of the project, and consciously identify what NOT to test.

What you will build

A file called manual_tests.py โ€” using bare assert statements, the same tool pytest builds on.

Step-by-step instructions

1

Create the file with Country and CountryRepository from earlier sessions

# manual_tests.py
class Country:
    def __init__(self, name, region, population):
        self.name = name
        self.region = region
        self.population = population

    def summary(self):
        return f"{self.name} ({self.region}): pop. {self.population:,}"

    def set_population(self, value):
        if value < 0:
            raise ValueError(f"population must be non-negative, got {value}")
        self.population = value

    def grow_population(self, amount):
        if amount < 0:
            raise ValueError(f"amount must be non-negative, got {amount}")
        self.population += amount
2

Write a plain assert-based check for summary()

def test_summary_format():
    k = Country(name="Kenya", region="Africa", population=54000000)
    assert k.summary() == "Kenya (Africa): pop. 54,000,000"

test_summary_format()
print("test_summary_format passed")
3

Write checks for grow_population, including the rejection case

def test_grow_population_increases_value():
    k = Country(name="Kenya", region="Africa", population=54000000)
    k.grow_population(1000000)
    assert k.population == 55000000

def test_grow_population_rejects_negative():
    k = Country(name="Kenya", region="Africa", population=54000000)
    try:
        k.grow_population(-5)
        assert False, "expected a ValueError but none was raised"
    except ValueError:
        pass  # expected

test_grow_population_increases_value()
test_grow_population_rejects_negative()
print("grow_population tests passed")
4

Write a check for set_population preserving previous valid state on rejection

This directly verifies the guarantee established in Session 21.

def test_set_population_rejects_and_preserves_state():
    k = Country(name="Kenya", region="Africa", population=54000000)
    try:
        k.set_population(-1)
        assert False, "expected a ValueError but none was raised"
    except ValueError:
        pass
    assert k.population == 54000000, "population should be unchanged after a rejected update"

test_set_population_rejects_and_preserves_state()
print("set_population rejection test passed")
5

Write a short comment list of things you deliberately did NOT test, and why

E.g. the exact wording of a print() statement, or a trivial getter with no logic.


5. Expected Files Changed

FileActionWhy
manual_tests.py Created Plain assert-based behavior checks for the highest-value logic in the project.
docs/sessions/session-31/index.html Created This session document.
If you find yourself editing any other file, stop. This session touches exactly 2 files.

6. Commit Checkpoint

Once the lab is complete and you can explain every line, make this exact commit:

git add manual_tests.py docs/sessions/session-31/index.html
git commit -m "session-31: write manual assert-based tests for the highest-value logic first"
Do not commit until you can answer out loud: "Why did I choose to test grow_population's rejection case, but decide NOT to test the exact wording of a print() statement?"

7. Code Review Checklist

Go through your code line by line and check each item:


8. Post-Coding Quiz

Same 5 questions. Take it again now that you have written and run the code. You need 4/5 to mark this session complete.

Question 1 of 5

You have manually run print(kenya.summary()) and visually confirmed it looks right, many times across many sessions. What does an automated test add that this process lacks?

Manual verification depends on you remembering to do it, correctly, every single time you change anything, anywhere in the project. A test encodes the check permanently and runs it consistently โ€” catching regressions you would not think to manually re-check.
Question 2 of 5

A good test for grow_population(amount) checks that self.population increases by amount. Should it also check that self.population is stored as a specific type of internal Python integer object?

Testing behavior means testing WHAT a piece of code does, observable from the outside โ€” the resulting value. Testing implementation means testing HOW it does it internally, which is fragile and tends to break tests unnecessarily when you refactor without changing actual behavior.
Question 3 of 5

Given everything built in Layers 1โ€“4, which is the best FIRST thing to write tests for?

Testing priority should follow risk and reuse: code that many other parts of the application depend on (Country's methods, validation logic, the repository) is the highest-value place to start, since a bug there has the widest blast radius.
Question 4 of 5

Why is testing literally everything, including trivial one-line getters with zero logic, often not worth it?

Every test is code you have to maintain. A test for a trivial getter with no logic (like a plain @property returning self._x) rarely catches a real bug and just adds upkeep cost โ€” testing effort is best spent where logic (and therefore risk of a bug) actually exists.
Question 5 of 5

Why does this session, like Sessions 12/20/27 before it, contain very little new syntax?

This is the fourth time the curriculum uses this structure: concept first (why does this practice exist, what should it apply to), then the concrete implementation next session โ€” building genuine understanding rather than memorized tool usage.

9. Reflection Questions

Think through these after the post-quiz. No right answer โ€” they are for discussion.

  1. What would you have to remember to do manually, every single time you changed Country, to catch the same bugs these tests now catch automatically?
  2. Why does test_grow_population_rejects_negative() check for a ValueError instead of checking that population became a specific wrong value?
  3. Can you think of a bug from an EARLIER session in this curriculum that one of today's tests would have caught immediately?
  4. What is the risk of writing too many trivial tests, in terms of your own time and the codebase's long-term maintainability?

10. What Breaks If This Knowledge Is Missing?

  • Silent regressions: Without any automated tests, a change made while working on Session 34 could silently break something built in Session 14, and nothing would tell you until a human happened to notice much later โ€” if ever.
  • A proper testing tool (Session 32): The bare assert statements from this session work, but they lack useful failure messages, test discovery, and a clean way to organize many tests. Session 32 introduces pytest to solve exactly these gaps.
  • Confident refactoring (Layer 6): Session 36-40's architecture refactoring only becomes safe to do confidently once a real test suite exists to catch anything the refactor accidentally breaks โ€” this session is the philosophical foundation for that safety net.

11. What We Learned

Python concept mastered: Why automated tests matter, the distinction between testing behavior and testing implementation, and prioritizing what is actually worth testing.

Unlocks: You can now write and reason about basic automated checks, and โ€” just as importantly โ€” deliberately choose what NOT to test.

Next session: Session 32 โ€” Setting Up pytest. We replace bare assert statements with pytest โ€” a real testing tool with better failure messages, discovery, and organization.