Test menus without Minecraft

Drive your menus in plain JUnit tests with FakeHost from the API's test fixtures - click, type, slide and read the rendered screen as text.

Last updated

Steps

Because a menu has no Minecraft classes, you can test it in an ordinary unit test. FakeHost, from the API's test fixtures, plays the part of the game: it opens your screens, drives widgets by id and renders the current screen as plain English text. One test covers every loader and version.

Set up#

build.gradle.kts
dependencies {
    testImplementation("dev.xichil.mvui:xis-mvui-api:1.0.0")
    testImplementation(testFixtures("dev.xichil.mvui:xis-mvui-api:1.0.0"))
    testImplementation(platform("org.junit:junit-bom:5.13.4"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
}

The API itself has to be on the test class path too, because compileOnly dependencies aren't. The classes are in package dev.xichil.mvui.fake: FakeHost and its GameBridge, FakeGame.

A first test#

MyMenuTest.java
import dev.xichil.mvui.fake.FakeHost;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class MyMenuTest {
    private MyConfig config;
    private FakeHost host;

    @BeforeEach
    void open() {
        config = new MyConfig();
        host = new FakeHost();
        host.open(new MyMenu(config).create(host));
    }

    @Test
    void togglingSoundsGreysOutVolume() {
        host.click("sounds");
        assertFalse(config.sounds);
        assertTrue(host.render().contains("[slider volume] Volume: 80 (disabled)"));
    }

    @Test
    void doneClosesTheMenu() {
        host.click("done");
        assertEquals(0, host.depth());
    }
}

Drive widgets#

Every method takes an entry id and returns the host, so calls chain: host.click("a").type("b", "x").

Method Does
click(id) presses a button, flips a toggle or checkbox, steps a cycle, expands or collapses a section, opens a dropdown's picker or a color picker's editor. On a ConfirmDialog, the ids are yes and no.
type(id, text) replaces the text of a TextInput, NumberField or MultiLineInput
slide(id, value) sets a slider's value, snapped and clamped as a drag would
pressKey(id, key) clicks a Keybind and presses key (Escape unbinds)
select(id, rowId) selects a SelectList row; null clears the selection

Like a real screen, FakeHost refuses entries the player couldn't use: a hidden or disabled entry throws IllegalStateException, and an unknown id throws IllegalArgumentException. That makes "this button is disabled now" easy to assert:

assertThrows(IllegalStateException.class, () -> host.click("volume.reset"));

Read the screen#

Method Gives
render() the current screen as text, as of the last refresh
current() the open Screen, or null when the menu is closed; current().id() names it
depth() how many screens are open
isBusy() whether a runAsync task is running
game() the FakeGame

render() prints one line per visible entry, a -- line before the footer, and section children indented by two spaces:

== My Mod [main] ==
[toggle sounds] Sounds: ON
[slider volume] Volume: 80
[section advanced] ▶ Advanced
[status status] SUCCESS Saved
--
[button done] Done
Entry Rendered as
Button [button id] label
Label [label id] text
StatusLine [status id] KIND text, or [status id] while empty
Toggle [toggle id] label: ON
Checkbox [checkbox id] [x] label or [ ] label
Cycle, Dropdown [cycle id] label: value, [dropdown id] label: value
Slider [slider id] label: value
TextInput, NumberField [input id] label: "text", plus (error: …)
MultiLineInput [textarea id] label: "line 1\nline 2", plus (error: …)
SelectList [list id], then one line per row: > rowId: text for the selected row, rowId: text otherwise
Keybind [keybind id] label: R (the key's English name)
ColorPicker [color id] label: #RRGGBB
Section [section id] ▼ title or ▶ title

A disabled entry ends with (disabled), and a tooltip adds (tooltip: …). A ConfirmDialog renders as its title, message and [yes] … [no] …. A busy screen adds a (Working…) line. All text is the English fallback.

Async work and the game#

runAsync tasks run synchronously in FakeHost, so their onDone has run by the time click returns. runOnMainThread runs its task at once.

FakeGame stands in for the game's options:

FakeGame game = new FakeGame().inWorld(true);
FakeHost host = new FakeHost(game);
// …
assertEquals(1, game.flushes());     // flushOptions() calls
assertEquals(0, game.reloads());     // reloadOptions() calls

reloadResult(ReloadResult) sets what reloadOptions() returns.

Composite widgets#

Dropdowns, color pickers and list editors are built from plain entries with derived ids, so tests drive them like any other screen:

host.click("biome");                          // opens the picker screen "biome.picker"
host.select("biome.list", "3");               // rows are the value indexes; picking closes the picker

host.click("highlight");                      // opens the editor "highlight.editor"
host.slide("highlight.red", 255).click("highlight.done");

host.type("servers.input", "example.org").click("servers.add");
host.select("servers.list", "example.org").click("servers.remove");

What FakeHost doesn't do#

Next steps#