Testing Is a Role, Not a Person

I was going to call this post “Interrogating My Own Code” but that was a bit opaque, even though it’s entirely accurate. I was then going to title it “When the Developer Is the Tester” because that’s part of what I’m talking about. But I ended up on what I believe is the more salient point.

This idea of the role, not a person is something I referenced a bit when I talked about mass extinctions in relation to testing. Trust me: it made sense at the time!

Here I want to dig into what it looks like when a developer tests. Note: not when a developer is a tester or when a tester is a developer. What I’m going to show you is code I wrote. There were times I was acting as a developer and times I was acting as a tester. I was never doing both simultaneously. Granted, the interleaving may have been quick in some cases, shifting back and forth rapidly, but the roles were always distinct.

It’s just like how you can’t truly multi-task. We aren’t wired for that. But we can context switch. Sometimes very rapidly.

This post is about code but you don’t have to be a coder to get value from this. At least I don’t think so. But I do think testers should have the ability to look at code and reason about it. At a bare minimum, that helps with automation. But beyond that, the much more important part is you start to learn the language of developers and the integration seams they deal with. You also start to build up a theory of error, understanding why we make mistakes when we build complex things. And you learn tests can put pressure on design.

Setting the Context

Some context before the code. The project this comes from is a Z-Machine interpreter written in Python. (It’s my Voxam project, if you’re curious.) The Z-Machine is the virtual machine Infocom built in 1979 to run its text adventures (Zork and its kin), and a “story file” is a compiled game for that machine. The first 64 bytes of every story file are a header: fixed offsets holding the version, release number, serial code, a checksum, and the addresses of structures the interpreter needs. The § citations you’ll see in the code refer to the Z-Machine Standard, the community specification this project treats as its requirements document. In what follows, Story is my class for a loaded file and Header is a typed view over those first 64 bytes.

Examining the Code

First, here’s some production code called story.py. To keep things simple, in all the listings that follow I’ve removed imports just to keep this relatively concise. Anything that looks undefined — HEADER_SIZE, the offset constants, the two error types — comes from the header module or a small shared errors module. I have kept all the comments because they can help situate the code.

Here’s another bit of production code called header.py.

Observing The Code

It may look like a lot but it’s relatively simple code when all is said and done. Now, I would ask you, as a curious tester, to take a look at the code and just reason about it a bit. I want you to see if you can spot one of the potential enemies of quality.

Go ahead. Take a look before reading on. I’ll wait.

I hasten to add: this requires nothing beyond the Mark 1 eyeball. Even with no coding skills whatsoever or never having seen Python in your life, that does not stop the ability to discern what I’m talking about here.

If you’re not sure what I’m talking about, the primary enemy of quality is opacity. And this shows up in various ways: ambiguity, inconsistency, and contradiction. One element that happens in specs or code is an aspect of those: duplication. With that hint provided, take a look again.

Finding anything?

There are two bits of duplication here worth spotting. That’s pretty much the final hint. Before you move on, make sure you see them.

Ready?

First Issue

Okay, so, before we even get to the test for this code, let’s talk about those duplications. As a tester, you should be able to note that the __post_init__ in header.py has a check that matches one of the conditions in the __post_init of story.py. Is that deliberate?

Yes. Deliberate. But the instinct to interrogate it is correct. Now let me give you the actual reasoning, as I see it, and then where I would (and did) draw the line.

The two checks share a predicate (len(data) >= 64) but they’re guarding different things, and that difference shows up in everything around them.

  • Story’s check answers “can this content be a story file at all?” That’s an admissibility judgment about a file, raising ZMachineStoryError, cited to the file-level rule (§1.1.1 in the spec).
  • Header’s check answers “can this view safely read its fields?”. That’s a precondition of a class, raising ZMachineHeaderError. Without it, code like Header(b"\x03") doesn’t crash. Python slicing is forgiving, so calling the _word() method on truncated bytes quietly returns garbage (an empty slice becomes 0). A silently-wrong header is far worse than a loud one.

What matters here is the design principle underneath: each frozen dataclass defends its own invariant at construction, without assuming who constructed it. Header is public and independently constructible; the tests I’m going to show you soon build it directly from tampered bytes, bypassing Story entirely. If Header relies on “someone upstream already checked,” the check is only as good as every current and future call site. That’s the make-invalid-states-unrepresentable principle, applied per class. It’s also redundancy in the defense-in-depth sense, not the copy-paste (duplication) sense.

That distinction, shown with one of the simplest examples possible, is what it means to be a developer thinking like a tester at a particular moment in time, whether that’s before writing the code, while writing the code, or after writing the code..

I call that out because one part of the role of a tester is to show, not just tell. And I see a lot of sentiment out there that “developers should test better.” Well, if you think that, you should be able to show what that means in their context.

I should also note that I’m dealing with internal qualities here. Those may bubble up to external qualities.

