Testing the Data Layer with Mocks
This is the Layer 5 gate. We test CountryRepository in complete isolation, using a small, controlled fake dataset โ no real files, no real network, ever.
Testing the data layer with fake data, never a real file or network.
1. Learning Objective
By the end of this session you will be able to:
- Test CountryRepository by constructing it around a small, hand-written fake dataset
- Test that validate_country_record correctly accepts good records and rejects bad ones
- Explain why testing the repository this way requires zero real files or network access
- Write a test that verifies get_all() and find_by_region() both return the correct Country instances
- Connect every prior Layer 5 technique (fixtures, exact-content assertions, pytest.raises) into one cohesive test file
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 does testing CountryRepository with a small hand-written fake dataset (not the real mock_countries.py or countries.json) make the tests more reliable?
A test constructs CountryRepository(raw_data=[{"name": "Testland", "region": "Testregion", "population": 1}]) and calls repo.get_all(). What should it assert?
How would you test that validate_country_record (Session 30) correctly rejects a record with population as a string instead of an int?
Why is testing the data layer this way (in-memory fake data, zero files, zero network) considered a genuine unit test rather than a slower integration test?
A test suite for this project now spans country.py's Country and CountryExplorer classes plus CountryRepository โ around a dozen test functions total. What has this Layer 5 investment actually purchased for Layer 6 (Architecture, next)?
3. The Concept โ Testing the Data Layer in Isolation
The test supplies its own small, controlled dataset directly to the repository โ no real file, no network, ever touched.
Building the repository around fake, in-memory data
Session 28's design โ accepting any raw_data through the constructor โ is exactly what makes this possible: a test can supply a tiny, purpose-built dataset instead of the real mock file or a real JSON file.
from country import Country, CountryRepository
def test_get_all_converts_raw_data_to_country_instances():
fake_data = [
{"name": "Testland", "region": "Testregion", "population": 1},
]
repo = CountryRepository(raw_data=fake_data)
result = repo.get_all()
assert len(result) == 1
assert isinstance(result[0], Country)
assert result[0].name == "Testland"
assert result[0].population == 1
Testing find_by_region against a known fake dataset
A small, deliberately crafted dataset โ with known regions โ makes it trivial to assert on exact results, following Session 33's exact-content discipline.
def test_find_by_region_filters_correctly():
fake_data = [
{"name": "Testland", "region": "Africa", "population": 1},
{"name": "Otherland", "region": "Europe", "population": 2},
{"name": "Thirdland", "region": "Africa", "population": 3},
]
repo = CountryRepository(raw_data=fake_data)
result = repo.find_by_region("Africa")
assert [c.name for c in result] == ["Testland", "Thirdland"]
Testing validate_country_record with good and bad records
Session 30's validation function is tested the same way as any other function โ normal case, and edge/error cases using pytest.raises.
import pytest
from country import validate_country_record
def test_validate_accepts_correct_record():
data = {"name": "Kenya", "region": "Africa", "population": 54000000}
assert validate_country_record(data) == data # unchanged, valid
def test_validate_rejects_missing_field():
with pytest.raises(ValueError):
validate_country_record({"name": "Ghost Nation"})
def test_validate_rejects_wrong_type():
with pytest.raises(TypeError):
validate_country_record({"name": "Kenya", "region": "Africa", "population": "fifty-four"})
This is a unit test, not an integration test
Because everything happens in-memory, with no real file or network access, these tests run fast and are completely unaffected by whether a real file exists, is correctly formatted, or a real API happens to be reachable at test time.
4. Lab
What you will build
A file called tests/test_repository.py, extending country.py with CountryRepository and validate_country_record if not already present.
Step-by-step instructions
Ensure country.py has CountryRepository and validate_country_record
# country.py (additions if not already present)
class CountryRepository:
def __init__(self, raw_data):
self._raw_data = raw_data
def get_all(self):
return [Country.from_dict(r) for r in self._raw_data]
def find_by_region(self, region):
return [Country.from_dict(r) for r in self._raw_data if r["region"] == region]
def validate_country_record(data):
required = {"name": str, "region": str, "population": int}
for key, expected_type in required.items():
if key not in data:
raise ValueError(f"missing required field: {key}")
if not isinstance(data[key], expected_type):
raise TypeError(f"{key} must be {expected_type.__name__}, got {type(data[key]).__name__}")
return data
Write tests for get_all() using a small fake dataset
# tests/test_repository.py
import pytest
from country import Country, CountryRepository, validate_country_record
def test_get_all_converts_to_country_instances():
fake_data = [{"name": "Testland", "region": "Testregion", "population": 1}]
repo = CountryRepository(raw_data=fake_data)
result = repo.get_all()
assert len(result) == 1
assert isinstance(result[0], Country)
assert result[0].name == "Testland"
Write tests for find_by_region with exact-content assertions
def test_find_by_region_exact_matches():
fake_data = [
{"name": "Testland", "region": "Africa", "population": 1},
{"name": "Otherland", "region": "Europe", "population": 2},
{"name": "Thirdland", "region": "Africa", "population": 3},
]
repo = CountryRepository(raw_data=fake_data)
result = repo.find_by_region("Africa")
assert [c.name for c in result] == ["Testland", "Thirdland"]
def test_find_by_region_no_matches():
repo = CountryRepository(raw_data=[{"name": "Testland", "region": "Africa", "population": 1}])
assert repo.find_by_region("Antarctica") == []
Write validate_country_record tests: valid, missing field, wrong type
def test_validate_accepts_correct_record():
data = {"name": "Kenya", "region": "Africa", "population": 54000000}
assert validate_country_record(data) == data
def test_validate_rejects_missing_field():
with pytest.raises(ValueError):
validate_country_record({"name": "Ghost Nation"})
def test_validate_rejects_wrong_type():
with pytest.raises(TypeError):
validate_country_record({"name": "Kenya", "region": "Africa", "population": "fifty-four"})
Run the full project test suite and confirm everything passes together
Run pytest with no arguments from the project root โ every test file from Sessions 32-35 should be discovered and pass.
# pytest -v
5. Expected Files Changed
| File | Action | Why |
|---|---|---|
country.py |
Modified | Ensures CountryRepository and validate_country_record are present as real, importable code. |
tests/test_repository.py |
Created | A complete, isolated test suite for the data-access layer using in-memory fake data. |
docs/sessions/session-35/index.html |
Created | This session document โ Layer 5 gate. |
6. Commit Checkpoint
Once the lab is complete and you can explain every line, make this exact commit:
git add country.py tests/test_repository.py docs/sessions/session-35/index.html
git commit -m "session-35: test the repository and validation logic in complete isolation"
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 does testing CountryRepository with a small hand-written fake dataset (not the real mock_countries.py or countries.json) make the tests more reliable?
A test constructs CountryRepository(raw_data=[{"name": "Testland", "region": "Testregion", "population": 1}]) and calls repo.get_all(). What should it assert?
How would you test that validate_country_record (Session 30) correctly rejects a record with population as a string instead of an int?
Why is testing the data layer this way (in-memory fake data, zero files, zero network) considered a genuine unit test rather than a slower integration test?
A test suite for this project now spans country.py's Country and CountryExplorer classes plus CountryRepository โ around a dozen test functions total. What has this Layer 5 investment actually purchased for Layer 6 (Architecture, next)?
9. Reflection Questions
Think through these after the post-quiz. No right answer โ they are for discussion.
- Why would testing CountryRepository against the REAL mock_countries.py file (instead of a small fake dataset built in the test) make the test more fragile over time?
- How many total tests have you written across Layer 5? What parts of the project (from Sessions 1-26) still have zero test coverage, and would they be worth adding tests for?
- What is the relationship between this session's "inject fake data through the constructor" technique and Session 15's "explicit constructor arguments, not hidden global state" principle?
- If you had to explain to someone why this project is now safer to refactor than it was at Session 24, what would you point to specifically?
10. What Breaks If This Knowledge Is Missing?
- Fragile, slow tests: Testing against real files or real APIs makes tests slow, flaky (network can fail for reasons unrelated to your code), and dependent on external state being correctly set up โ exactly what isolated unit testing with fake data avoids.
- Unsafe refactoring (Layer 6): Without this session's test coverage of the data layer specifically, Layer 6's folder reorganization sessions would risk silently breaking how data flows through the repository, with nothing to catch it.
- Real APIs without a safety net (Layer 7): When Session 42 introduces a genuinely real, unreliable external API, having the repository's OWN logic already fully tested in isolation means only the new real-API-specific code needs new testing attention, not the whole data layer again.
11. What We Learned
Python concept mastered: Testing a data-access layer in complete isolation using small, purpose-built fake datasets injected through the constructor โ true unit testing, not integration testing.
Unlocks: The entire application โ Country, CountryExplorer, CountryRepository, and validation โ is now covered by a real, isolated, fast test suite. Layer 6's refactoring can proceed with confidence.
Next session: Session 36 โ Package and Folder Organization. Layer 6 begins. We reorganize the growing project into a proper package structure โ everything up to now has lived in a handful of flat files.