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.
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#
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#
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#
- A menu you open with
host.open(menu.create(host))isn't registered, so the library's failure isolation doesn't wrap it: whatever your code throws reaches the test, which is what you want there. - It doesn't draw anything. Layout, colors and the look of each version are the backends' job.
Next steps#
- Validate what players type: asserting error lines.
- A settings menu: a complete menu with its test.