I talk about these quality distinction when I talk about my role as a quality and test specialist and I provide an example (in a gaming context) when I talk about navigating qualities.

That said, if you did find the above code (and test!) smells, I do want to affirm that what you detected is real, and here’s the honest part: this duplication is a symptom of both classes reading raw bytes directly.

As a developer, I’m thinking that ends naturally in the next branch of work which would be implementing a memory model. Once a Memory class owns the bytes and provides bounds-checked reads, both Story and Header read through it, and “are there enough bytes?” gets exactly one home. At that point Header’s check likely dissolves into Memory’s bounds checking rather than being deleted outright.

So, as an engineer acting in the developer role, I wrote the above code. As that same engineer, acting in the tester role looking at the code I wrote, my recommendation to myself was: keep both for this branch and let the memory branch absorb the duplication properly, rather than pre-solving it now by coupling Story to Header (which would mean Story raising header-flavored errors for what is really a truncated-file problem, or constructing throwaway objects in __post_init__).

Now, if I just couldn’t abide the thought of duplication even for a short duration, and if I were going to trim one of them, the one that must survive is Story’s. Without it, self.data[0] on an empty file raises a bare IndexError before any friendly error can fire. Dropping Header’s would also mean dropping a crucial test (which I’ll be showing you called test_rejects_content_too_short_for_a_header) and accepting that a directly-constructed short Header misreads silently. I wouldn’t make that trade, but it is a coherent position if you document “construct via Story.header” as the contract.

Second Issue

Okay, so that’s one duplication. I mentioned there were two. Hopefully you spotted the second and now, given the above narrative, let’s ask: Does that same logic apply to the version property which, for now, exists duplicated on both story and header?

Answer: No! And the distinction between the two cases is worth making crisp, because it’s the difference between duplication that’s doing work and duplication that isn’t.

The length checks we dealt with above are two different assertions that happen to share a predicate: different questions (“admissible file?” vs. “safe view?”), different exceptions, different failure consequences. Deleting either one loses something real. By contrast, version is one fact with two copies. Both read data[0], both mean the same thing, both cite §11.1, and neither adds any protection the other lacks. There’s no defense-in-depth story here. If the two ever disagreed it could only be a bug in one of them, which is exactly the situation duplication creates and delegation makes impossible.

So, acting as the tester to my developer, my recommendation: dedupe this one. The authoritative home is Header and that’s because the version number is a header field; that’s its spec-given address. Story.version is still worth keeping as ergonomic surface, but as a delegation:

One definition of the read, one place a future fix would go. The Header construction per access is a frozen dataclass wrapping the same bytes reference: effectively free.

And that is, in fact, the change I made. The story.py listing earlier shows the pre-change state deliberately, so that you could spot the duplication yourself before I resolved it.

One boundary note so the pattern doesn’t over-generalize: Story.__post_init__ should keep reading self.data[0] raw rather than going through self.header.version. Validation code runs before the object has earned its invariants, and routing it through Header would re-entangle the two classes at exactly the seam we just discussed keeping separate. Plus it works only while Story happens to check length first. Raw access during validation, delegated access after: that’s the general rule, and, as a developler, I konw thatwill come up again when Memory arrives.

A useful heuristic to carry forward: when you spot duplication, ask “if these two copies diverged, would that ever be correct?” If yes (the length checks; one could legitimately become stricter), they’re separate things. If no (version), they’re one thing written twice.

Testing the Code

Now let’s look at the tests. Here is test_story.py:

And here is test_header.py:

There’s lots here a tester might glom onto, but one thing that should stick out is:

As a tester that would immediately trigger my Spidey Sense. Why isn’t the release number uniform across eight files compiled from the same source? And why should anyone trust that table? Answering that requires a confession, because the story behind those two columns is the most tester-flavored part of this whole exercise.

The FIXTURES being referred to are eight tiny story files, one per Z-Machine version, all compiled from the same source. When I first added them, every one was named with r0 — release 0 — because that’s what I believed the release to be. Then, while writing these tests, I misremembered the value twice in a row (“they’re all 1” … “no, wait, all 0”) and finally did what I should have done first: looked at the actual bytes. The bytes said neither. They said 0, 0, 1, 1, 1, 1, 1, 1.

Before trusting that reading — because at this point my own header-reading code was as much a suspect as my memory — I cross-checked with a small, completely independent script I’ve had for a while that parses Z-code headers using none of this project’s code. It agreed: 0, 0, then 1 for the rest. It turns out the Inform compiler emits release 0 for its Version 1 and 2 targets and release 1 for Version 3 and later. My code was right. My test data’s names were wrong.

So the fix went into the fixtures, not the code: I renamed the files to tell the truth.

  • simple-test-r0-s260727.z1
  • simple-test-r0-s260727.z2
  • simple-test-r1-s260727.z3
  • simple-test-r1-s260727.z4
  • simple-test-r1-s260727.z5
  • simple-test-r1-s260727.z6
  • simple-test-r1-s260727.z7
  • simple-test-r1-s260727.z8

