Chip8 IDE with Compose - A TDD Assembler
I like Test-Driven Development (TDD) because it helps me overcome the hurdle of starting from no code when beginning a project and focus on breaking down problems and writing code. Reading Test Driven Development: By Example by Kent Beck was a turning point for me. I also practiced with the Test-First Challenge by Bill Wake. Between these two resources, I truly embraced the practice, and it has been immensely helpful. TDD encourages iterative, incremental steps to build a project and provides reassurance that refactors haven’t broken working code. TDD feels like a natural fit for writing my Chip8 IDE Nachos, at least for the compiler component.
I probably won’t be writing many tests for the UI, but I am following TDD for the compiler1. I’ve done this a couple of times before for my SuperFX and WLA language servers, and it is a great way to start. It makes me learn the languages I’m implementing at a deeper level, and the tests create APIs that are easy to use and extend by other tools. In fact, having my tests be the first “user” of my code can create really useful APIs, though the code will probably be badly encapsulated (that’s what refactoring is for).
I’m following the Octo Assembly Language manual and writing tests as it says things2. So far I’ve started the first paragraph:
Octo programs are a series of
tokens
separated bywhitespace
. Some tokens represent Chip-8instructions
and some tokens aredirectives
which instruct Octo to do some special action as the program is compiled. The:
directive, followed by a name (which cannot contain spaces) defines alabel
. A label represents amemory address
- a location in your program. You must define at least one label called main which serves as the entrypoint to your program.
These are a lot of things! We have whitespace separating tokens, directive tokens, labels, and memory addresses as well as a requirement for a main label. I wrote the following two tests which capture much of this.
1 @Test
2 fun programMustContainMain() {
3 // Initialize the Assembler
4 val assembler = Assembler()
5
6 //First, we verify that the absence of a main label causes the program to fail
7 val programWithoutMain = """
8 : pain
9 clear
10 jump pain
11 """.trimIndent()
12
13 var program = assembler.compile(programWithoutMain)
14
15 assertTrue(program.hasErrors(), "Program with out main should have errors")
16
17 //Next, we verify that the presence of a main label causes the program to pass.
18 val programWithMain = """
19 : main
20 clear
21 jump main
22 """.trimIndent()
23
24 var program = assembler.compile(programWithMain)
25 assertFalse(program.hasErrors(), "Program with main should not have errors")
26 }
When I wrote this test I just assumed I had things working the way I wanted. I want the Assembler to return a program, and the program should be an object that lets me inspect the state of the assembly. This is almost certainly not my final design; Assembler should probably be a functional object instead of an class, “compile” should probably be named “assemble”, and the program should be a linkable result object that is then compiled into a Chip8 binary. However, none of those things are my problem right now. Right now my problem is the tests don’t compile.
To make the tests compile I’ve created all of my classes and stubbed out methods.
1class Assembler {
2 fun compile(program: String): Program {
3 return Program()
4 }
5}
6
7class Program() {
8
9 val errors = mutableListOf<Error>()
10
11 fun addError(error: Error): Program{
12 errors.add(error)
13 return this
14 }
15
16 fun hasErrors(): Boolean {
17 return errors.isNotEmpty()
18 }
19
20}
21
22sealed interface Error {
23 object NoMain : Error
24}
The test compiles and runs!3 Unfortunately, the test also fails; how do I get it to pass4? It is completely valid in TDD to do the bare minimum to get it to pass as each test informs the next step in design. Starting with failing tests and incrementally adding just enough functionality to pass them keeps the development process focused and lean. Since I want to ensure there is a main
label, I could write the following:
1fun compile(program: String) : Program {
2 return Program().apply {
3 if (!program.contains(": main")) {
4 addError(NoMain)
5 }
6 }
7}
and the test would pass5. Instead, I’ve chosen to actually write a proper tokenizer (and tests for it) and leave this test failing for now. Failing tests serve as excellent markers to remember where you left off, especially for hobby projects. I know that when I come back I can just build the project and get a giant red label pointing me to where I wanted to start when I stopped working.
Speaking of leaving a hobby project in the middle of something…
Links
- Test Driven Development: By Example
- Test-First Challenge by Bill Wake
- Octo Assembly Language
- SuperFX language server
- WLA language server
- Nachos
I find TDD for UI tests frustrating to write because UI is so visual, and the testing frameworks feel very tightly coupled to the UI implementation that makes refactoring a lot of work. I do write UI tests, but they tend to be smoke style tests using something like Selenium. I’ve also TDD’d in weird places, like my SuperFX 3D engine. ↩︎
“Things” is the technical term. ↩︎
Quick send it to production! ↩︎
Ok, now can we send it to production? ↩︎