# Gum Unit Tests

> Gum Unit Test Reference

- Skill: `vchelaru/gum-unit-tests` (Agent Skill)
- Install (CLI): `npx skillmds@latest add vchelaru/gum-unit-tests`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vchelaru/gum-unit-tests/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: vchelaru (https://skillmd.com/u/vchelaru)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/vchelaru/gum-unit-tests

---


# Gum Unit Test Reference

## Test Projects

| Project | Location | What it tests |
|---------|----------|---------------|
| **`MonoGameGum.Tests`** | `MonoGameGum.Tests/` | **Default project for new tests.** MonoGame runtime, Forms controls, rendering, localization, data types — anything not specific to V3 visuals or integration |
| `Gum.ProjectServices.Tests` | `Tests/Gum.ProjectServices.Tests/` | Headless services: error checking, codegen, font generation, project loading, save byte-parity (`ProjectSaveParityTests` over `ParityCorpus/`) |
| `Gum.Presentation.Tests` | `Tests/Gum.Presentation.Tests/` | The tool's headless logic in `Tools/Gum.Presentation` (commands, view models, managers, plugin host) |
| `Gum.Avalonia.Tests` | `Tests/Gum.Avalonia.Tests/` | The Avalonia tool head: composition, shell, plugin host, canvas host, tree control, theme resources, and an unattended run of the real executable |
| `Gum.Cli.Tests` | `Tests/Gum.Cli.Tests/` | CLI command exit codes and output |
| `MonoGameGum.Tests.V3` | `Tests/MonoGameGum.Tests.V3/` | Tests specific to V3 default visuals |
| `MonoGameGum.IntegrationTests` | `Tests/MonoGameGum.IntegrationTests/` | Requires a real `GraphicsDevice`: content loading, renderer teardown, full `GumService` lifecycle |
| `RaylibGum.Tests` | `Tests/RaylibGum.Tests/` | raylib runtime (incl. the `#if RAYLIB` branches of the source-shared `GueDeriving/*Runtime.cs`) |
| `SkiaGum.Tests` | `Tests/SkiaGum.Tests/` | Skia runtime and shape runtimes |
| `Gum.ProjectServices.SkiaGum.Tests` | `Tests/Gum.ProjectServices.SkiaGum.Tests/` | SkiaGum-backed SVG export (`SkiaGumSvgExportService`, the service behind `gumcli svg` / tool File ▸ Export); drives `SKSvgCanvas` headlessly |

**When in doubt, put tests in `MonoGameGum.Tests/`.** Only use V2/V3 projects for tests that exercise visual-version-specific behavior.

**`SkiaGum.Tests` runs in CI as a blocking suite** (#3233) — it renders into an in-memory CPU raster `SKSurface`, so it is fully headless despite the name. A red Skia test now fails the job like any other Bucket-A suite.

**`RaylibGum.Tests` runs in CI as a blocking Windows suite** (#3250). raylib's `InitWindow` needs an OpenGL 3.3 context the GPU-less runners lack; the Windows job supplies it via Mesa's `llvmpipe` software GL (dropped in next to the test binaries before the suite runs), so the tests run headless and a red raylib test now fails the job like any other Bucket-A suite. (#3233's earlier macOS probe *hung* at GLFW/Cocoa window creation — Win32 window creation is not main-thread-coupled, which is why Windows works.) A green CI run now **does** cover raylib — including the `#if RAYLIB` branches of a source-shared `GueDeriving/*Runtime.cs` — so the old mandatory local pre-merge run is no longer required; still update any assertion that pins old behavior when you change raylib-covered code. (Issue #3234: #3183 changed the raylib stroke-width PreRender but left the suite asserting the pre-#3183 value; back then CI didn't run raylib, so it shipped red — now it would be caught.)

## Key Rules

- Always use **Shouldly** — never xUnit `Assert`. Alphabetize test methods within a class.
- Disable parallel execution in every test project (`[assembly: CollectionBehavior(DisableTestParallelization = true)]`) — Gum uses global singletons.
- A test asserting on an absolute path must not use a Windows-style `"C:\..."` literal — `Path.IsPathRooted` doesn't recognize a drive letter as rooted on Unix, so macOS/Linux CI treats it as relative and silently prepends the runner's real working directory, corrupting the path. Use a leading-slash literal (e.g. `"/game/Content/"`) instead — rooted on both platforms.
  - A test that *creates* files under a temp directory has the mirror-image trap: `\` is a legal file name character on macOS/Linux, so `Path.Combine(root, "Folder\\File.cs")` makes one oddly named file in `root` rather than a nested one — green on Windows, red on CI. Write the relative path with `/` and `Replace('/', Path.DirectorySeparatorChar)` it.
  - If the assertion compares against `ToolsUtilities.FilePath.FullPath`, a bare leading-slash literal still isn't safe: route the literal through `new FilePath(...).FullPath` on both sides of the comparison instead. See the `gum-file-paths` skill for why, and for the other `FilePath` comparison traps.
- `MessageDialogStyle.YesNo` is a static property returning a **new instance per get**, so a Moq setup matching it by value never matches. The unmatched call returns the default `MessageDialogResult` (0, negative), so the code under test takes the user-declined branch and the test fails somewhere unrelated. Match on the dialog title or `It.IsAny<MessageDialogStyle?>()`.
- Use named parameters for boolean literals.
- Don't name a test namespace after an existing Gum type (e.g. `MonoGameGum.Tests.Binding` collides with `Gum.Forms.Data.Binding`) — an unrelated file elsewhere in the same test project that references the type unqualified can suddenly fail to compile (`CS0118: '...' is a namespace but is used like a type`).

## Avalonia head tests (Gum.Avalonia.Tests)

- Anything that creates an Avalonia object (controls, `ResourceDictionary`, geometry) must be
  `[AvaloniaFact]`, which runs on the headless UI thread; a plain `[Fact]` throws "Call from invalid
  thread".
- Drive input with the `Avalonia.Headless` window helpers (`MouseDown`/`MouseUp`/`KeyPress` with a
  `PhysicalKey`) and call `Dispatcher.UIThread.RunJobs()` before asserting on anything the control
  updates on a later dispatcher pass.
- Tests that compose plugins need the container registered with `Locator` (see
  `HeadTestServices`). A hung test host blocks the next run until it is killed; run with
  `--blame-hang --blame-hang-timeout 120s`.
- `HeadProcessTests` launches the built head (`Tool/Gum.Avalonia/bin/<Config>/net10.0`) on a copied
  fixture; it skips without a display and on CI.

## Save parity corpus

`ParityCorpus/` holds whole project folders that must re-save byte for byte in every culture and on
every OS. A red parity test is a regression; only an intended format change regenerates the
baselines (`GUM_UPDATE_PARITY_BASELINES=1`, then review the diff). See `ParityCorpus/README.md`.

## Test at production defaults

When a feature's tests disable a production default for isolation (e.g. `LoaderManager.Self.CacheTextures = false`), remember that default is **on** in real apps — so any code path that only runs with it on is left untested. Treat "this test turns a production default off" as a smell: keep at least one test that exercises the path at the production default. (A raylib font regression survived review because every font test ran with caching off, which made a new cache-hit branch dead code.)

## Headless Tests (ProjectServices, MonoGameGum.Tests.V3)

Read `BaseTestClass` before adding setup — it handles singleton init, a ready-made `GumProjectSave`, and `Dispose` cleanup. Don't repeat that in subclasses.

Every `StateSave` must have `ParentContainer` set — `GetValueRecursive` traverses via that field and silently misbehaves or throws when it is null. Use `ScreenSave` for standalone state tests (no base type, no StandardElementsManager fallback).

`InternalsVisibleTo` is set up in `Gum.ProjectServices.csproj` for `Gum.ProjectServices.Tests` — internal members are directly accessible.

## Headless Forms allocation tests: install a real Cursor

`BaseTestClass` installs a Moq mock as `FrameworkElement.MainCursor`, and each proxied member access allocates (~296 B). Any control that reads `MainCursor` (e.g. a `ScrollBar` value setter) then pollutes an `AllocationMeasurer` result with a pure test artifact. Assign a real `MonoGameGum.Input.Cursor(null)` before measuring — production always uses a real cursor. See `ListBoxScrollAllocationTests`.

## WPF-touching tool code (GumToolUnitTests) needs an STA thread

xUnit's runner is **MTA**, but WPF `FrameworkElement`s (`MenuItem`, `Menu`, `ComboBox`, …) throw `InvalidOperationException: The calling thread must be STA` when constructed. If a tool class news up a WPF control — often a ViewModel building right-click `MenuItem`s in its constructor, or a plugin's `StartUp()` — mark the test `[StaFact]` (Xunit.StaFact), which runs it on an `ApartmentState.STA` thread. See `MenuStripManagerTests`.

**Verify the real construction blocker empirically before designing around an assumed one.** A quick throwaway probe (construct the object, see what actually throws) beats reasoning: e.g. `BitmapFrame` PNG decode and most non-control VM constructors run fine on MTA, so the blocker is usually the WPF control, not the singleton/resource you suspected.

**`[StaFact]` alone isn't enough for a control that pulls `StaticResource`s from an App-level merged `ResourceDictionary`** — those only exist inside the real running `Application`, so construction throws `XamlParseException` ("Cannot find resource named '...'") even under STA. Don't construct the real control to test a plugin's event-driven show/hide logic; extract that logic into a small class taking `ISelectedState`/`IPluginTab` via the constructor (mirrors `VariableGridSelectionCoordinator`) and test it without touching the control.

## Plugin/DI composition tests (GumToolUnitTests)

`AllPluginsCompositionTests` composes **every** tool plugin through MEF the way `PluginManager.LoadPlugins` does, and `ServiceProviderCompositionSpikeTests` resolves the bridged services from the real `Builder.cs` container. Two reusable techniques live there:

- **Stub anything headlessly without running its constructor.** `RuntimeHelpers.GetUninitializedObject(type)` fabricates a concrete instance (even a heavy WinForms/WPF host singleton) with no ctor call; a Moq proxy covers interfaces/abstract types. Composition/DI only needs the dependency to *exist* as the right type, so this avoids STA/graphics setup entirely. (Run the composition on `RunOnSta` regardless — some plugin *constructors* still touch WPF.)
- **Satisfy not-yet-drained plugins' direct `Locator.GetRequiredService<T>()` ctor calls** with a catch-all `IServiceProvider` registered via `Locator.Register(...)`; remove it in `Dispose` (Locator has no `Unregister` — `RenameManagerTests` shows the reflection teardown).

Keep the MEF batch an **explicit** mirror of `LoadPlugins` (not a catch-all export provider) so a plugin gaining an unbridged `[ImportingConstructor]` dependency turns the test red — that regression signal is the whole point. See the `gum-tool-plugins` skill for keeping `PluginBridgedServiceTypes.All` in sync during drains.

## Golden-image pixel-diff tests (SkiaGum.Tests)

`Tests/SkiaGum.Tests/GoldenImages/` covers rendering behavior that has no `Style`/paint-parameter to assert on (e.g. geometric per-glyph transforms) — the rest of `SkiaGum.Tests`' visual tests assert on paint/style objects instead of pixels. `PixelComparer` is a pure per-pixel/per-channel diff with tolerance (unit-tested in-memory, no files); `GoldenImageAssert.Matches(surface, name)` loads a checked-in baseline PNG from `GoldenImages/Baselines/<name>.png` and diffs it against a rendered `SKSurface`.

Baselines are **approved snapshots, not derived from spec** — same convention as Jest's `--updateSnapshot`. If the baseline is missing or the render regresses, the assertion fails and writes the actual render to `GoldenImages/Actual/<name>.actual.png`; review that PNG, then copy it into the source `GoldenImages/Baselines/` folder to approve it. Add the new `<None Include="GoldenImages\Baselines\**\*.png">` csproj entry's `CopyToOutputDirectory` pattern for any new baseline subfolder.

**Golden-image tests are not currently viable for text.** Pixel-exact comparison assumes identical rasterization on every CI runner, which text breaks even with every obvious source of drift eliminated. Attempted on #3692 (`TextCustomizationGoldenImageTests`, since removed): (1) a system font family (`FontName = "Arial"`) — the macOS Actions image has no Arial and silently substitutes a different typeface, blowing the pixel tolerance; fixed by loading a bundled TTF directly via `SKTypeface.FromFile` and a custom `Topten.RichTextKit.FontMapper` assigned to the static `FontMapper.Default`. (2) `MathF.Sin`-derived glyph offsets/colors — `Math.Sin`/`MathF.Sin` call into the OS math library (ucrt/libSystem/glibc), not guaranteed bit-identical cross-platform, so a last-bit difference nudges a glyph by a sub-pixel amount and flips an antialiased edge pixel; fixed by replacing them with a fixed lookup table of exact integers/bytes. Windows still passed and macOS still failed after **both** fixes — with the identical bundled font and zero floating-point math, Skia itself rasterizes/hints the same glyph outline differently per platform. There is no known fix within this harness's current design (`PixelComparer`'s strict per-pixel-position/channel diff). Until a per-OS-baseline or fuzzy/structural comparison strategy exists, keep golden-image tests restricted to non-text, geometric content (shapes, colors, alpha — see `RectangleGoldenImageTests`) and cover per-glyph geometry (position/color from a `[Custom]`-style callback) with deterministic assertions against RichTextKit's own layout data instead (`TextBlock.FontRuns[i].GlyphPositions`/`.Style`, as in `TextCustomizationTests` — no rasterization involved, so it's exact and OS-independent).

## Integration Tests (MonoGameGum.IntegrationTests)

Use this project for anything requiring a real `GraphicsDevice`. Each test creates a minimal nested `Game` subclass, calls `game.RunOneFrame()` to trigger `Initialize`, then asserts. See `Tests/MonoGameGum.IntegrationTests/MonoGameGum/GumServiceUnitTests.cs` for the established pattern. Always call `LoaderManager.Self?.DisposeAndClear()` in the `Game.Dispose` override to prevent state leaking across tests via the singleton.

