Testing and Refactoring Systems
Learning Objectives
- You can test state transitions and independent instances.
- You can check that rejected operations leave existing state unchanged.
- You can run persistence tests using isolated temporary files.
- You can refactor in stages and distinguish tests of individual model operations from checks that several parts work together.
A method may work on its first call but fail after several operations. Saving and loading may preserve a total while losing individual records. Moving code into modules may leave the calculations working but accidentally start the interface during import.
Test the changing values, the saved records, and the way the interface uses returned results.
Check a Sequence of State Changes
The example uses the availability-only LoanItem from the stepper in Methods and Valid State. Run it, then inspect how the checks follow a sequence of calls. A successful run prints Checks passed..
Add a second return attempt and check both its result and the availability. Keep these expectations as the application grows.
Those expectations come from the contract. Moving a method into another file should not require changing them just to match the new implementation.
Check Separate Instances
This deliberately flawed class stores its list at class level:
Both lines print the note even though we added it through first. Every instance using that attribute reaches the same list. Move self.notes = [] into an __init__ method and remove the class-level notes = []. Predict the second line before running the repaired version. A test using one instance may never reveal the problem.
Class attributes have legitimate uses. For a collection that should belong to one instance, use the per-instance initialization introduced in Defining Classes and Creating Objects. Trace the class-attribute example below, then investigate the notebook starter.
Now repair the notebook so each instance keeps its own notes and changing a returned list does not change the notebook. Check your repair using two instances.
Repair a note collection
0 / 15 points
Repair Notebook so every new instance starts with its own empty notes. add(text) receives a string, appends it unchanged, and returns None. all_notes() returns a new list containing the current notes in order, including repetitions.
Adding to one notebook must not affect another. Changing a returned list must not change the notebook. Do not print or read input. Investigate both state changes and returned results in the starter.
Work in main.py. Open Show file tree, select check.py, and Run to try the supplied sample checks. Add cases for the remaining requirements, including boundary values and repeated operations where relevant. Keep test calls in check.py so importing main.py does not print or ask for input. Submit sends the files for grading against the full contract and returns feedback.
The repair exercise checks your implementation. Now write checks that can distinguish correct and faulty implementations of the same interface. Choose operations and expected results from the contract; inspecting a particular internal attribute would tie the checks to one implementation.
The next exercise passes a class to your checking function. Classes can be passed as arguments, just like the functions passed to sorted in Part 4. In check_notebook(notebook_type), call notebook_type() to create an instance of the supplied class. Call it again to create a second instance. The caller passes Notebook without parentheses; your function decides when to create its objects.
Check a note collection
0 / 25 points
Complete check_notebook(notebook_type) by adding assertions that check the supplied interface:
- a new notebook starts empty and is independent of other notebooks;
add(text)stores each string unchanged in order, including repetitions;all_notes()returns a separate list that callers can change without changing the notebook; later additions must not change an earlier result.
The parameter is a class. Create instances by calling notebook_type(), just as you would call Notebook(). Use that parameter so your checks can be run against different implementations of the same interface. Use only the public add(text) and all_notes() methods to inspect behavior.
Your checks must accept correct implementations and expose plausible mistakes in storage, independence, or snapshots. Do not print or use input. Keep the supplied program call so running main.py executes the checks. The supplied Notebook implementation is correct; your task is to test the contract, not alter that class.
Work in main.py. Run executes your checks against the supplied correct class; passing that run alone does not show whether your checks find mistakes. Submit also tries other implementations of the stated interface.
Check Rejection Without Partial Change
Use check.py in the preceding chapter’s storage exercise; it already imports EquipmentLibrary. Duplicate identifiers must not add another item:
library = EquipmentLibrary()library.add_item("E10", "Camera", "media")before = library.to_records()
try: library.add_item(" E10 ", "Other camera", "media")except ValueError: rejected = Trueelse: rejected = False
assert rejected == Trueassert library.to_records() == beforeThe surrounding spaces check that identifiers are compared after trimming. The final assertion checks that the failed operation did not partially change state.
Similarly, an unsuccessful load should not replace the current library with empty or partly reconstructed data. Keep loading on the right side of an assignment and allow the caller to retain its existing state when that call raises.
Test Files in a Disposable Directory
The following round-trip check needs your completed load_library and save_library from the Keep a library file exercise in Objects and Persistent Data. Complete those two functions before running it. In that exercise’s check.py, temporarily replace the sample checks with the code below. Keep check.py, models.py, and storage.py together in your IDE. The Inline Editor can run the same check within one execution. The standard-library tempfile module can create a directory for a test and remove it afterward:
import osfrom tempfile import TemporaryDirectoryfrom models import EquipmentLibraryfrom storage import save_library, load_library
with TemporaryDirectory() as folder: path = os.path.join(folder, "library.json")
original = EquipmentLibrary() original.add_item("E10", "Camera, compact", "media") original.borrow_item("E10") save_library(path, original)
restored = load_library(path) assert restored.to_records() == original.to_records()
restored.return_item("E10") assert original.find("E10").available == FalseTemporaryDirectory provides the folder path inside the with block and removes the temporary files when the block ends. os.path.join builds a path from a folder and filename; it is a path operation, not the string .join() used to construct reports.
These tools keep test files away from a real saved catalogue. Their documentation can be consulted like the other library interfaces from Part 5.
Refactor One Boundary at a Time
Revisit the Move a class into a module exercise in Modules and Program Structure. Its StudySession(subject, minutes) class considers a session long when it lasts at least 60 minutes. Moving the class from main.py to models.py should preserve that rule and the application’s printed output.
After the move, rerun that exercise’s check.py. Its boundary checks test these expectations through the imported class:
from models import StudySession
assert StudySession("History", 59).is_long() == Falseassert StudySession("Math", 60).is_long() == TrueThe checks use the same class through its new module. The expected results still come from the original rule: 59 minutes is below the boundary, and exactly 60 minutes meets it. Changing the comparison while moving the class would change behavior as well as structure.
The same approach applies when a one-file application mixes prompts, model operations, and JSON code. Preserve representative behavior with checks. Move the model definitions to models.py and rerun model tests. Move persistence to storage.py and rerun file tests. Finally, leave coordination in app.py and run complete interaction checks.
Moving code and changing its behavior simultaneously makes a failure harder to interpret. Keep the intended input, output, and error policy stable during a structural refactoring.
Model Tests Do Not Test the Whole User Interface
The model can correctly reject a second borrow while the user interface still prints Item borrowed. unconditionally. A separate interaction check is needed to verify that the user interface uses returned values correctly.
Likewise, helper tests may pass while an unguarded entry point starts a menu during import. Include an import check and at least one complete run with successful and rejected operations.
Use the following review to choose checks for object state, files, and the complete user interface.
Check Your Understanding
- Why should a method be tested across several calls, not only once?
- What can a two-instance test reveal that a one-instance test misses?
- Why compare state before and after a rejected operation?
- What does a temporary directory protect during file tests?
- Which failures can remain even when every calculation test passes?