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

Check predictions against actual results
Test input or actionExpected result
Initial runTwo lines: First idea and Second idea
ReopenExact text includes both newline characters
Repeat whole scriptTwo 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Repaired runRun one, then Run two
Original second wOnly Run two remains
Remove first newlineEntries 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
NoteLunch, "small" drink preserved as one field
Amount typestr
Field countThree 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
List with score 8Accepted record list
{}ValueError: wrong root
Score "8" or trueValueError: unsuitable type
Malformed JSONJSONDecodeError 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
bad; 21Rejected for separate rules
0; 20Accepted endpoints
-1Range 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
11Rejected; scores remain [8]
0Accepted; scores become [8, 0]
Malformed textRejected 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Path propertiesnotes.txt, notes, .txt
Read textcafé
with_suffixProposed 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
2026-12-31 to 2027-01-01One day
2024-02-29Accepted
2025-02-29ValueError
20260101Format 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Correct implementationBoth test methods pass
Change lower test to 0 < valueZero case fails
Unsuitable textExpected 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Unicode recordExact note and integer restored
Empty list[] restored
Test resourcesIsolated 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Save and reloadTwo records; CHF 0.30
Amount 1.234Rejected before candidate save
Corrupted existing JSONStartup stops; no replacement with empty records
Month with no recordsCHF 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Empty input{}
Repeated food values 10 and 20food: 30
Separate travel 50travel: 50
Input after helperUnchanged
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.

Revisit the related lesson

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