This is a follow-on to my previous post about what it looks like when a developer tests. Same project, next unit of work. So let’s dig in to some more code.

Last time the enemy of quality we interrogated was duplication and I left one thread deliberately hanging: two classes each checked that a file had at least 64 bytes, I argued the redundancy was doing real work, and I promised the question would come up again. It just did, and it resolved in a way I didn’t show you last time. This post is about that resolution, and then about a discipline I try to reinforce constantly: tests belong on the edges.
If you haven’t read the previous post, the short version of the context: I’m building an interpreter for the Z-Machine, the virtual machine Infocom built in 1979 to run its text adventures. A “story file” is a compiled game. The § citations refer to the Z-Machine Standard, which this project treats as its requirements document. The project is called Voxam.
The unit of work here is the Z-Machine’s memory map. The specification (§1.1) divides memory into three regions with different access rules. Dynamic memory, from address zero up to a boundary the header declares, can be read and written by a running game. Static memory, from that boundary up to either the last byte of the file or address $ffff — whichever comes first — can be read but never written. High memory can’t be directly accessed at all. Those rules are enforcement work, and enforcement work is boundary work.
Here’s the production code, a class called Memory. As before, I’ve removed imports, docstrings, and most comments to keep the listing readable. Anything that looks undefined comes from modules you saw last time.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
STATIC_MEMORY_CAP = 0xFFFF KIB = 1024 MAX_STORY_LENGTH = { 1: 128 * KIB, 2: 128 * KIB, 3: 128 * KIB, 4: 256 * KIB, 5: 256 * KIB, 6: 512 * KIB, 7: 512 * KIB, 8: 512 * KIB, } BYTE_MAX = 0xFF WORD_MAX = 0xFFFF class Memory: def __init__(self, story: Story) -> None: self._data = bytearray(story.data) self._static_base = story.header.static_memory_base self._high_base = story.header.high_memory_base maximum = MAX_STORY_LENGTH[story.version] if len(self._data) > maximum: msg = ( f"story file is {len(self._data)} bytes, but version " f"{story.version} allows at most {maximum} (§1.1.4)" ) raise ZMachineMemoryError(msg) if self._static_base < HEADER_SIZE: msg = ( f"static memory begins at ${self._static_base:04x}, which " f"would leave dynamic memory smaller than the " f"{HEADER_SIZE}-byte header (§1.1.1)" ) raise ZMachineMemoryError(msg) if self._static_base > len(self._data): msg = ( f"static memory begins at ${self._static_base:04x}, beyond " f"the end of the {len(self._data)}-byte file (§1.1)" ) raise ZMachineMemoryError(msg) if self._high_base < self._static_base: msg = ( f"high memory begins at ${self._high_base:04x}, inside " f"dynamic memory, which runs up to " f"${self._static_base:04x} (§1.1.3)" ) raise ZMachineMemoryError(msg) self._read_limit = min(len(self._data), STATIC_MEMORY_CAP + 1) def read_byte(self, address: int) -> int: self._require_readable(address) return self._data[address] def read_word(self, address: int) -> int: self._require_readable(address) self._require_readable(address + 1) return int.from_bytes(self._data[address : address + 2], "big") def write_byte(self, address: int, value: int) -> None: self._require_writable(address) self._require_byte(value) self._data[address] = value def write_word(self, address: int, value: int) -> None: self._require_writable(address) self._require_writable(address + 1) self._require_word(value) self._data[address : address + 2] = value.to_bytes(2, "big") def _require_readable(self, address: int) -> None: if not 0 <= address < self._read_limit: msg = ( f"cannot read ${address:04x}: game-readable memory runs " f"from $0000 up to ${self._read_limit - 1:04x} (§1.1.2)" ) raise ZMachineMemoryError(msg) def _require_writable(self, address: int) -> None: if not 0 <= address < self._static_base: msg = ( f"cannot write ${address:04x}: only dynamic memory, below " f"${self._static_base:04x}, is writable (§1.1.2)" ) raise ZMachineMemoryError(msg) def _require_byte(self, value: int) -> None: if not 0 <= value <= BYTE_MAX: msg = f"value {value} does not fit in a byte" raise ZMachineMemoryError(msg) def _require_word(self, value: int) -> None: if not 0 <= value <= WORD_MAX: msg = f"value {value} does not fit in a word" raise ZMachineMemoryError(msg) |
Before we get anywhere near the tests, I want you to notice one design decision. Look at the signature of __init__. What does it take?
Not bytes. A Story.
Last time, two classes — Story and Header — each accepted raw bytes, and each therefore had to check for itself that enough bytes existed. I argued that duplication was earning its keep: each class defended its own invariant because each could be constructed independently, from anything. But notice what that argument quietly implied: the duplication existed because both classes accepted raw, unproven input.
Memory doesn’t. It accepts a Story, and a Story cannot exist without having survived validation. That’s what its __post_init__ guarantees. The type of the parameter carries the proof. There is no third copy of the 64-byte check in this class because there is nothing left to check: you can’t hand Memory unvalidated bytes, not because of a runtime guard, but because of the shape of the code. The pattern has a name worth knowing: parse, don’t validate. Validate once, at the boundary where raw data enters, and from then on pass around types that could only exist by having passed.
That gives the previous post’s open question a real answer. Actually, not one answer, but a taxonomy of three. When two classes can each be constructed independently, from raw and unproven input, duplicate the check: each one is defending territory the other can’t see. When two checks turn out to be one fact wearing two names, delegate to a single authority: divergence there could only ever be a bug. And when you can arrange it (the best case, and the one this post is actually about!) design the duplication out of existence entirely, by making the type of the input itself the proof that validation already happened. Three answers to what looked, last time, like a single yes-or-no question: is this duplication deliberate?
What does this tell us? Duplicate the check when independent constructability demands it. Delegate to one authority when it’s one fact written twice. And, best of all when you can get it: design the duplication out of existence by letting types carry proof. As a tester reading code, all three are worth recognizing on sight, because each one tells you something different about what needs testing. That last one is the interesting case: there is no test to write for “Memory rejects short bytes,” because that situation is unrepresentable. The test surface shrank because the design got stronger.
Remember my oft-stated mantra: tests put pressure on design. The example I just gave is what I mean when I say tests put pressure on design. The pressure can work in reverse, too: a design choice can dissolve tests you’d otherwise owe.
Now the tests. There are more than I’ll show here (such as validation of each constructor rule, value-range checks, tests against real story files), but this cluster is the one I want to talk about:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
STATIC_BASE = 128 HIGH_BASE = 256 SIZE = 512 def memory_image( version: int = 3, static_base: int = STATIC_BASE, high_base: int = HIGH_BASE, size: int = SIZE, seed: dict[int, int] | None = None, ) -> Memory: data = bytearray(size) data[0] = version data[0x04:0x06] = high_base.to_bytes(2, "big") data[0x0E:0x10] = static_base.to_bytes(2, "big") for address, value in (seed or {}).items(): data[address] = value return Memory(Story(bytes(data))) def test_allows_write_to_the_last_dynamic_byte() -> None: memory = memory_image() memory.write_byte(STATIC_BASE - 1, 0x77) assert_that(memory.read_byte(STATIC_BASE - 1)).is_equal_to(0x77) def test_rejects_write_at_the_static_boundary() -> None: memory = memory_image() with pytest.raises(ZMachineMemoryError, match="only dynamic memory"): memory.write_byte(STATIC_BASE, 0x77) def test_rejects_word_write_straddling_the_boundary() -> None: memory = memory_image() with pytest.raises(ZMachineMemoryError, match="only dynamic memory"): memory.write_word(STATIC_BASE - 1, 0xBEEF) def test_rejects_read_beyond_the_file() -> None: memory = memory_image() assert_that(memory.read_byte(SIZE - 1)).is_equal_to(0) with pytest.raises(ZMachineMemoryError, match="game-readable memory"): memory.read_byte(SIZE) def test_rejects_word_read_straddling_the_file_end() -> None: memory = memory_image() with pytest.raises(ZMachineMemoryError, match="game-readable memory"): memory.read_word(SIZE - 1) def test_rejects_negative_address() -> None: # Python's own indexing would happily serve data[-1] as the last # byte of the file; the guard has to catch this before Python does. memory = memory_image() with pytest.raises(ZMachineMemoryError, match="cannot read"): memory.read_byte(-1) with pytest.raises(ZMachineMemoryError, match="cannot write"): memory.write_byte(-1, 0) def test_caps_static_memory_at_ffff() -> None: memory = memory_image(size=0x10800, seed={0xFFFF: 0x42}) assert_that(memory.read_byte(0xFFFF)).is_equal_to(0x42) with pytest.raises(ZMachineMemoryError, match="game-readable memory"): memory.read_byte(0x10000) |
Look at where these tests choose to stand. Not “somewhere in dynamic memory” and “somewhere in static memory.” They stand at exactly the last writable address, and exactly the first unwritable one. One address apart, opposite verdicts.
Testers have a name for this: boundary value analysis and domain testing. And it’s old enough that it sometimes gets dismissed as beginner material. It’s not beginner material. It’s a claim about where defects live. A guard like address < static_base has essentially one interesting way to be wrong: an off-by-one. Written as <=, every “somewhere in the middle” test still passes. The only tests that can convict it are the ones sitting directly on the edge. A test suite that probes regions but not their boundaries is checking the part of the guard that was never going to be wrong.
And notice the straddle tests, because they’re the boundary idea taken one step further. A word is two bytes. A word write at the last dynamic address has a legal first byte and an illegal second byte. Which rule wins? The spec’s answer is that no part of a write may touch static memory, so the whole write must refuse. That’s a case that doesn’t exist for single-byte operations at all. It emerges from the interaction of a two-byte operation with a one-byte boundary. When an operation has width, every boundary generates an extra family of cases: not just “before” and “after” but “astride.”
Then there’s the test with the comment in it, about negative addresses. This one is my favorite in the file, and it connects back to something from last time. In the previous post, the hazard was that Python slicing is forgiving: ask int.from_bytes for a word from an empty slice and you get 0, not a crash. Here the hazard is that Python indexing is forgiving: ask for data[-1] and Python cheerfully hands you the last byte of the file. No error. Nothing visibly wrong. A game that computed a negative address through some arithmetic bug would read memory from the wrong end of the file and keep running.
As a tester, I would encourage you to sit with that for a second, because it’s a theory-of-error insight that transfers to any codebase in any language: the conveniences of your implementation language are load-bearing walls for bugs. In C, the classic hazards are the things the language fails to check. In Python, the hazards invert: they’re the things the language checks and then helpfully “handles.” Negative indices, empty slices, implicit truthiness: each one is a place where wrong code doesn’t crash, and code that doesn’t crash doesn’t get noticed. When I build a theory of error for a codebase, “what does this language forgive?” is one of the first questions I ask. Or work with developers to ask. The guard clause 0 <= address exists purely to defeat a convenience, and the test exists to make sure nobody ever “simplifies” it away.
One more edge worth savoring: the $ffff cap test builds a memory image bigger than 64K purely so it can stand on both sides of a boundary that isn’t the end of the file. Address $ffff reads fine; address $10000 refuses, even though the file has plenty of bytes there. That test encodes a fact about the spec that the file itself contradicts: the file says “I have data here” and the spec says “you may not look.” If you only generated tests from the shape of the data, you would (likely) never write that test! It comes from the shape of the rules.
So, to gather the threads: the developer half of me built a wall with a gate in it. The tester half of me went to the wall and pushed on it exactly where the stones meet: the last legal address, the first illegal one, the operations that straddle the seam, the addresses the language would politely pretend are fine. And one level up from both of them, a design decision: accept proof, not bytes. Tha decision removed an entire category of pushing that would otherwise have been necessary.
Tests live on the edges. The middle takes care of itself.
As an addendum on my Voxam program, and a developer/tester mindset working on it, I’ll note that at the time I post this, there are 1,120 tests with full passing coverage. There are 29 full acceptance playthroughs of various games, all of which can be played back in their entirety. I also have a “regtest” mechanism for testing to match actual game transcript details. I bring this up because this leads to personal marketability but, more importantly, when someone asks “Does it make sense to have developers test?”, I can say yes and point to an example. And if (when?) someone asks “But, then, do we need a tester?” I can say yes and point to the same example.