Write Tests¶
Catch2, tags, the headless host, goldens.
Every control in libwinui is testable without an HWND, a GPU or a Windows session. Controls draw through the Renderer interface, and TestRenderer records those calls as plain data. Layout and input run against the same logical tree on every backend. Most tests therefore build a small tree, lay it out with a deterministic text measurer, poke it, and look at the recorded commands.
Catch2 Layout¶
All tests go into one binary, winui_tests, built from tests/test_<thing>.cpp. A new file has to be added to the add_executable(winui_tests ...) list in tests/CMakeLists.txt; it is not globbed. The binary links winui::testing, the testing tier's own library (include/winui/testing/, src/testing/), which brings winui::winui along; the library itself never compiles a testing source.
A typical file starts like this:
// SPDX-License-Identifier: MIT
#include "winui/testing/HeadlessUI.hpp"
#include "winui/testing/TestRenderer.hpp"
#include "winui/ui/status/InfoBadge.hpp"
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
using namespace ui;
using namespace ui::testing;
using Catch::Matchers::WithinAbs;
The smallest useful test measures, arranges and renders one control, then checks the command stream (from tests/test_info_badge.cpp):
TEST_CASE("Value mode renders a filled pill plus the value's own text", "[info-badge][render]")
{
HeadlessTextMeasurer m;
Theme theme = Theme::light();
InfoBadgeControl badge{42};
const Size desired = badge.measure({100.0f, Unbounded}, m, theme);
badge.arrange({0, 0, desired.width, desired.height});
TestRenderer r;
badge.render(r, theme);
REQUIRE(r.count() == 2);
REQUIRE(r.count_of<FillRoundedRect>() == 1);
REQUIRE(r.count_of<DrawTextRun>() == 1);
const auto& text = std::get<DrawTextRun>(r.commands[1]);
REQUIRE(text.text == "42");
}
TestRenderer::commands is a std::vector<RenderCommand>, a std::variant of one struct per draw call. count(), count_of<T>(), std::get<T> and std::holds_alternative<T> cover most assertions. Each command struct has a defaulted operator==, so a whole command can be compared at once.
Tags¶
Every test case carries tags. Use the control's name in kebab case plus the area it tests:
| Tag | Covers |
|---|---|
[button], [info-badge], ... |
One control. |
[layout], [render], [input], [focus], [keyboard] |
What part of the control's contract the case checks. |
[spec], [binding] |
The declarative surface and the reactive layer. |
[theme], [palette], [retro], [luna], [aero], [metro], [mdl2], ... |
Theme resolution and the era packs. |
[golden] |
Every golden test. With [semantic] for the text dumps, [pixel] for the Direct2D captures, [scene] for multi-control scenes. |
Run a subset by passing tags to the binary. Tags in one bracket group must all match:
./build/bin/Debug/winui_tests.exe "[info-badge]"
./build/bin/Debug/winui_tests.exe "[golden][semantic]"
./build/bin/Debug/winui_tests.exe "[theme]~[retro]" # theme tests, minus the era packs
Tests that need Windows (pixel goldens, Win32 implementations of the seams) sit inside #ifdef _WIN32. Everything else runs on any platform.
The Headless Host¶
HeadlessUI (include/winui/testing/HeadlessUI.hpp) is a complete window host without an HWND: one Window, a HeadlessWindowHost for window state and chrome requests, and a HeadlessTextMeasurer for layout. It is the tool for layout, focus, input and window-state tests.
Declare the tree with specs, and take the observers you need through .control:
TEST_CASE("Clicking Save counts", "[button][headless]")
{
HeadlessUI host = create_headless();
Property<int> clicks{0};
ui::Control<ui::Button> save;
host.window().set_content(ui::VerticalStack{.content = {
ui::Text{.text = ui::bind(clicks, [](int n) { return std::to_string(n); })},
ui::Button{.text = "Save", .on_click = [&] { ++clicks; }, .control = save},
}});
host.layout({400, 300});
const Point center = save->bounds().center();
ui::testing::seams(host.window()).handle_pointer({center, PointerButton::Left, PointerAction::Down});
ui::testing::seams(host.window()).handle_pointer({center, PointerButton::Left, PointerAction::Up});
REQUIRE(clicks.get() == 1);
}
What makes it deterministic:
- Text.
HeadlessTextMeasurergives every code point the same advance (0.55 em, scaled by weight) and every line 1.35 em. Layout numbers do not depend on installed fonts, DPI or the OS version. - Time.
host.tick(16ms)advances the window's clock by exactly that much. Hover delays, animations and caret blinking are driven through it, never by a real clock. - The OS theme. A test resolves its theme from a fixed
OsThemeSnapshot, never from the machine it runs on. - OS services. The clipboard, spell checker, context menus, launcher and native host all have test doubles (
TestClipboard,TestSpellChecker, ...). The clipboard and the launcher belong to the process and are installed withui::testing::set_clipboard()andset_launcher();HeadlessUIinstalls its own unless the test did. The others are installed on theWindowthroughseams(). Declare the double before theWindow, so it outlives it:
struct LinkWindow
{
HeadlessTextMeasurer measurer;
TestLauncher launcher; // destroyed after the window
Window window;
LinkWindow()
{
ui::testing::set_launcher(&launcher);
}
};
Declare the test's tree with specs, since that is the path real callers take.
Traps in Control Tests¶
- Composite controls live on the heap.
ComboBox,AutoSuggestBoxandNumberBoxown an innerTextBoxand callset_parent_of(*text_box_, this)in their constructor. A by-value return that is not elided moves the control and leaves the inner field's parent pointer at the old address. Build them withstd::make_unique, usually in amake_<control>()helper that also measures and arranges them. - Popups need a real
Windowand a second layout.Window::sync_popups()discovers and positions popups insidelayout(), not insidehandle_pointer()or a setter. After the input that opens a popup, calllayout()again before looking at it. A real host does the same, since it lays out again on the paint that follows every input event. A click on popup content (a drop-down row) only hits it throughWindow::handle_pointer(). - Animations settle without a clock. Headless layout tests never call
tick(), so an animated control must show its end state when no frame has ticked yet. A test that wants to watch an animation run callstick()once first (e.tick(16ms)), which is what a host with a frame clock does. - Timing tests do not gate. A test that measures a speed-up is tagged
[.][benchmark]. The[.]hides it from a plain run, because the result depends on the machine (a single-core CI runner can legitimately show no speed-up). Run it explicitly withwinui_tests.exe "[benchmark]". - Never write to the user's dictionary. The
Win32SpellCheckertests never calladd_to_dictionary()orISpellChecker::AutoCorrect(), because both change the user's own Windows dictionary.ignore_for_session()andadd_autocorrection()are session-only and in-process, so they are safe. SetLIBWINUI_SPELL_PNGto a file path to have the Direct2D squiggle test also write its frame as a PNG for eyeballing.
Testing the Declarative Surface¶
A spec builds the same controls a hand-written tree does. So the spec layer's main test is an equivalence: a declared tree and the hand-built tree it replaces must emit the identical render-command stream (tests/test_spec.cpp):
That makes every existing golden the regression net for the spec layer, without a golden per spec. What a spec test does check, per field, is that the value reaches the control, a bind(...) keeps it in sync, and seeding a value fires no change handler:
TEST_CASE("ui::InfoBadge binds severity, icon and background", "[spec][binding]")
{
Property<InfoBadgeSeverity> severity{InfoBadgeSeverity::Success};
ui::Control<ui::InfoBadge> badge;
std::unique_ptr<Element> tree = ui::build(ui::InfoBadge{.severity = bind(severity), .control = badge});
CHECK(badge->severity() == InfoBadgeSeverity::Success);
severity = InfoBadgeSeverity::Critical;
CHECK(badge->severity() == InfoBadgeSeverity::Critical);
}
The reactive layer has its own file, tests/test_property.cpp. It covers every lifetime case of a subscription (subscriber dies first, property dies first, the equality gate, a handler unsubscribing itself during dispatch, subscriptions moved when a vector reallocates). Change its invariants only with a very good reason.
Goldens¶
Goldens are checked-in expected outputs in tests/golden/, driven by tests/test_golden.cpp. There are two kinds, and the semantic one comes first.
Semantic goldens (.txt) are the render-command stream printed by describe() (include/winui/render/Describe.hpp; ui::testing::describe(window) renders a whole window first), one line per command, for a tree laid out with HeadlessTextMeasurer:
SetRasterization geometry=antialiased text=grayscale snap=false
FillRoundedRect rect=(94.00,30.00,52.80,29.90) radius=4.00 color=#FFFFFFB3
StrokeRoundedRectGradient rect=(94.50,30.50,51.80,28.90) radius=3.50 extent=-3.00 width=1.00 stops=[0.33:#00000029 1.00:#0000000F]
DrawTextRun rect=(94.00,30.00,52.80,29.90) color=#000000E4 size=14.00 weight=regular halign=center valign=center text="Save"
That is tests/golden/button-normal.txt, a Fluent 2 button at rest.
They do not depend on fonts, resolution or the OS, run on every platform, and a diff reads as "this rectangle moved" or "this color changed". Colors and metrics are just values in the stream, so a semantic golden covers a theme as well as a layout.
Pixel goldens (.png) are real Direct2D and DirectWrite output. The scene is laid out with the DirectWrite measurer and drawn by the same D2DRenderer the on-screen window uses, captured by ui::testing::OffscreenCapture into a software WIC target with grayscale text anti-aliasing, so the result is the same from run to run. They are Windows-only and trade portability for fidelity to what actually ships. The comparison allows a channel difference of 1 and no mismatched pixels beyond that. Capture over the theme's real window background (theme.colors().background), since Fluent's control fills are translucent and look wrong over plain white.
Use a pixel golden only when the raster itself is the question: a new primitive in the Direct2D backend, anti-aliasing, text rendering. For a primitive, a targeted check often beats a checked-in PNG. The Icon bitmap test in tests/test_golden.cpp captures a 40x40 scene and samples two pixels to prove that a bitmap was drawn at all. A wrong bitmap format can leave the recorded command correct while Direct2D draws nothing, and only a real capture shows that. The same file has spot checks of this kind for draw_image_rect() and draw_svg(), draw_geometry(), radial gradients and in-app acrylic (a hard red/white edge under it must come out as a ramp).
OffscreenCapture initializes COM only if the thread has not already, and releases every WIC and Direct2D interface before its own CoUninitialize, since releasing one afterwards crashes. Keep that order if you touch it.
A theme gets a pixel golden for one representative scene, not one per control. The control-suite scene has a light Fluent 2 golden plus one each for Windows 9x, Luna and dark Metro. Everything else about an era is pinned by tests/test_theme.cpp and the semantic goldens. The two non-obvious choices:
- Metro is captured in dark mode on purpose. Metro's
CheckBoxandRadioButtonborder thickness is 0 in dark and 2 in light (the reverse of itsButton), so dark is where the era's toggle polarity shows. Light mode and the Metro button asymmetry are pinned by the[theme][metro]cases intests/test_theme.cpp, since the scene holds no button. - Luna's scene holds every Luna toggle correction (the checked box's near-white well with its
#21A121tick inside a#1C5180border, the different green of the radio dot). For Luna the semantic golden carries the weight: a pixel golden can be made self-consistent, but it cannot be validated without a real Windows XP to compare against.
Updating Goldens¶
A missing golden fails the test with a hint. To write or rewrite them, set LIBWINUI_UPDATE_GOLDEN:
Do this on purpose, after a visual or layout change you intended, and read the diff before you commit it. A semantic diff should only touch the lines you expected to move. A pixel diff needs a look at the images.
The pixel goldens are made on a development machine, and another Windows build draws text with other font and DirectWrite versions. CI sets LIBWINUI_SKIP_PIXEL_GOLDENS=1, which skips every comparison against a .png (ctest reports those cases as skipped). The semantic goldens and the pixel tests that compare two renders with each other still run there.
Semantic goldens are compared byte for byte and describe() writes LF line endings. .gitattributes pins tests/golden/*.txt to LF, so a checkout with core.autocrlf does not turn every line into a mismatch. If every semantic golden fails with output that looks identical, check the line endings first.
Adding a Golden¶
- Write a function that builds the scene into a
Windowand lays it out with a givenTextMeasurer, asbuild_controls_scene()does. - Add a semantic
TEST_CASEtagged[golden][semantic]that comparesdescribe(*window)againsttests/golden/<name>.txt. - If the raster matters, add a pixel
TEST_CASEtagged[golden][pixel]inside#ifdef _WIN32that captures withOffscreenCaptureand callscheck_pixel_golden(). - Run with
LIBWINUI_UPDATE_GOLDEN=1, check the new files, and commit them with the test.
What Not to Test¶
- Pixels in unit tests. A unit test asserts on the command stream and on layout bounds, never on a rendered bitmap. The raster belongs to the pixel goldens.
- Windows in the core. A test of layout, input, focus or rendering must run on any platform. Only a test of the Windows backend itself sits inside
#ifdef _WIN32. - The real OS. No test reads the user's theme, the real clipboard or the wall clock. Use the snapshot, the doubles and
tick(). - Each spec's rendering. The equivalence test already ties the spec layer to the control's own tests and goldens. A spec test checks that fields arrive, not what the control draws.
- Controls by string lookup. There is no
find<ButtonControl>("save"). Hold the control through.control, which fails at compile time instead of at runtime.