Jest, nUnit and other unit test frameworks are an essential part of the modern arsenal of software engineering tools. So when I recently started a retro-computing project, which involves writing Z80 assembly code for the ZX Spectrum, an unfamiliar environment, where the smallest mistake can cause a crash, I did the obvious thing, and set up unit testing for the assembly code… using TypeScript.
The previous blog post (Part I) covered my chosen build pipeline for this modern old-fashioned ZX Spectrum ROM development. This post delves into how I implemented a modern unit-testing approach for code on 40-year-old machine, using Deno, TypeScript and WASM.
![[Code listing showing some Z80 unit tests… passing.]](https://dysphoria.net/wp-content/blogs.dir/1/files/2025/05/Screenshot-2025-05-17-at-16.19.58.png)
TL;DR
It is possible to use the core emulation engine from a Spectrum emulator, running ‘headless’ (without its UI), to run segments of target Z80 machine code in an isolated, deterministic, and reproducible environment.
If we select an emulator written in web technologies, it is possible to call from a standard TypeScript or JavaScript unit test framework. And it turns out that Deno’s built-in standard test framework is a very low-friction testing framework to call from TypeScript.
This is my story…
Problem statement
- My Z80 skilz are rusty. I did once used to program in Z80 assembly (and probably hand-assembled my programs into hex). But I’ve become soft and too used to modern ways. Therefore…
- I need unit testing—and modern build tools
- I’d rather write the tests in a high-level, dynamic-ish language, please, not assembly.
- I’m developing ROM code, so there may be other limitations on how my code-under-test can run (Is it even possible to unit-test segments of ROM code without compromising its design? I didn’t know.)
- I didn’t want to lock myself into a particular IDE, or an obscure technology set; I wanted as ‘open’ a solution as possible.
Having identified 1) an IDE with syntax highlighting support for Z80 code; 2) an assembler; 3) a Spectrum emulator; 4) a build tool, I was left with trying to find 5) some way to do the unit testing.
Unit testing assembly code with TypeScript
The thesis
I postulated that an emulator written in web technologies could be adapted to running unit tests from Jest, (or another familiar JS unit test framework).
There are a number of online, web-based ZX Spectrum emulators, but the most mature, supported, and updated one appeared to be JSSpeccy 3. The core emulation engine of the JSSpeccy emulator is written in WebAssembly (WASM), loosely coupled to the IO code, (which is written in JavaScript), so the core emulator should be straightforward to isolate, and to call from a standard TypeScript or JavaScript unit test framework.
What does it look like?
How do we get from typing ‘deno task test
’ to actually executing emulated Z80 code?
Here’s one of my unit tests:
describe("Keyboard scan", () => {
it("Given no keys pressed, returns all zeros", async () => {
const vm = await loadedVm
// Given no keys pressed
vm.callSubroutine(rom.KEY_SCAN)
const r = keyScanResults(vm)
expect(r).toEqual({keyCodes: NO_KEYS, shiftState: 0})
})
It’s written in the usual Given–When–Then style (or Arrange, Act, Assert, if you must). It
- Sets up the virtual machine
- Calls the (Z80) code-under-test, then
- Checks the results.
Since the tests are written in TypeScript, we can add some helpful layers of abstraction to make writing tests easier, and to make the tests themselves much easier to read. For example, the function called in the final section of the test, keyScanResults()
, looks like this:
function keyScanResults(vm: Vm) {
const r = vm.getRegisters()
return {
keyCodes: {
primary: r.E,
secondary: r.D,
},
shiftState: r.B
}
}
All this function does is to extract the results of the Z80 subroutine (which are returned in registers) into a JS structure with understandable names, but that makes the tests very much easier to write and to read.
Using JSSpeccy 3’s emulator to run tests
Key to the lightweight tests, with minimal boilerplate, is the Vm
class, which you’ve seen in the example test above.
const vm = await loadedVm
vm.callSubroutine(...)
vm.getRegisters()
This is a wrapper around JSSpeccy’s WASM emulation core. It’s a pretty thin layer, but it does successfully hide some of the implementation details, such as:
- Mapping to the emulator’s API. WASM programs are compiled to a flat memory model, so all of the emulator state, like Z80 register contents, and Spectrum memory contents, are mapped into particular locations in that flat memory. The wrapper takes care of mapping, (for example), Z80 CPU register names, to the appropriate bytes in emulator’s memory.
- Paged device memory. To accommodate different Spectrum models, the emulator maintains a virtual 128K memory, which is paged into the 48K address space. The memory access methods in
Vm
take care of this memory page mapping so the tests can ignore it. - Different models of Spectrum. I only care about the 48K model for my project.
Because the Spectrum hardware itself is so simple, the API of the emulator wrapper layer is simple too:
loadVm() — function | Loads the WASM emulator core from the filesystem, and sets up an instance of the Vm class, in my case loading into it the custom ROM that I’m developing. |
getRegisters() : RegisterSet setRegisters(Partial<RegisterSet>) | Gets all the CPU register values, or sets any number of them. Allows addressing individual 8-bit registers (like A and IXh ) as well as register pairs (like BC and AF ). |
getRam(position, length): ArrayLike<byte> setRam(position, bytesOrString) | Allows setting or retrieving a chunk of Spectrum RAM, from or to a JavaScript array. |
peekByte() / peekWord() pokeByte() / pokeWord() | Read and write individual bytes and 16-bit words of memory. |
runPcAt(address, tStates) | Runs the virtual Z80 processor, starting from PC = ‘address’, for ‘tStates’ processor cycles, or until it hits a HALT instruction. |
callSubroutine(address, tStates) | Similar… except that it first sets up the stack, creates a small bit of boilerplate Z80 code which CALL s the given routine, then HALT s. |
getStack() | Retrieves the entire contents of the Z80 stack from memory, as JavaScript array of 16-bit words. (Assumes that the top of the stack is 0xF000 , which is a convention that callSubroutine() uses too.) |
keyDown(rowNumber, keyMask) keysDown({rowNumber, keyMask}[]) | Sets the input keyboard state. When/if I ever get to testing other peripherals, like the tape input, I’ll add some APIs for them. |
Structure of a test
Almost every test boils down to:
await loadedVm
- Set up the memory or registers (or input devices) for the test input
- Call the code-under-test, (usually
vm.callSubroutine()
) - Check the results, in memory, registers, or the stack
Because everything runs within the JavaScript/WASM virtual machine, and emulating a Z80 is pretty cheap, tests run very quickly.
Currently my project builds and runs all its tests in a few seconds.
Assembly language symbols
There’s one more bit of abstraction here, which makes a huge difference to how easy it is to write and read these tests:
If every test had to call subroutines with absolute numeric addresses— callSubroutine(0x23AF)
—that would create a significant barrier to writing and maintaining tests: We rarely know the exact address at which the assembler will assemble each label. Modifying the code of previous subroutines usually changes the address of subsequent routines.
Ideally we’d like to refer to routines (and memory locations generally) by their assembly labels, not numeric addresses. So how to do that?
symbols.txt → symbols.ts
My chosen Z80 assembler, SjASMPlus (and probably other assemblers), can optionally output a symbol file as part of its output. That output looks, in part, like this:
KEY_RPT_NEXT: EQU 0x00005B0D
KEY_RPT_CODE: EQU 0x00005B0C
KEY_SUPERSEDED: EQU 0x00005B0B
END_NEED_INIT: EQU 0x00005B0B
GLOBALS: EQU 0x00005B00
CAPS_SHIFT_ANIM.off: EQU 0x00001562
CAPS_SHIFT_ANIM.draw: EQU 0x00001567
CAPS_SHIFT_ANIM.on: EQU 0x00001558
CURSOR_XOR.loop1: EQU 0x0000153D
...
The output is very regular (easy to parse), though notice that some symbols are hierarchical: ‘local’ labels may be nested within ‘global’ labels. For example, the global label CAPS_SHIFT_ANIM
has three local labels, .off
, .draw
and .on
nested under it.
I created a transformation script, symbolstojs
, which runs after assembly, but before running tests, which transforms this text file into a TypeScript file, with all of the labels (including nested ones) represented as a single, large TypeScript structure:
// ASM ENTRY POINTS
// AUTOGENERATED
export const rom = {
CAPS_ICON_XY: {
addr: 0x5B0F,
},
CAPS_SHIFT_ANIM: {
addr: 0x1547,
draw: {
addr: 0x1567,
},
off: {
addr: 0x1562,
},
on: {
addr: 0x1558,
},
...
Tests just need to import this structure (‘rom
’) from the generated TS file, and refer to labels as, for example, ‘rom.CAPS_SHIFT_ANIM.draw
’. When that label moves, because some other code has changed, the tests continue to work.
It also adds a level of ‘typing’ between the tests and the Z80 code: If a label is removed, or renamed, the test will break, until the test is modified to match.
The ZX Spectrum screen
I want to just briefly mention graphics. The ZX Spectrum has a relatively simple, memory-mapped screen. You can inspect the screen memory by examining the RAM between 0x4000
and 0x57ff
inclusive. However, that’s not a very easy way of checking that your graphics routines are working.
It would be easier to test graphics routines by exporting the Spectrum screen area as an image, and comparing it to the expected output, represented as a PNG or GIF image on disk.
I’ve created a small TypeScript module which does this: screen.ts
.
It uses the Vm
class to access memory, and the pureimage
NPM module to load and save and convert images.
Why JSSpeccy3’s design works so well
For our purposes, JSSpeccy3 consists of three layers:
- JavaScript User Interface (UI). It’s minimal, but the emulator implements an HTML/JS UI to choose the emulated machine model, and select TZX game files.
- JavaScript wrapper code. When JSSpeccy is running as an in-browser emulator, the visible screen canvas, audio output and PC keyboard mapping are all handled by JavaScript wrappers. And in fact, even real-world timing (synching the emulated machine with a nominal 50 Hz frame rate) is handled by JavaScript.
- WebAssembly (WASM) Spectrum emulation engine (The Core). The WASM core is responsible for emulating the Z80, CPU clock, ULA, memory and audio/tape interface—and has zero IO dependencies.
This isolated design makes the WASM core particularly well-suited to our purposes. It lets us run our Z80 code-under-test, in a perfectly controlled environment.
I’ve managed to use the JSSpeccy WASM core as-is, for testing, without any modifications. It’s a perfectly reusable component.
Conclusions & future work
Summary
I have a Z80 assembly development toolchain running with modern build tools: Deno and the webdev ecosystem, which works very well:
The unit test framework does not impose any particular constraints on how the code is written, and makes automated tests fast and convenient to write, and to run.
Possible future work
- It would be relatively easy to extract the testing layers—the
Vm
class, and screen graphics comparison utilities—and publish them as NPM packages. I’ll happily do that if there’s demand for it! - The
Vm
layer could be expanded to support other ZX Spectrum peripherals, like audio output, joystick input, (…and possibly Microdrives, the ZX Printer…) - The overall approach, of using TypeScript tests and a Wasm emulator, as a testing environment for low-level code, is very amenable to other target technologies; other legacy processors; even other legacy code.
The end
Next time I hope to report on some of the lessons learned from recreating an 8-bit microcomputer ROM from scratch!
Thanks for reading. Please let me know if you make use of any of these ideas in your own retro projects.
Pingback: Setting Up a Modern ZX Spectrum Toolchain [part 1 of 2] | Andrew’s Mental Dribbling!