Skip to content
lajohnstonPublic

About

Test runner for the Sega Master System, for use with WLA DX

Resources

Stars

4 stars

Watchers

1 watching

Forks

Latest commit

 

History

271 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Zest - Sega Master System/Z80 Test Runner

Zest is a unit test runner for use with the Sega Master System and WLA DX assembler (v10.6+). Utilising the WLA DX macro features, it provides a high-level syntax so you can easily feed routines with fixed inputs and assert their output.

describe "increment"

it "should increment the value in register A"
    ld a, 0
    call increment      ; call routine
    expect.a.toBe 1     ; assert the result

If the above test fails, the tests will stop running and the test description and failure will be printed on the screen.

Zest pass scenario Zest failure scenario

Pinpointing bugs in assembly code can require lots of manual effort to recreate certain scenarios and ensure everything is working, reducing your confidence for making changes and optimisations to your code.

Instead, with a test runner you can define the list of behaviours you intend a routine to have and ensure they pass, then feel confident to change and optimise the code while being informed if you accidentally break anything.

Contents

Quick start

The template directory contains a working starter project with bash and batch build scripts to build the ROM on Linux and Windows respectively. For the scripts to work please ensure wla-z80 and wlalink 10.6+ are in your system path or within the template directory.

Create a blank test suite asm file (i.e. suite.asm) that will serve as the entry point. In this file, use .include to pull in the Zest library and the code files you want to test.

; ...suite.asm

; Include Zest library
.incdir "../zest"           ; point to zest directory
    .include "zest.asm"     ; include the zest.asm library
.incdir "."                 ; return to current directory

; Include the code you want to test
.include "increment.asm"

; Append your test files to zest.suite
.section "suite" appendto zest.suite
    .include "increment.test.asm"
.ends

Within the test file (increment.test.asm), the 'describe' and 'it' macros let you define the unit being tested and what behaviours it should exhibit:

; ...increment.test.asm

describe "increment"

it "should increment the value in A"
    ld a, 0
    call increment
    expect.a.toBe 1

it "should not increment past 255"
    ld a, 255
    call increment
    expect.a.toBe 255

If a test fails, the message will be printed on the screen with details of the test and assertion that failed.

You can also use test as an alias for it if you prefer:

describe "math"

test "add"
    ...

test "subtract"
    ...

Examples

Examples are included in the repo (see /examples). These also double as E2E tests for Zest itself. You can build these in Linux/WSL using ./examples/build.sh, but you won't really need to unless your contributing to the repo. The ROMs will be placed in ./examples/dist which you can then run in an emulator or on an actual system. Most are set to pass, but there are a few that test failure scenarios like memory corruption.

Assertions

Zest comes with the following assertions out of the box.

Registers

Example usage:

  • expect.a.toBe 255
  • expect.b.toBe -128
  • expect.a.toBe 1 "Custom assertion failure message"
expect.a.toBe -128
expect.b.toBe 255
expect.c.toBe 255
expect.d.toBe 255
expect.e.toBe 255
expect.h.toBe 255
expect.l.toBe 255
expect.i.toBe 255
expect.ixh.toBe 255
expect.ixl.toBe 255
expect.iyh.toBe 255
expect.iyl.toBe 255

expect.bc.toBe -32768
expect.de.toBe someLabel
expect.hl.toBe 65535
expect.ix.toBe 65535
expect.iy.toBe 65535

Flags

expect.carry.toBe 0
expect.carry.toBe 1

expect.parityOverflow.toBe 0
expect.parityOverflow.toBe 1

expect.sign.toBe 0
expect.sign.toBe 1

expect.zeroFlag.toBe 0
expect.zeroFlag.toBe 1

Stack

Expect the stack to contain x additional items since the start of the test:

expect.stack.size.toBe 3    ; 3 values (16-bit words)

; With custom message
expect.stack.size.toBe 3 "Expected stack size to still be 3"

Expect a position in the stack to contain a given word value. You will need to use this before other assertions as those might affect the values in the stack.

expect.stack.toContain $1234    ; expect the most recent pushed word to be $1234
expect.stack.toContain $1234 1  ; expect the stack position before to contain $1234

; With custom assertion failure message
expect.stack.toContain $1234 0 "my custom message"

Data

Assert the data at the given address contains the given sequence of bytes.

expect.address.toContain $C000 $00          ; expect $C000 to contain $00
expect.address.toContain $C001 $01 $02 $03  ; expect sequence from $C001 to contain $01, $02, $03

Assert the address in HL points to the expected value

expect.hl.toPointToByte 100 "optional failure message"

; Assert pointer points to the given little-endian word value
expect.hl.toPointToWord $1234 "optional failure message"

Clobber detection

Use zest.initRegisters to initialise all the register with unique values. You can then assert that they all retain these values with expect.all.toBeUnclobbered:

