LEARN PYTHON | 05 FILES, VALIDATION AND TESTING
Build it. Test it. Explain it.
Save useful data, reject unsuitable input and collect repeatable evidence. Work independently before opening a hint, then compare your tested solution with one possible approach.
YOUR CHALLENGE ROUTE
Complete these tasks in your Python editor. The page is a challenge guide, not a general Python runner. Use fictional details where helpful. Work in order or choose a task matching an area you need to practise.
Use a new practice folder for these tasks: several examples deliberately create or replace named practice files. The test examples use temporary folders. Multi-file tasks need their companion modules. Run Python in your editor; this page does not execute arbitrary source.
TRACK YOUR TESTED SOLUTIONS
Tick a task only after you have run it, checked at least two relevant cases and explained one line. These are self-recorded checks, not an automatic assessment of your source code.
Progress lasts while this page is open. Save your actual Python files in your project folder.
01 | FOUNDATION
SAVE, APPEND AND REOPEN A NOTE
Create a small practice log and verify its contents from a fresh read. Work in a new practice folder.
YOUR REQUIREMENTS
- Write one line with w and UTF-8.
- Append a second line with a.
- Use with for every file lifetime.
- Reopen and compare the complete text.
- Explain the deliberately destructive first write.
Hint: where to begin
write does not add a newline. The w operation creates known starting contents and replaces an existing practice file.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Initial run | Two lines: First idea and Second idea |
| Reopen | Exact text includes both newline characters |
| Repeat whole script | Two lines again because the first w resets the practice file |
Reveal one possible solution and explanation
with open("practice_log.txt", "w", encoding="utf-8") as file:
file.write("First idea\n")
with open("practice_log.txt", "a", encoding="utf-8") as file:
file.write("Second idea\n")
with open("practice_log.txt", "r", encoding="utf-8") as file:
restored = file.read()
print(repr(restored))
assert restored == "First idea\nSecond idea\n"The final read supplies persistence evidence. Appending alone would accumulate another second line on each run; this script deliberately resets its known fixture first.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Create a separate append-only version and predict its contents after two runs.
02 | REPAIR FILE MODE
REPAIR AN OVERWRITTEN LOG
A second write unexpectedly erases the first entry. Repair the mode and prove that both entries remain.
YOUR REQUIREMENTS
- Prepare a known first line.
- Use a for the second operation.
- Reopen after both context blocks.
- Compare expected and actual text.
SOURCE TO REPAIR
with open("practice_log.txt", "w", encoding="utf-8") as file:
file.write("Run one\n")
with open("practice_log.txt", "w", encoding="utf-8") as file:
file.write("Run two\n")Hint: where to begin
A second w open truncates before writing. Keeping a with block does not change the meaning of its mode.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Repaired run | Run one, then Run two |
| Original second w | Only Run two remains |
| Remove first newline | Entries join instead of remaining separate lines |
Reveal one possible solution and explanation
with open("practice_log.txt", "w", encoding="utf-8") as file:
file.write("Run one\n")
with open("practice_log.txt", "a", encoding="utf-8") as file:
file.write("Run two\n")
with open("practice_log.txt", encoding="utf-8") as file:
print(file.read(), end="")The change preserves earlier content while adding a new line. A context manager closes the file but does not protect content from a deliberately truncating mode.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Use x to create a new output and handle FileExistsError without replacing the earlier file.
03 | FORMAT-AWARE DATA
PRESERVE A COMMA IN CSV
Write and reload a record whose note contains a comma and quotation marks.
YOUR REQUIREMENTS
- Use csv.DictWriter with declared columns.
- Write a header.
- Use UTF-8 and newline="".
- Reload with DictReader.
- Check exact note text and the amount field type.
Hint: where to begin
Do not construct the CSV line by joining raw fields. The writer handles quoting and doubled quote characters.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Note | Lunch, "small" drink preserved as one field |
| Amount type | str |
| Field count | Three named fields, not a split note |
Reveal one possible solution and explanation
import csv
record = {"name": "Maya", "amount": "2.50", "note": 'Lunch, "small" drink'}
with open("practice.csv", "w", encoding="utf-8", newline="") as file:
writer = csv.DictWriter(file, fieldnames=["name", "amount", "note"])
writer.writeheader()
writer.writerow(record)
with open("practice.csv", encoding="utf-8", newline="") as file:
restored = next(csv.DictReader(file))
assert restored == record
print(restored["note"])
print(type(restored["amount"]).__name__)The CSV module follows delimiter and quote rules. An ordinary reader preserves field text but does not independently validate a currency amount.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add a café note and a line break, then verify the round trip with the CSV parser.
04 | JSON SCHEMA
CHECK JSON SYNTAX AND SHAPE SEPARATELY
Decode a list of player records and reject a wrong root or an incorrectly typed score.
YOUR REQUIREMENTS
- Use json.loads, not eval.
- Require a list of dictionaries.
- Require exactly name and score fields.
- Require non-blank names and integer scores zero through ten.
- Reject Boolean scores as distinct from integers.
Hint: where to begin
type(score) is int rejects True; isinstance(True, int) would be true. Syntax parsing alone is not a schema check.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| List with score 8 | Accepted record list |
| {} | ValueError: wrong root |
| Score "8" or true | ValueError: unsuitable type |
| Malformed JSON | JSONDecodeError from parsing |
Reveal one possible solution and explanation
import json
def decode_players(text):
data = json.loads(text)
if type(data) is not list:
raise ValueError("Use a list of records")
for row in data:
if type(row) is not dict or set(row) != {"name", "score"}:
raise ValueError("Use name and score fields")
if type(row["name"]) is not str or not row["name"].strip():
raise ValueError("Use a non-blank name")
if type(row["score"]) is not int or not 0 <= row["score"] <= 10:
raise ValueError("Use an integer score from 0 to 10")
return data
print(decode_players('[{"name":"Maya","score":8}]'))Every stage has a purpose: parsing syntax, checking collection shape and validating record values. An empty list is permitted under this contract.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Write unittest checks for each rejection route and the empty-list case.
05 | VALIDATION
VALIDATE A QUANTITY WITHOUT HIDING ERRORS
Create a parser for integer text from zero through twenty inclusive. The helper raises ValueError on rejection; the caller reports it.
YOUR REQUIREMENTS
- Separate conversion and range checks.
- Accept zero and twenty.
- Reject malformed text and just-outside numbers.
- Return a valid integer.
- Keep print outside the parser.
Hint: where to begin
int handles conversion; a condition enforces the application range. No broad catch is needed for the helper’s ordinary contract.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| bad; 21 | Rejected for separate rules |
| 0; 20 | Accepted endpoints |
| -1 | Range rejection |
Reveal one possible solution and explanation
def parse_quantity(text):
quantity = int(text)
if not 0 <= quantity <= 20:
raise ValueError("Use quantity 0 to 20")
return quantity
for text in ["bad", "21", "0", "20"]:
try:
quantity = parse_quantity(text)
except ValueError:
print("Rejected:", text)
else:
print("Accepted:", quantity)The caller catches the expected ValueError and controls feedback. Conversion and range remain distinct even though the demonstration gives both rejection routes a short message.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Use exception chaining to give conversion failure a clearer message while keeping its cause.
06 | STATE INTEGRITY
REJECT A CANDIDATE WITHOUT CHANGING THE LIST
Add validated practice scores to a list. A rejected score must not leave a placeholder or partial update.
YOUR REQUIREMENTS
- Validate before append.
- Keep zero as a valid score.
- Reject eleven.
- Compare the list before and after rejection.
- Use specific ValueError handling.
Hint: where to begin
Do not append first and hope a later check repairs the state. A parser can return the accepted value before the list changes.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| 11 | Rejected; scores remain [8] |
| 0 | Accepted; scores become [8, 0] |
| Malformed text | Rejected before append |
Reveal one possible solution and explanation
def parse_score(text):
score = int(text)
if not 0 <= score <= 10:
raise ValueError("Use 0 to 10")
return score
scores = [8]
for text in ["11", "0"]:
before = scores.copy()
try:
candidate = parse_score(text)
except ValueError:
assert scores == before
print("Rejected without changing scores")
else:
scores.append(candidate)
print(scores)The parser returns only an acceptable value. The rejected candidate never reaches the mutation. A copy of this flat integer list is sufficient to compare its before and after values.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Extend the design to a record with multiple fields and validate the whole record before committing.
07 | PATHLIB
MAKE AN EXPLICIT PRACTICE DATA FOLDER
Use pathlib to construct a practice folder and text file. Show the difference between path properties and filesystem operations.
YOUR REQUIREMENTS
- Join components with Path / filename.
- Create parents deliberately.
- Write and read UTF-8 text.
- Print name, stem and suffix.
- Create a CSV path value without renaming the actual text file.
Hint: where to begin
with_suffix constructs another location value; it does not move the file. The practice write deliberately replaces its named practice file.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Path properties | notes.txt, notes, .txt |
| Read text | café |
| with_suffix | Proposed notes.csv; original text file still exists |
Reveal one possible solution and explanation
from pathlib import Path
folder = Path("practice_data")
folder.mkdir(parents=True, exist_ok=True)
path = folder / "notes.txt"
path.write_text("café\n", encoding="utf-8")
print(path.name, path.stem, path.suffix)
print(path.read_text(encoding="utf-8"), end="")
print("Proposed export:", path.with_suffix(".csv").name)
print("Text still exists:", path.is_file())Joining and suffix changes build Path objects. mkdir and write_text perform filesystem operations. Relative practice_data is based on the current working directory.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: In a saved script, anchor the folder beside __file__ and explain the changed base.
08 | CALENDAR DATA
CHECK DATES ACROSS A BOUNDARY
Validate exact YYYY-MM-DD text and calculate a duration across a year boundary.
YOUR REQUIREMENTS
- Check dashed structure with a full match.
- Use date.fromisoformat for calendar validity.
- Calculate the day difference using date subtraction.
- Test a leap-day acceptance and non-leap rejection.
Hint: where to begin
String structure and real calendar validity are different checks. The year boundary does not need a manually special-cased day calculation.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| 2026-12-31 to 2027-01-01 | One day |
| 2024-02-29 | Accepted |
| 2025-02-29 | ValueError |
| 20260101 | Format rejection under the exact dashed contract |
Reveal one possible solution and explanation
from datetime import date
import re
def parse_day(text):
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", text):
raise ValueError("Use YYYY-MM-DD")
return date.fromisoformat(text)
start = parse_day("2026-12-31")
end = parse_day("2027-01-01")
print("Elapsed days:", (end - start).days)
print("Leap date:", parse_day("2024-02-29"))Calendar objects handle different month lengths and year boundaries. The parser’s exact-format requirement is stricter than all forms the standard ISO parser supports.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Test a 30-day month’s final valid date and the just-outside day.
09 | UNIT TESTS
WRITE A BOUNDARY TEST SUITE
Build a unittest suite for a count parser. Demonstrate that a zero-rejecting bug fails a meaningful test.
YOUR REQUIREMENTS
- Use a TestCase subclass.
- Check accepted values 0, 1, 9 and 10.
- Check rejected -1 and 11 plus malformed text.
- Use subTest to label related inputs.
- State the expected result independently.
Hint: where to begin
The inclusive lower boundary is zero. A test set containing only ordinary positive values will miss a > 0 bug.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Correct implementation | Both test methods pass |
| Change lower test to 0 < value | Zero case fails |
| Unsuitable text | Expected ValueError, not a successful conversion |
Reveal one possible solution and explanation
import unittest
def parse_count(text):
value = int(text)
if not 0 <= value <= 10:
raise ValueError("Use 0 to 10")
return value
class CountTests(unittest.TestCase):
def test_accepted(self):
for value in [0, 1, 9, 10]:
with self.subTest(value=value):
self.assertEqual(parse_count(str(value)), value)
def test_rejected(self):
for text in ["-1", "11", "ten", ""]:
with self.subTest(text=text):
with self.assertRaises(ValueError):
parse_count(text)
if __name__ == "__main__":
unittest.main()Tests distinguish the endpoints rather than mirroring an incorrect condition. subTest labels failures for a particular input. A passing suite covers these stated cases, not every possible application requirement.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add a pure Boolean predicate and test that it does not mutate its input.
10 | INTEGRATION TEST
ISOLATE A FILE ROUND TRIP
Use a temporary directory to check that JSON records can be written and reloaded without touching a real save.
YOUR REQUIREMENTS
- Create temporary resources in setUp.
- Register cleanup.
- Save Unicode-containing data.
- Read a fresh object from disk.
- Check empty-data round trip separately.
Hint: where to begin
A real reload calls json.load on a newly opened file. Comparing a list with itself is not persistence evidence.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Unicode record | Exact note and integer restored |
| Empty list | [] restored |
| Test resources | Isolated directory cleaned after each method |
Reveal one possible solution and explanation
from pathlib import Path
from tempfile import TemporaryDirectory
import json
import unittest
class JsonTests(unittest.TestCase):
def setUp(self):
self.temporary = TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.path = Path(self.temporary.name) / "practice.json"
def round_trip(self, expected):
with self.path.open("w", encoding="utf-8") as file:
json.dump(expected, file, ensure_ascii=False)
with self.path.open(encoding="utf-8") as file:
actual = json.load(file)
self.assertEqual(actual, expected)
def test_unicode_record(self):
self.round_trip([{"note": "café", "amount_cents": 250}])
def test_empty(self):
self.round_trip([])
if __name__ == "__main__":
unittest.main()Each test gets fresh resources and reads from disk. The round-trip helper is not discovered as a test because its name does not start test_.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add a malformed-JSON rejection test and verify that reading does not alter the original bytes.
11 | CAPSTONE
BUILD AND REOPEN THE EXPENSE TRACKER
Create the project modules or use the download after comparing your own design. Run its tests and perform a real saved-data workflow.
YOUR REQUIREMENTS
- Keep model.py, storage.py, tracker.py and test_tracker.py together.
- Run tracker.py.
- Add 0.10 and 0.20 CHF with valid dates and categories.
- Quit, reopen and confirm total 0.30.
- Reject a third decimal without saving.
- Export a comma-containing note.
- Run the bundled suite in temporary directories.
Hint: where to begin
The sample below is an independent check file to save beside the project modules. It uses a temporary folder, not the application’s real save.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Save and reload | Two records; CHF 0.30 |
| Amount 1.234 | Rejected before candidate save |
| Corrupted existing JSON | Startup stops; no replacement with empty records |
| Month with no records | CHF 0.00 |
Reveal one possible solution and explanation
from pathlib import Path
from tempfile import TemporaryDirectory
from model import build_expense, total_cents, money
from storage import save_expenses, load_expenses
rows = [build_expense("2026-10-01", "food", "0.10", "A"),
build_expense("2026-10-02", "travel", "0.20", "B")]
with TemporaryDirectory() as folder:
path = Path(folder) / "expenses.json"
save_expenses(path, rows)
restored = load_expenses(path)
assert restored == rows
assert total_cents(restored) == 30
print("Reloaded total:", money(total_cents(restored)))The check tests connected validation, storage and calculation. The manual quit-and-reopen workflow also verifies the application entry point and actual chosen save location.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Record the behaviour for failed access and a CSV replacement cancellation.
12 | REFLECT AND EXTEND
EXTEND CATEGORY TOTALS WITH TESTS
Add a pure category-total helper, test it, then connect it to a menu command without changing saved records.
YOUR REQUIREMENTS
- Return a dictionary of totals.
- Do not mutate supplied expense records.
- Test empty input, repeated categories and separate categories.
- Keep integer cents throughout.
- Rerun the existing tracker suite after the feature.
Hint: where to begin
get(category, 0) gives a starting subtotal for a new category. Add each amount once.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Empty input | {} |
| Repeated food values 10 and 20 | food: 30 |
| Separate travel 50 | travel: 50 |
| Input after helper | Unchanged |
Reveal one possible solution and explanation
import unittest
def category_totals(expenses):
totals = {}
for row in expenses:
category = row["category"]
totals[category] = totals.get(category, 0) + row["amount_cents"]
return totals
class CategoryTests(unittest.TestCase):
def test_empty(self):
self.assertEqual(category_totals([]), {})
def test_groups_without_mutation(self):
rows = [{"category": "food", "amount_cents": 10},
{"category": "food", "amount_cents": 20},
{"category": "travel", "amount_cents": 50}]
before = [row.copy() for row in rows]
self.assertEqual(category_totals(rows), {"food": 30, "travel": 50})
self.assertEqual(rows, before)
if __name__ == "__main__":
unittest.main()The helper creates a new output dictionary and leaves the supplied records untouched. The test uses a deliberately small calculation fixture; full expense schema validation belongs to the model boundary.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add a categories menu command and compare formatted totals with a manually calculated example.
KEEP A SMALL TEST RECORD
Record input, expected return or exception, expected state or file change, actual behaviour and conclusion. Test exact boundaries and one case for each distinct rejection rule.
For persistence, reopen a new file object or restart the application. Record the path used so a different working directory cannot be mistaken for lost data. For failures, check that existing files and accepted records remain unchanged where the contract promises it.
READY FOR THE NEXT SECTION?
You are ready to move on when you can choose file modes, parse structured data, validate values, handle expected failures, locate files clearly and write independent automated checks.
If one skill is uncertain, revisit its lesson and change a worked example before repeating the challenge. The Section 5 workbook is coming soon.
Next: classes, objects and constructors Python course navigation