ahel is live on Product Hunt today. Upvote

Gum Unit Test Reference

SkillDev tools

Writing unit tests in the Gum repo. Triggers: tests in Gum.ProjectServices.Tests, Gum.Cli.Tests, or any other Gum test project.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Gum Unit Test Reference skill

What this skill tells your AI

The instructions your AI receives, as published by vchelaru/gum in .claude/skills/gum-unit-tests/SKILL.md and read by ahel’s review.

Test Projects

ProjectLocationWhat it tests
MonoGameGum.TestsMonoGameGum.Tests/Default project for new tests. MonoGame runtime, Forms controls, rendering, localization, data types — anything not specific to V3 visuals or integration
Gum.ProjectServices.TestsTests/Gum.ProjectServices.Tests/Headless services: error checking, codegen, font generation, project loading, save byte-parity (ProjectSaveParityTests over ParityCorpus/)
Gum.Presentation.TestsTests/Gum.Presentation.Tests/The tool's headless logic in Tools/Gum.Presentation (commands, view models, managers, plugin host)
Gum.Avalonia.TestsTests/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.TestsTests/Gum.Cli.Tests/CLI command exit codes and output
MonoGameGum.Tests.V3Tests/MonoGameGum.Tests.V3/Tests specific to V3 default visuals
MonoGameGum.IntegrationTestsTests/MonoGameGum.IntegrationTests/Requires a real GraphicsDevice: content loading, renderer teardown, full GumService lifecycle
RaylibGum.TestsTests/RaylibGum.Tests/raylib runtime (incl. the #if RAYLIB branches of the source-shared GueDeriving/*Runtime.cs)
SkiaGum.TestsTests/SkiaGum.Tests/Skia runtime and shape runtimes
Gum.ProjectServices.SkiaGum.TestsTests/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.
  • Run the whole test project before pushing, not only the new test filtered by name. Shared singletons make tests order-dependent, and a filtered run hides that.
  • 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.
  • MainWindow is a container singleton that HeadCompositionTests shows and closes. A test that needs it must not Show() it again (a closed window cannot be re-shown) and must not build a second one through ActivatorUtilities (it re-parents the singleton plugin tab controls and breaks unrelated tests). Read its state through window.Content without showing it.
  • 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 FrameworkElements (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 MenuItems 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 StaticResources 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 UnregisterRenameManagerTests 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.

Signals

GitHub stars
620
Forks
80
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
gum-unit-tests
Source
github.com/vchelaru/gum