Testing functions
Finding out whether a function works usually means building the pack, opening a world, running /reload, setting up some scores by hand and watching chat. A Test does that in a fraction of a second, without the game: mms has an interpreter for the commands functions compile to, and a test runs your functions in it and checks what they did.
A first test
Function double(n: int): int {
return n * 2
}
Test doubling {
expect double(21) == 42
expect double(-1) == -2
}
mms test
ok: 1 test passed
A test of a function that returns a value needs nothing but expect lines. Tests are never written into the pack, so they can sit next to the code they test or in a file of their own, such as tests.mms.
Where tests run
mms testruns every test and exits with 1 when one fails.-run <regexp>keeps the tests whose names match, and-vlists passing tests too, with everything the functions said in chat, titles and the action bar.- The web editor shows a Tests tab once a project has a test, and runs them all again after every edit.
- VS Code lists them in its Testing view, and marks a failing
expectat its line.
All three use the same interpreter, so they agree.
Setup, run, expect
Most functions change state instead of returning something. A test of one has three parts:
Type SiegeState {
wave: int = 0
boss_alive: bool
}
Storage game: SiegeState
Function next_wave {
game.wave += 1
game.boss_alive = game.wave % 5 == 0
}
Test every_fifth_wave_has_a_boss {
setup {
game.wave = 4
}
run next_wave()
expect game.wave == 5
expect game.boss_alive
}
| Part | Does |
|---|---|
setup { } |
puts the world in the state the test needs. It is function code: assignments, commands, calls, let. |
run f() |
calls a function |
expect cond |
checks a condition, and stops the test when it is false |
Every test starts in a fresh world, right after the pack's load function, so Globals and Storage fields hold their defaults and no test sees what another one did. run and expect lines can alternate, which suits anything that happens over time:
Global timer: int = 2
Global rang: bool = false
Function tick {
timer -= 1
if (timer == 0) {
rang = true
}
}
Test rings_on_the_second_tick {
run tick()
expect !rang
run tick()
expect rang
}
Standing in for a player
There are no players in a test, so @a finds no one, and @p and @r are not supported. Most function code only needs something to run as, and a summoned entity will do. Give it a tag, name the selector, and run the function as it:
/** Mana each player has. */
Objective mana
/** Spells cast since the pack loaded. */
Global casts: int = 0
/** Takes the cost from whoever runs it. False when they can't pay. */
Function pay(cost: int): bool {
if (mana[@s] >= cost) {
mana[@s] -= cost
casts += 1
return true
}
return false
}
/** Stands in for a player. */
const caster = @e[tag=caster, limit=1]
Test paying_takes_mana {
setup {
/summon armor_stand 0 64 0 {Tags:["caster"]}
mana[caster] = 12
let paid = false
As(caster) {
paid = pay(5)
}
}
expect paid
expect mana[caster] == 7
expect casts == 1
}
Test the_poor_pay_nothing {
setup {
/summon armor_stand 0 64 0 {Tags:["caster"]}
mana[caster] = 3
let paid = true
As(caster) {
paid = pay(5)
}
}
expect !paid
expect mana[caster] == 3
}
A let in setup stays in scope for the expect lines, which is how paid gets out of the As block. Entities keep their type, tags, position and scores, so count(@e[type=zombie, tag=wave]) and exists(...) can check what a function summoned or killed.
When a test fails
FAIL paying_takes_mana
main.mms:39:3: expected mana[caster] == 8, but mana[caster] is 7
1 of 3 tests failed
A failed comparison shows both sides with their values. The line is the expect, and the editors mark it in place.
What the interpreter runs
The interpreter knows the commands that function code compiles to, and enough of the game to check logic:
| Works | |
|---|---|
| scores, Globals, Storage, strings | scoreboard, data on storage |
| control flow and calls | execute if/unless/as/at/positioned/store, function, return |
| entities | summon, kill, tag, and @e and @s with type, tag, scores, limit |
| messages | say, tellraw, title, which -v prints |
give |
listed by -v |
There are no blocks, no players and no ticks. A command the interpreter does not know, such as particle, effect or setblock, fails the test and names the command. That is on purpose: a test never passes by skipping something.
Writing code that can be tested
The limit suggests a habit that is good anyway: keep deciding apart from showing. The function that works out what happens changes scores and storage, and returns the answer. The function the game calls asks it, and then does the particles, sounds and effects.
Objective mana
/** Deciding: can be tested. */
Function pay(cost: int): bool {
if (mana[@s] >= cost) {
mana[@s] -= cost
return true
}
return false
}
/** Showing: a wand's right-click hook. */
Function cast {
if (!pay(5)) {
actionbar(@s, "{red}Not enough mana")
return
}
/particle flame ~ ~1 ~ 0.2 0.2 0.2 0.05 20
/effect give @s speed 5 1
}
pay holds everything that can go wrong, and the tests above cover it. cast is short enough to check by eye, and a test can still run its refusing branch, since that stops before the first particle.
pay asks whether the player has enough, not whether they have too little, and that matters. A score nobody has set is not a number yet, so every comparison with it is false: mana[@s] < cost would let a brand new player cast for free. A test with a caster whose mana is never set would catch it.
What to test
- Rules with edges. Costs, limits, cooldowns, the last wave, an empty list: anywhere
<could have been<=. - State machines. Set a phase, run the tick function, expect the next phase. The wave arena tutorial tests its game this way.
- Blueprints. A test can call the functions an instance emits, which checks the Blueprint itself. The
shopexample does. - Bugs you have fixed. A test named after the bug keeps it fixed.
mms test needs only the project folder, so it fits a CI step next to mms check. An AI agent can run the same tests through mms mcp.
The functions-advanced, waves, shop and treecapitator examples all have a tests.mms to read.