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.
Confirming a change actually happened โ before and after.
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.
Why is checking state BOTH before and after a mutating call more thorough than only checking the state after?
What is a pytest fixture, in the sense of def fresh_explorer(): return CountryExplorer(countries=[]) decorated with @pytest.fixture?
Why is sharing ONE CountryExplorer instance across many different test functions (instead of a fresh one per test, via a fixture) risky?
You want to test that CountryExplorer.total_population correctly reflects a country added via add_country(). What should the test check?
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?
3. The Concept โ Testing State Changes
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
What you will build
Extends country.py and adds tests/test_state_changes.py.
Step-by-step instructions
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)
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=[])
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
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
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
| File | Action | Why |
|---|---|---|
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. |
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"
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.
Why is checking state BOTH before and after a mutating call more thorough than only checking the state after?
What is a pytest fixture, in the sense of def fresh_explorer(): return CountryExplorer(countries=[]) decorated with @pytest.fixture?
Why is sharing ONE CountryExplorer instance across many different test functions (instead of a fresh one per test, via a fixture) risky?
You want to test that CountryExplorer.total_population correctly reflects a country added via add_country(). What should the test check?
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?
9. Reflection Questions
Think through these after the post-quiz. No right answer โ they are for discussion.
- 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?
- Why does test_add_country_updates_computed_properties need to check BOTH country_count and total_population, rather than just one of them?
- What real bug would the multi-step sequence test catch that three separate single-step tests might miss?
- 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.