it "should not clobber any registers"
    zest.initRegisters
    call myRoutine
    expect.all.toBeUnclobbered

Often a routine will intend to return values within certain registers, so opt these out of the check by using expect.all.toBeUnclobberedExcept and passing the register names:

zest.initRegisters
call myFunction
expect.all.toBeUnclobberedExcept "a" "b"

Acceptable register args are "a", "f", "b", "c", "d", "e", "h", "l", "ixh", "ixl", "iyh", "iyl", "i", "a'", "f'", "b'", "c'", "d'", "e'", "h'", "l'". Register pairs are also allowed: "af", "bc", "de", "hl", "af'", "bc'", "de'", "hl'"

Mock assertions

See Mocking/stubbing labels for more details about mocks.

expect.mock.toHaveBeenCalled myMock

You can use the zest.mock.getTimesCalled macro to load A with the number of times a given mock was called.

zest.mock.getTimesCalled myMock
expect.a.toBe 2

zest.fail

Use the zest.fail macro to fail a test with an optional message if the program reaches that point.

zest.fail ; fails with a default message
zest.fail "Did not expect this piece of code to run"

Mocking/stubbing labels

Sometimes when testing a routine it's necessary or preferable to mock/stub out some of the routines it calls. The mocked routine can assert it's been called with the expected register values, and you can craft it to have a fixed effect of your choosing (loading registers with fixed values, or setting values to RAM etc.).

To do this you first need to separate these labels and routines into separate files, and ensure these files aren't imported into your test suite. This would then free up these labels so you can define you own versions in the test suite. When the code runs it will then utilise your fake versions rather than the real ones.

Zest provides a 'Mock' API that lets you define fake routines in RAM so that you can define different behaviour for different test scenarios. In your test suite you can define your mocks in ramsections using appendto zest.mocks:

.ramsection "my mock instances" appendto zest.mocks
    ; `call someLabel` will call this
    someLabel instanceof zest.Mock
.ends

Mocks get reset at the start of each test with a default handler that will simply return (ret) to the caller. To override this in a given test, wrap some code in zest.mock.start and zest.mock.end:

; Define a custom handler for this mocked label
zest.mock.start someLabel
    ld a, 123
    ; ret is added automatically
zest.mock.end

ld a, 0
call someLabel      ; this will call our handler
expect.a.toBe 123   ; this will pass

With the above, when we call someLabel, we actually call the code defined between zest.mock.start and zest.mock.end. Another test can implement its own behaviour for this same label so you can therefore test different scenarios.

Mocking macros

The principals of mocking macros is the same in that you'd need to ensure you don't import the real macro and instead create a fake version with the same name. To plug this into the mock API you could just make the fake macro call one of the mocks:

.ramsection "my mock instances" appendto zest.mocks
    myMock instanceof zest.Mock
.ends

.macro "myMacro"
    call myMock
.endm

Mocking port reads

It isn't possible to mock the value returned from the in a, (port) instruction directly. Instead, define a macro that wraps this instruction, i.e:

.macro "readPort" args portNumber
    in a, (portNumber)
.endm

This can live in a separate file that the code will .include but the test suite won't. Instead, the test suite will include its own version with the same name and args and when the code runs it won't know the difference.

; Our fake version of the readPort macro
.macro "readPort" args portNumber
    zest.ports.loadA portNumber ; load A with a fake value (see below)
.endm

To set the value for each of the ports, using zest.ports.set:

; In our test
ld a, $ff
zest.ports.set $dc  ; store the fake value $ff for port $DC
zest.ports.set $dd  ; store the fake value $ff for port $DD

Mocking controller input

For code that relies on user input, you'll want to be able to mock out the controller ports to return set values such as up, button 1 etc. You could use mocks to mock out your user input handling routines completely, but if you'd rather include this input handling in the test coverage, Zest provides some macros to fake the raw input values at the port level.

The controller input is read from ports $DC and $DD, so you could mock these port values directly. For convenience, Zest also provides zest.mockController1 and zest.mockController2 helper macros to ensure the correct bits are set in the correct port.

zest.mockController1 zest.UP
zest.mockController2 zest.DOWN

Input button arguments you can pass to these macros are:

zest.UP
zest.DOWN
zest.LEFT
zest.RIGHT
zest.BUTTON_1
zest.BUTTON_2
zest.NO_INPUT

OR these together with | to simulate multiple buttons being pressed:

; Down and button 1 pressed
zest.mockController2 zest.DOWN|zest.BUTTON_1

These mock controller values are reset at the start of each test.

Mocking VDP status flags