That rename is what makes the release assertion legitimate at all. The generalizable rule, which will recur every time I look at fixture tests: assert fixture values only when the assertion is anchored to something outside the file itself; in this case, the filename’s serial code, its extension’s version. Otherwise, test the behavior with synthetic bytes where you chose the expected value. Fixtures prove the interpreter handles reality; synthetic bytes prove it handles each field correctly.

But notice the trap I nearly walked into. Renaming files to match the bytes and then calling the names an “external anchor” would be circular reasoning! The anchor would just be the bytes wearing a costume. What breaks the circle is the independent cross-check: a second implementation, sharing no code with mine, agreeing on what the bytes say. Only then did the filenames become trustworthy anchors rather than echoes.

With truthful names, the release assertion becomes exactly what the serial assertion is: a fixture-identity check anchored outside the bytes. The explicit table, rather than parsing r0 and r1 back out of the filenames,- keeps the expected values visible in the test, and the comment above it records why they differ. The lesson I would want a tester to carry out of this: sometimes the bug is in the test data. Fixture metadata can lie just as easily as code can, and it deserves the same scrutiny.

Let’s also consider the checksum tests. Look back at the test listing and ask a tester’s question: which of those checksum tests is doing a job none of the others can?

At an earlier point in this work, every checksum test I had was relational: computed == stored for the fixtures, or != after tampering. Nothing pinned computed_checksum to an exact value that could be verified by hand. The fixture tests do have a real oracle hiding in them (Inform computed those stored checksums independently, so eight agreements is strong evidence) but no relational test would catch, say, a start-boundary error that happened to cancel out. A strict test fixes the expected value by arithmetic, not by the code’s own output.

That gap is why the test test_computes_checksum_by_the_spec_rule exists. It’s one test, but it pins four properties of the algorithm at once, each of which would independently break the exact value.

  • Start boundary: if summing began at $00 instead of $40, the version byte and the two planted header words would leak in (+290).
  • End boundary: the sum must stop at the declared length (576), not the actual file size.
  • Padding exclusion: the ten 0xAA bytes exist purely to be wrongly includable (+1700 if the code used len(data)).
  • Modulo wrap: 0x1FE00 exceeds a word, so a missing % 0x10000 produces 130560, not 65024.

That’s the anatomy of a strict test for any spec algorithm: choose inputs so that every plausible mistake perturbs the hand-computed answer. The relational tests I already have then do the complementary job, which is proving the algorithm agrees with reality (Inform’s stored values) rather than just with my arithmetic.

With this, the checksum behavior is fully specified by tests: exact value (this), real-world agreement (eight fixtures), corruption detection (the tamper test), and field read (the 0xBEEF synthetic).

Careful? Lucky? Or disciplined?

Notice what actually happened with those release numbers, though, before I move on. I didn’t catch my own mistake by being careful. I caught it by not trusting myself. By treating my own memory of “what the bytes probably say” as a claim that needed a second, independent witness before it could stand. That’s not a virtue I always have as a person. In what will come as a shock to no one, I misremember things and take shortcuts like anyone else. What I count on is the discipline that the tester role imposes on me from outside.

That’s the whole essay, really, compressed into one small embarrassing anecdote. The developer wrote code he believed was correct. The person who wrote it also believed the fixture names were correct, twice, in two different wrong directions. Neither of those beliefs was worth anything on its own. What broke the tie wasn’t more confidence; it was a second implementation that owed the first one nothing, and a role willing to ask “but how do you know?” even of its own author.

The checksum test does the same job at a smaller scale. It doesn’t exist because I’m thorough. It exists because, standing in the tester’s role, I asked the developer’s role a question he hadn’t thought to ask himself: “if you were wrong about the boundary, or the padding, or the modulo, would this test even notice?” The honest answer was no, not with what I had. So the role went and built a test that would notice: four ways a plausible mistake could hide, each one now with nowhere left to go.

None of this required two people. It required one person willing to keep switching hats, quickly enough and honestly enough that neither hat got to relax into assuming the other one already checked. That is the discipline. Not “developers should test better,” as an instruction handed down from outside, but a role, occupied deliberately, asked to interrogate a person who is, like many of us, a pretty unreliable narrator of his own work.

Share

This article was written by Jeff Nyman

Anything I put here is an approximation of the truth. You're getting a particular view of myself ... and it's the view I'm choosing to present to you. If you've never met me before in person, please realize I'm not the same in person as I am in writing. That's because I can only put part of myself down into words. If you have met me before in person then I'd ask you to consider that the view you've formed that way and the view you come to by reading what I say here may, in fact, both be true. I'd advise that you not automatically discard either viewpoint when they conflict or accept either as truth when they agree.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.