| name | writing-unit-tests |
| description | Guidelines for writing unit tests in the Hex1b TUI library. Use when creating new tests for widgets, nodes, or terminal functionality. |
Writing Unit Tests Skill
This skill provides guidelines for AI agents writing unit tests for the Hex1b TUI library. It outlines the preferred testing approach, patterns, and anti-patterns to avoid. Tests use MSTest 4 with MSTest.Sdk/4.2.3, OutputType=Exe, and Microsoft.Testing.Platform (MTP). global.json configures dotnet test to use MTP; test projects can also run in executable mode with dotnet run --project tests/SomeProject/.
Core Philosophy
- Prefer full terminal stack testing - Use
Hex1bTerminal.CreateBuilder() to create complete terminal environments
- Use
.WithHex1bApp() for TUI functionality tests - This wires up the full app lifecycle
- Keep tests simple and linear - Avoid excessive abstractions; repeating patterns are beneficial for AI agents
- Assert on visual behavior - Use
CellPatternSearcher and color assertions for render verification
- Update this skill when discovering new patterns - Build the body of knowledge as part of PRs
When to Use Full Stack vs Isolation
| Test Type | Approach |
|---|
| Widget behavior, layout, rendering | Full stack with Hex1bTerminal.CreateBuilder() |
| Input handling, focus navigation | Full stack with WithHex1bApp() |
| Low-level APIs (Surface, SurfaceCell) | Test in isolation (dependencies of Hex1bApp) |
| Color/theme verification | Full stack with snapshot color assertions |
Standard Test Structure
Full Stack Integration Test
Test files import Microsoft.VisualStudio.TestTools.UnitTesting. The Hex1b.Testing namespace is global-using'd via Directory.Build.props for helpers such as TestSeq. For test output, add public TestContext TestContext { get; set; } = null!; and call TestContext.WriteLine(...). Suppressed MSTest analyzers are MSTEST0014, MSTEST0030, MSTEST0032, and MSTEST0057.
This is the preferred pattern for most tests:
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class WidgetNameTests
{
[TestMethod]
public async Task WidgetName_Scenario_ExpectedBehavior()
{
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) => ctx => new VStackWidget([
new TextBlockWidget("Hello"),
new ButtonWidget("Click Me")
]))
.WithHeadless()
.WithDimensions(80, 24)
.Build();
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Hello"), TimeSpan.FromSeconds(2), "initial render")
.Down()
.WaitUntil(s => s.ContainsText("> Click Me"), TimeSpan.FromSeconds(2), "button focused")
.Capture("focused-button")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Assert.IsTrue(snapshot.ContainsText("> Click Me"));
}
}
Key Elements
await using var terminal - Ensures proper disposal
.WithHeadless() - No actual terminal output (CI-safe)
.WithDimensions(80, 24) - Explicit terminal size
WaitUntil before assertions - Prevents timing issues
.Capture("name") - Saves SVG/HTML for debugging
Ctrl().Key(Hex1bKey.C) - Clean exit
Input Sequencing Patterns
Basic Navigation
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Item 1"), TimeSpan.FromSeconds(2), "list rendered")
.Down()
.WaitUntil(s => s.ContainsText("> Item 2"), TimeSpan.FromSeconds(2), "moved to item 2")
.Down()
.WaitUntil(s => s.ContainsText("> Item 3"), TimeSpan.FromSeconds(2), "moved to item 3")
.Capture("navigation-result")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Text Input
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Name:"), TimeSpan.FromSeconds(2), "form rendered")
.Type("John Doe")
.WaitUntil(s => s.ContainsText("John Doe"), TimeSpan.FromSeconds(2), "text entered")
.Capture("text-input")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Keyboard Shortcuts
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Ready"), TimeSpan.FromSeconds(2), "app ready")
.Ctrl().Key(Hex1bKey.S)
.WaitUntil(s => s.ContainsText("Saved"), TimeSpan.FromSeconds(2), "save completed")
.Capture("after-save")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Visual Assertion Patterns
Using CellPatternSearcher
For precise cell-level assertions:
var pattern = new CellPatternSearcher().Find('█');
var result = pattern.Search(snapshot);
Assert.IsTrue(result.HasMatches);
Assert.AreEqual(expectedX, result.First!.Start.X);
var pattern = new CellPatternSearcher().FindPattern(@"Count:\s*\d+");
var result = pattern.Search(snapshot);
Assert.IsTrue(result.HasMatches);
var pattern = new CellPatternSearcher()
.Find(ctx => char.IsDigit(ctx.Cell.Character[0]));
var result = pattern.Search(snapshot);
Assert.AreEqual(3, result.Count);
Color Assertions
For verifying themed/styled output:
Assert.IsTrue(snapshot.HasBackgroundColor(Hex1bColor.FromRgb(0, 100, 200)),
"Button should have blue background");
Assert.IsTrue(snapshot.HasForegroundColor(Hex1bColor.FromRgb(255, 255, 255)),
"Text should be white");
var bgColor = snapshot.GetBackgroundColor(10, 5);
Assert.AreEqual(Hex1bColor.FromRgb(255, 0, 0), bgColor);
Assert.IsTrue(snapshot.HasUniformBackgroundColor(0, Hex1bColor.FromRgb(50, 50, 50)),
"Header row should have dark background");
Available Color Extension Methods
| Method | Purpose |
|---|
HasBackgroundColor() | Any cell has a background color |
HasBackgroundColor(Hex1bColor) | Any cell has specific background |
HasForegroundColor() | Any cell has a foreground color |
HasForegroundColor(Hex1bColor) | Any cell has specific foreground |
GetBackgroundColor(x, y) | Get background at position |
GetForegroundColor(x, y) | Get foreground at position |
HasUniformBackgroundColor(y, color) | All cells in row have same background |
VisualizeBackgroundColors() | Debug helper with visual representation |
Anti-Patterns to Avoid
📘 See the test-fixer skill for detailed diagnosis and fixes when tests become flaky.
❌ Insufficient WaitUntil Conditions (Partial Render)
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Header"), TimeSpan.FromSeconds(2))
.Capture("screen")
.Build()
.ApplyAsync(terminal, ct);
Assert.IsTrue(snapshot.ContainsText("Footer"));
Problem: Rendering is inherently async. Finding "Header" doesn't guarantee "Footer" has rendered yet. This is especially problematic when testing other terminal frameworks (like Spectre Console) which may drop input if they're not ready to receive it.
Fix: Over-specify the WaitUntil condition to ensure everything you need is present:
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Header") && s.ContainsText("Footer"),
TimeSpan.FromSeconds(2), "full screen rendered")
.Capture("screen")
.Build()
.ApplyAsync(terminal, ct);
Guideline: If you're going to assert on specific screen content, include it in the WaitUntil condition. Don't assume the rest of the screen is ready just because one part appeared.
❌ Snapshot After Exit
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Hello"), TimeSpan.FromSeconds(2))
.Capture("final")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, ct);
Assert.IsTrue(snapshot.ContainsText("Hello"));
Fix: The WaitUntil already verified the content. If you need to assert, the WaitUntil serves as the assertion.
❌ Missing WaitUntil After Action
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Item 1"), TimeSpan.FromSeconds(2))
.Down()
.Capture("after-down")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, ct);
Fix: Always add WaitUntil after any action that changes state:
.Down()
.WaitUntil(s => s.ContainsText("> Item 2"), TimeSpan.FromSeconds(2), "moved down")
.Capture("after-down")
❌ Task.Delay for Async Events
await terminal.SendKeyAsync(Hex1bKey.Enter);
await Task.Delay(100);
Assert.IsTrue(eventFired);
Fix: Use TaskCompletionSource to signal completion:
var eventSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
eventSignal.TrySetResult();
await eventSignal.Task.WaitAsync(TimeSpan.FromSeconds(2), ct);
❌ Over-Abstracted Test Helpers
var result = await TestHelpers.CreateTerminalAndRunScenario(
widgets: WidgetFactory.CreateStandardList(),
actions: ActionBuilder.NavigateAndSelect(3),
assertions: AssertionBuilder.SelectedItem("Item 3")
);
Prefer: Simple, linear, self-contained tests. Repetition is acceptable and helps AI agents understand patterns.
Widget Test Dimensions
When writing tests for widgets, consider all the dimensions that affect behavior. Each widget should have tests covering these scenarios:
1. Terminal Size Variations
Widgets must work across different terminal sizes. Use MSTest DataRow for parameterized cases:
[TestMethod]
[DataRow(40, 10)]
[DataRow(80, 24)]
[DataRow(120, 40)]
[DataRow(200, 60)]
public async Task ListWidget_VariousTerminalSizes_RendersCorrectly(int width, int height)
{
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) => ctx => new ListWidget(["Item 1", "Item 2", "Item 3"]))
.WithHeadless()
.WithDimensions(width, height)
.Build();
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Item 1"), TimeSpan.FromSeconds(2), "list rendered")
.Capture($"list-{width}x{height}")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
}
Key questions to answer:
- What is the realistic minimum terminal size for this widget?
- Does the widget truncate, scroll, or wrap when space is limited?
- Does the widget expand appropriately in large terminals?
- Are there edge cases at specific sizes?
2. Container Widget Context
Widgets behave differently depending on their parent container. Test inside various layouts:
[TestMethod]
public async Task ProgressWidget_InsideBorder_RendersWithCorrectWidth()
{
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) => ctx => new BorderWidget(
new ProgressWidget { Value = 50, Maximum = 100 },
title: "Loading"
))
.WithHeadless()
.WithDimensions(60, 10)
.Build();
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Loading"), TimeSpan.FromSeconds(2), "border rendered")
.Capture("progress-in-border")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
}
[TestMethod]
public async Task Button_InsideHStack_SharesSpaceCorrectly()
{
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) => ctx => new HStackWidget([
new ButtonWidget("Cancel"),
new ButtonWidget("OK")
]))
.WithHeadless()
.WithDimensions(40, 5)
.Build();
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Cancel") && s.ContainsText("OK"), TimeSpan.FromSeconds(2))
.Capture("buttons-in-hstack")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
}
Common container scenarios to test:
- Inside
VStackWidget (vertical stacking)
- Inside
HStackWidget (horizontal stacking)
- Inside
BorderWidget (reduced available space)
- Inside
ScrollPanelWidget (scrollable content)
- Inside
SplitterWidget (resizable panes)
- Nested containers (e.g., Border inside VStack inside Splitter)
3. Theming Behavior
Verify that widgets respect theme colors and can be customized:
[TestMethod]
public async Task Button_WithCustomTheme_UsesThemeColors()
{
var customTheme = new Hex1bTheme("TestTheme")
.Set(ButtonTheme.BackgroundColor, Hex1bColor.FromRgb(255, 0, 0))
.Set(ButtonTheme.ForegroundColor, Hex1bColor.FromRgb(255, 255, 255));
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) =>
{
options.Theme = customTheme;
return ctx => new ButtonWidget("Test Button");
})
.WithHeadless()
.WithDimensions(40, 5)
.Build();
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Test Button"), TimeSpan.FromSeconds(2))
.Capture("themed-button")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Assert.IsTrue(snapshot.HasBackgroundColor(Hex1bColor.FromRgb(255, 0, 0)),
"Button should have red background from theme");
Assert.IsTrue(snapshot.HasForegroundColor(Hex1bColor.FromRgb(255, 255, 255)),
"Button should have white text from theme");
}
[TestMethod]
public async Task Button_FocusedState_UsesFocusedThemeColors()
{
await using var terminal = Hex1bTerminal.CreateBuilder()
.WithHex1bApp((app, options) => ctx => new VStackWidget([
new TextBlockWidget("Header"),
new ButtonWidget("Focusable Button")
]))
.WithHeadless()
.WithDimensions(40, 5)
.Build();
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Focusable Button"), TimeSpan.FromSeconds(2))
.Tab()
.WaitUntil(s => s.ContainsText(">"), TimeSpan.FromSeconds(2), "button focused")
.Capture("focused-button-theme")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Assert.IsTrue(snapshot.HasBackgroundColor(), "Focused button should have background color");
}
Theming scenarios to test:
- Default theme renders correctly
- Custom theme colors are applied
- Focused vs unfocused states use appropriate theme values
- Disabled state styling (if applicable)
- Theme inheritance from parent widgets
4. Widget Test Matrix
For comprehensive widget coverage, consider this matrix:
| Dimension | Variations to Test |
|---|
| Terminal Size | Minimum (40×10), Standard (80×24), Large (120×40), Very Large (200×60) |
| Container | Root, VStack, HStack, Border, Scroll, Splitter, Nested |
| Theme | Default, Custom colors, Focused state, Disabled state |
| Content | Empty, Minimal, Typical, Maximum/overflow |
| State | Initial, After interaction, Edge cases |
Not every widget needs every combination, but consider which dimensions are relevant for the widget's behavior.
Low-Level API Testing (Isolation)
For APIs that are dependencies of Hex1bApp (like Surface), test in isolation:
[TestMethod]
public void Surface_WriteText_SetsCorrectCells()
{
var surface = new Surface(80, 24);
surface.WriteText(0, 0, "Hello");
Assert.AreEqual('H', surface[0, 0].Character[0]);
Assert.AreEqual('e', surface[1, 0].Character[0]);
Assert.AreEqual('l', surface[2, 0].Character[0]);
Assert.AreEqual('l', surface[3, 0].Character[0]);
Assert.AreEqual('o', surface[4, 0].Character[0]);
}
[TestMethod]
public void SurfaceCell_WithColor_PreservesColor()
{
var cell = new SurfaceCell('X', Hex1bColor.Red, Hex1bColor.Blue);
Assert.AreEqual('X', cell.Character[0]);
Assert.AreEqual(Hex1bColor.Red, cell.Foreground);
Assert.AreEqual(Hex1bColor.Blue, cell.Background);
}
Test Naming Convention
Follow MethodName_Scenario_ExpectedBehavior:
[TestMethod]
public async Task ListWidget_DownArrow_SelectsNextItem() { }
[TestMethod]
public async Task TextBox_TypeText_DisplaysInput() { }
[TestMethod]
public async Task Button_EnterKey_TriggersClickHandler() { }
[TestMethod]
public void Surface_Fill_SetsAllCellsInRegion() { }
Updating This Skill
When you discover a new testing pattern while writing tests:
- Add the pattern to this skill as part of the same PR
- Include a concrete example with comments
- Explain when to use it (what problem does it solve?)
- If it's an anti-pattern, add it to the anti-patterns section with the fix
This builds the body of knowledge available to AI agents working on the codebase.
Examples of Patterns to Document
- New assertion helpers or extension methods
- Patterns for testing specific widget types
- Workarounds for platform-specific behavior
- Performance testing patterns
- Patterns for testing async behavior
Checklist for New Tests