For routines that require the VDP status flags, you can use zest.ports.set $bf directly, or use the zest.mockVdpStatusFlags helper macro passing one of the following constants (ORed together with | if you want multiple):

  • zest.VDP_NO_STATUS_FLAGS
  • zest.VDP_VBLANK_STATUS
  • zest.VDP_SPRITE_OVERFLOW_STATUS
  • zest.VDP_SPRITE_COLLISION_STATUS
zest.mockVdpStatusFlags zest.VDP_VBLANK_STATUS
zest.mockVdpStatusFlags zest.VDP_VBLANK_STATUS|zest.VDP_SPRITE_COLLISION_STATUS

Utils

Timeout detection

By default, Zest will timeout and fail a test if it takes more than 10 full frames/VBlanks to complete, in case the code has got itself into an infinite loop, forgotten to return, or jumped to an invalid location. It does this by decrementing a counter at each VBlank and timing out when it reaches zero. Note: this timeout detection relies on the code not disabling interrupts.

As the test won't necessarily begin at the start of a full frame, an additional frame is added to this number to represent the current partial frame.

The default timeout can be overridden for all tests by defining the zest.defaultTimeout value before importing Zest.

; Timeout tests if they take more than 1 full frame
.define zest.defaultTimeout 1

.incdir "../zest"
    .include "zest.asm"
.incdir "."

To override this default timeout for individual tests, use zest.setTimeout within the test. You could use this to increase the timeout for tests you expect to take a longer amount of time:

it "should not timeout"
    ; Only timeout this test if it takes more than 20 full frames
    zest.setTimeout 20
    call mySlowRoutine

zest.waitForVBlank

The interrupt handler used for timeout detection must reset the VDP status flags. If your code or test was in the middle of 2-byte VDP address/command set, this reset would cause it to go into an invalid state. You can enable the Break on invalid VDP access option in Emulicious to detect when this happens.

To prevent the issue you can use di before any code that is potentially affected, though this will opt-out of timeout and crash detection. Alternatively, use zest.waitForVBlank to wait until after the next interrupt, so long as the code is expected to complete before the interrupt after it.

zest.vblank.start

Sets a custom VBlank callback that will be called when the next VBlank occurs. This will be reset at the end of the test. This is mainly of use for routines that have a halt instruction and expect a side effect to have occurred by the time the interrupt returns.

test "my code"
    zest.vblank.start
        ; Custom routine that will run next VBlank
        ld a, 1
    zest.vblank.end

    ld a, 0
    halt    ; wait for the VBlank
    ; a is now 1

Memory overwrite detection

Zest will attempt to detect if its RAM state has been overwritten by a test, and if so will stop the program with an error message. A backup of the test description pointers is kept in VRAM (within the sprite attribute table gap) and Zest will attempt to restore these so it can recover and display the test that exhibits the issue.

Terminating tests manually

Timeout detection relies on interrupts being enabled so it can periodically interrupt the running code to check everything's ok. If all else fails and you just see a blank screen, press the pause button to terminate the current test and display the test description. The code may have disabled interrupts and is stuck with a halt instruction or an infinite loop.

zest.currentBlock, zest.currentTest

Variables will be set for each describe block and test that starts. These can be .printed within macros for debugging purposes.

.print "Current block ", zest.currentBlock
.print "Current test ", zest.currentTest

Hooks

You can append to the following sections to hook custom code into the test lifecycle:

zest.preSuite

Runs at the start of the suite.

.section "myPreSuiteHook" appendto zest.preSuite
    ; some code
.ends

zest.preTest

Runs at the start of each test.

.section "myPreTestHook" appendto zest.preTest
    ; some code
.ends

zest.postTest

Runs at the end of each test.

.section "myPostTestHook" appendto zest.postTest
    ; some code
.ends

Paging

The suite ROM bank is 16KB but if you use this all you can split your tests across multiple banks. To enable this, define a numeric zest.SUITE_BANKS value before importing the library. This defaults to 1.

.define zest.SUITE_BANKS 3  ; enable 3x16KB test suite banks

Append your tests to zest.suite (bank 1), zest.suiteBank2, zest.suiteBank3 etc.

.section "tests in bank 1" appendto zest.suite
    ...
.ends

.section "tests in bank 2" appendto zest.suiteBank2
    ...
.ends

.section "tests in bank 3" appendto zest.suiteBank3
    ...
.ends

zest.setBank

Any free sections you .include are placed in bank 0 by default and are accessible by all the tests. If this bank runs out of space you can use zest.setBank to place the code in the additional banks. You should ensure they are in the same bank as the tests that require them.

.include "a.asm"    ; bank 0 (default)- accessible by all tests

zest.setBank 1
.include "one.asm"  ; bank 1 - accessible by tests in zest.suite

zest.setBank 2
.include "two.asm"  ; bank 2 - accessible by tests in zest.suiteBank2

About

Test runner for the Sega Master System, for use with WLA DX

Resources

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages