Layer 5 Session 34 Testing

Testing Classes and State Changes

Not every method returns a meaningful value โ€” many exist to change an object's state over time. We test that state changes correctly, using setup/teardown fixtures for clean, isolated tests.

population 54,000,000 changes over time

Confirming a change actually happened โ€” before and after.

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:

  • Write a test that verifies a mutation happened by checking state before and after
  • Use a pytest fixture to set up a fresh instance for every test, avoiding shared state between tests
  • Test that CountryExplorer's add_country correctly updates its computed properties
  • Test a multi-step sequence of state changes, not just a single call
  • Explain why sharing one instance across many tests is risky

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

Why is checking state BOTH before and after a mutating call more thorough than only checking the state after?

A test that never checks the "before" state cannot rule out that the method call did nothing at all, if the value happened to coincidentally already match the expected result โ€” checking both is a stronger, more trustworthy test.
Question 2 of 5

What is a pytest fixture, in the sense of def fresh_explorer(): return CountryExplorer(countries=[]) decorated with @pytest.fixture?

A fixture provides reusable setup logic. Any test function that takes the fixture's name as a parameter automatically receives a freshly created object from it โ€” every test gets its own independent starting point, with no manual setup code repeated in each test.
Question 3 of 5

Why is sharing ONE CountryExplorer instance across many different test functions (instead of a fresh one per test, via a fixture) risky?

This is Session 05's and Session 20's reference/mutation lessons applied directly to test design: a shared mutable object means tests are no longer independent โ€” the order tests happen to run in can change their outcome, which is a serious test-suite design flaw.
Question 4 of 5

You want to test that CountryExplorer.total_population correctly reflects a country added via add_country(). What should the test check?

This directly verifies Session 26's guarantee: since total_population is computed fresh from self.countries every access, it should automatically reflect the addition โ€” testing before and after values confirms this actually holds true in the real running code.
Question 5 of 5

A test performs THREE sequential state changes (e.g. grow_population twice, then set_population once) and checks only the final value. What does this kind of test verify that three separate single-step tests would not?

Some bugs only emerge from a REALISTIC SEQUENCE of state changes โ€” for instance, a method that assumes it is always called first might behave incorrectly the second time. A multi-step test exercises this more realistic scenario that isolated single-call tests would miss.

3. The Concept โ€” Testing State Changes

FIXTUREkenya()TEST Afresh instanceTEST Bfresh instance

A fixture builds a brand-new instance for each test that requests it โ€” no test can accidentally leak mutated state into another.

Checking state before and after

A thorough state-change test confirms the "before" value is what you expect, performs the mutation, and confirms the "after" value is correct โ€” ruling out a test that would coincidentally pass even if the method silently did nothing.

from country import Country

def test_grow_population_changes_state():
    k = Country(name="Kenya", region="Africa", population=54000000)
    assert k.population == 54000000   # confirm the "before" state explicitly

    k.grow_population(1000000)

    assert k.population == 55000000   # confirm the "after" state

Fixtures โ€” fresh, isolated setup for every test

A fixture is reusable setup logic pytest automatically provides to any test that asks for it by parameter name โ€” guaranteeing every test starts from a clean, independent instance.

import pytest
from country import Country

@pytest.fixture
def kenya():
    return Country(name="Kenya", region="Africa", population=54000000)

def test_grow_population(kenya):        # pytest automatically calls kenya() and passes the result in
    kenya.grow_population(1000000)
    assert kenya.population == 55000000

def test_set_population(kenya):          # a COMPLETELY SEPARATE, fresh instance โ€” not shared with the test above
    kenya.set_population(99)
    assert kenya.population == 99

The risk of a shared instance across tests

Without a fixture, using one module-level instance across many tests means a mutation in one test silently carries into the next โ€” a direct real-world consequence of the reference-sharing behaviour from Session 05 and 16.

# RISKY โ€” a single shared instance across tests
shared_kenya = Country(name="Kenya", region="Africa", population=54000000)

def test_a_grows_population():
    shared_kenya.grow_population(1000000)
    assert shared_kenya.population == 55000000  # passes

def test_b_expects_original_population():
    assert shared_kenya.population == 54000000  # FAILS โ€” test_a's mutation leaked in!
    # This test's result now depends on test execution ORDER, which is a serious design flaw

Testing a computed property tracking a mutation

Combining Session 26's computed properties with state-change testing verifies the whole chain works correctly together.

from country import Country, CountryExplorer

@pytest.fixture
def empty_explorer():
    return CountryExplorer(countries=[])

def test_total_population_reflects_added_country(empty_explorer):
    assert empty_explorer.total_population == 0   # before

    empty_explorer.add_country(Country(name="Kenya", region="Africa", population=54000000))

    assert empty_explorer.total_population == 54000000   # after โ€” automatically correct

4. Lab

Lab objective: Add CountryExplorer to country.py, then test state changes using fixtures, before/after checks, and a multi-step sequence.

What you will build

Extends country.py and adds tests/test_state_changes.py.

Step-by-step instructions

1

Add CountryExplorer with add_country and computed properties to country.py

# country.py (additions)
class CountryExplorer:
    def __init__(self, countries):
        self.countries = countries

    def add_country(self, country):
        if not isinstance(country, Country):
            raise TypeError(f"expected a Country instance, got {type(country).__name__}")
        self.countries.append(country)

    @property
    def country_count(self):
        return len(self.countries)

    @property
    def total_population(self):
        return sum(c.population for c in self.countries)
2

Write fixtures for a fresh Country and a fresh empty CountryExplorer

# tests/test_state_changes.py
import pytest
from country import Country, CountryExplorer

@pytest.fixture
def kenya():
    return Country(name="Kenya", region="Africa", population=54000000)

@pytest.fixture
def empty_explorer():
    return CountryExplorer(countries=[])
3

Write before/after tests for grow_population and set_population

def test_grow_population_changes_state(kenya):
    assert kenya.population == 54000000
    kenya.grow_population(1000000)
    assert kenya.population == 55000000

def test_set_population_changes_state(kenya):
    assert kenya.population == 54000000
    kenya.set_population(1)
    assert kenya.population == 1
4

Test add_country updating both computed properties

def test_add_country_updates_computed_properties(empty_explorer, kenya):
    assert empty_explorer.country_count == 0
    assert empty_explorer.total_population == 0

    empty_explorer.add_country(kenya)

    assert empty_explorer.country_count == 1
    assert empty_explorer.total_population == 54000000
5

Write a multi-step sequence test

Grow twice, then set once, checking the final value reflects all three operations correctly.

def test_multi_step_population_sequence(kenya):
    kenya.grow_population(1000000)   # 54,000,000 -> 55,000,000
    kenya.grow_population(2000000)   # 55,000,000 -> 57,000,000
    kenya.set_population(60000000)   # -> 60,000,000 directly
    assert kenya.population == 60000000

5. Expected Files Changed

FileActionWhy
country.py Modified Adds CountryExplorer with add_country and computed properties.
tests/test_state_changes.py Created Fixture-based, isolated tests for state changes, computed properties, and multi-step sequences.
docs/sessions/session-34/index.html Created This session document.
If you find yourself editing any other file, stop. This session touches exactly 3 files.

6. Commit Checkpoint

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

git add country.py tests/test_state_changes.py docs/sessions/session-34/index.html
git commit -m "session-34: test state changes with fixtures, before/after checks, and multi-step sequences"
Do not commit until you can answer out loud: "Why does using a fixture instead of one shared module-level instance prevent tests from affecting each other?"

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

Why is checking state BOTH before and after a mutating call more thorough than only checking the state after?

A test that never checks the "before" state cannot rule out that the method call did nothing at all, if the value happened to coincidentally already match the expected result โ€” checking both is a stronger, more trustworthy test.
Question 2 of 5

What is a pytest fixture, in the sense of def fresh_explorer(): return CountryExplorer(countries=[]) decorated with @pytest.fixture?

A fixture provides reusable setup logic. Any test function that takes the fixture's name as a parameter automatically receives a freshly created object from it โ€” every test gets its own independent starting point, with no manual setup code repeated in each test.
Question 3 of 5

Why is sharing ONE CountryExplorer instance across many different test functions (instead of a fresh one per test, via a fixture) risky?

This is Session 05's and Session 20's reference/mutation lessons applied directly to test design: a shared mutable object means tests are no longer independent โ€” the order tests happen to run in can change their outcome, which is a serious test-suite design flaw.
Question 4 of 5

You want to test that CountryExplorer.total_population correctly reflects a country added via add_country(). What should the test check?

This directly verifies Session 26's guarantee: since total_population is computed fresh from self.countries every access, it should automatically reflect the addition โ€” testing before and after values confirms this actually holds true in the real running code.
Question 5 of 5

A test performs THREE sequential state changes (e.g. grow_population twice, then set_population once) and checks only the final value. What does this kind of test verify that three separate single-step tests would not?

Some bugs only emerge from a REALISTIC SEQUENCE of state changes โ€” for instance, a method that assumes it is always called first might behave incorrectly the second time. A multi-step test exercises this more realistic scenario that isolated single-call tests would miss.

9. Reflection Questions

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

  1. Rewrite one of this session's fixture-based tests using a single shared module-level instance instead. Can you construct a second test that would now fail due to leaked state?
  2. Why does test_add_country_updates_computed_properties need to check BOTH country_count and total_population, rather than just one of them?
  3. What real bug would the multi-step sequence test catch that three separate single-step tests might miss?
  4. How does pytest deciding to call kenya() fresh for every test that requests it relate to Session 13's lesson that every __init__ call creates an independent instance?

10. What Breaks If This Knowledge Is Missing?

  • Order-dependent test failures: Tests that share mutable state can pass or fail depending on the ORDER they happen to run in โ€” an extremely confusing and hard-to-diagnose category of bug in a test suite, entirely avoided by fixtures.
  • Testing the data layer (Session 35): The next session โ€” the Layer 5 gate โ€” applies these exact same fixture and state-testing techniques to CountryRepository, using a mock data source built specifically for testing.
  • Safe architecture refactoring (Layer 6): A test suite with reliable, isolated tests (thanks to fixtures) is what makes Layer 6's refactoring sessions safe โ€” an unreliable, order-dependent suite would give false confidence or false alarms during a refactor.

11. What We Learned

Python concept mastered: Testing state changes with before/after assertions, pytest fixtures for isolated setup, and multi-step sequence testing.

Unlocks: You can now write reliable, independent tests for any object whose behavior involves changing over time โ€” not just functions that simply return a value.

Next session: Session 35 โ€” Testing the Data Layer with Mocks. Layer 5 gate. We test the data-access layer itself, using a small mock repository built specifically for testing, isolated from real data entirely.