# 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.

URL: https://xichil.dev/docs/xis-mvui/v1/guides/testing/ · Updated: 2026-11-06 · Applies to: Fabric, Quilt, Legacy Fabric, Forge, NeoForge, MC 1.7.10 - 26.3

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

```kotlin title="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

```java title="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:

```java
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:

```text
== 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.

> **Tip:** For readable failures, assert with a message that includes the whole screen:
> `assertTrue(host.render().contains(line), () -> "expected '" + line + "' in:\n" + host.render())`.

## 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:

```java
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:

```java
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](https://xichil.dev/docs/xis-mvui/v1/guides/validation/index.md): asserting error lines.
- [A settings menu](https://xichil.dev/docs/xis-mvui/v1/examples/settings-menu/index.md): a complete menu with its test.
