Testing
Termina provides VirtualInputSource for automated testing without a real terminal.
Overview
Testing TUI applications typically requires:
- Simulating keyboard/mouse input
- Verifying state changes
- Running without a physical terminal
VirtualInputSource solves these by providing programmatic input injection.
Setting Up Tests
Register Virtual Input
csharp
var inputSource = new VirtualInputSource();
builder.Services.AddTerminaVirtualInput(inputSource);Enqueue Input Events
csharp
// Single keys
inputSource.EnqueueKey(ConsoleKey.Enter);
inputSource.EnqueueKey(ConsoleKey.UpArrow);
// Keys with modifiers
inputSource.EnqueueKey(ConsoleKey.C, control: true); // Ctrl+C
// Characters and strings
inputSource.EnqueueChar('a');
inputSource.EnqueueString("Hello World");
// Mouse events
inputSource.EnqueueClick(10, 5, MouseButton.Left);
inputSource.EnqueueScroll(10, 5, up: true);
// Resize events
inputSource.EnqueueResize(120, 40);
// Signal completion
inputSource.Complete();VirtualInputSource API
View VirtualInputSource implementation
csharp
using System.Threading.Channels;
namespace Termina.Input;
/// <summary>
/// Virtual input source for testing and programmatic input.
/// External code pushes events via Enqueue methods, which get forwarded to the event loop.
/// </summary>
public sealed class VirtualInputSource : IInputSource
{
private readonly Channel<IInputEvent> _inputChannel = Channel.CreateUnbounded<IInputEvent>();
/// <summary>
/// Enqueue a key event to be processed.
/// </summary>
public void EnqueueKey(ConsoleKeyInfo key)
{
_inputChannel.Writer.TryWrite(new KeyPressed(key));
}
/// <summary>
/// Enqueue a key by ConsoleKey (no character, no modifiers).
/// </summary>
public void EnqueueKey(ConsoleKey key)
{
EnqueueKey(new ConsoleKeyInfo('\0', key, shift: false, alt: false, control: false));
}
/// <summary>
/// Enqueue a key by ConsoleKey with modifiers.
/// </summary>
public void EnqueueKey(ConsoleKey key, bool shift = false, bool alt = false, bool control = false)
{
char keyChar = key >= ConsoleKey.A && key <= ConsoleKey.Z
? (char)('a' + (key - ConsoleKey.A))
: '\0';
EnqueueKey(new ConsoleKeyInfo(keyChar, key, shift, alt, control));
}
/// <summary>
/// Enqueue a character key.
/// </summary>
public void EnqueueChar(char c)
{
var consoleKey = char.ToUpper(c) switch
{
>= 'A' and <= 'Z' => (ConsoleKey)(char.ToUpper(c) - 'A' + (int)ConsoleKey.A),
>= '0' and <= '9' => (ConsoleKey)(c - '0' + (int)ConsoleKey.D0),
' ' => ConsoleKey.Spacebar,
_ => ConsoleKey.NoName
};
EnqueueKey(new ConsoleKeyInfo(c, consoleKey, shift: false, alt: false, control: false));
}
/// <summary>
/// Enqueue a string as a series of character keys.
/// </summary>
public void EnqueueString(string text)
{
foreach (var c in text)
EnqueueChar(c);
}
/// <summary>
/// Enqueue a mouse event.
/// </summary>
/// <param name="x">X position (column).</param>
/// <param name="y">Y position (row).</param>
/// <param name="button">The mouse button.</param>
/// <param name="eventType">The type of mouse event.</param>
/// <param name="modifiers">Optional keyboard modifiers.</param>
public void EnqueueMouse(int x, int y, MouseButton button, MouseEventType eventType, ConsoleModifiers modifiers = 0)
{
_inputChannel.Writer.TryWrite(new MouseEvent(x, y, button, eventType, modifiers));
}
/// <summary>
/// Enqueue a mouse click event.
/// </summary>
public void EnqueueClick(int x, int y, MouseButton button = MouseButton.Left)
{
EnqueueMouse(x, y, button, MouseEventType.Press);
}
/// <summary>
/// Enqueue a mouse scroll event.
/// </summary>
public void EnqueueScroll(int x, int y, bool up)
{
EnqueueMouse(x, y, up ? MouseButton.WheelUp : MouseButton.WheelDown, MouseEventType.Scroll);
}
/// <summary>
/// Enqueue a terminal resize event.
/// </summary>
/// <param name="width">New terminal width.</param>
/// <param name="height">New terminal height.</param>
public void EnqueueResize(int width, int height)
{
_inputChannel.Writer.TryWrite(new ResizeEvent(width, height));
}
/// <summary>
/// Signal that no more input will be provided.
/// </summary>
public void Complete()
{
_inputChannel.Writer.TryComplete();
}
/// <inheritdoc />
public async Task RunAsync(ChannelWriter<object> writer, CancellationToken cancellationToken)
{
// Forward all enqueued input to the shared event channel
await foreach (var evt in _inputChannel.Reader.ReadAllAsync(cancellationToken))
{
await writer.WriteAsync(evt, cancellationToken);
}
}
}Key Methods
| Method | Description |
|---|---|
EnqueueKey(ConsoleKey) | Send a key without character |
EnqueueKey(ConsoleKey, shift, alt, control) | Send key with modifiers |
EnqueueChar(char) | Send a character key |
EnqueueString(string) | Send multiple characters |
EnqueueClick(x, y, button) | Send mouse click |
EnqueueScroll(x, y, up) | Send scroll event |
EnqueueResize(width, height) | Send resize event |
Complete() | Signal end of input |
Example: Testing Counter App
csharp
[Fact]
public async Task Counter_IncrementDecrement_UpdatesState()
{
// Arrange
var inputSource = new VirtualInputSource();
var builder = Host.CreateApplicationBuilder();
builder.Services.AddTerminaVirtualInput(inputSource);
builder.Services.AddTermina("/counter", termina =>
{
termina.RegisterRoute<CounterPage, CounterViewModel>("/counter");
});
var host = builder.Build();
// Act - queue input before starting
_ = Task.Run(async () =>
{
await Task.Delay(100); // Wait for startup
inputSource.EnqueueKey(ConsoleKey.UpArrow);
inputSource.EnqueueKey(ConsoleKey.UpArrow);
inputSource.EnqueueKey(ConsoleKey.DownArrow);
await Task.Delay(50);
inputSource.EnqueueKey(ConsoleKey.Escape);
inputSource.Complete();
});
await host.RunAsync();
// Assert - ViewModel state
// (Access via service provider or exposed test hooks)
}Demo Test Mode
The demos include a --test flag for CI/CD:
csharp
var testMode = args.Contains("--test");
if (testMode)
{
var scriptedInput = new VirtualInputSource();
builder.Services.AddTerminaVirtualInput(scriptedInput);
_ = Task.Run(async () =>
{
await Task.Delay(100);
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
scriptedInput.EnqueueString("Test message");
scriptedInput.EnqueueKey(ConsoleKey.Enter);
scriptedInput.EnqueueKey(ConsoleKey.Escape);
scriptedInput.Complete();
});
}Run in CI:
bash
dotnet run --project demos/Termina.Demo.RegionBased -- --testTesting Patterns
Unit Testing ViewModels
Test ViewModels in isolation:
csharp
[Fact]
public void HandleKeyPress_UpArrow_IncrementsCount()
{
// Arrange
var vm = new CounterViewModel();
vm.OnActivated();
// Act - simulate via reflection or test hooks
// vm.HandleKeyPress(new KeyPressed(...));
// Assert
Assert.Equal(1, vm.Count);
}Integration Testing
Test full page/ViewModel interaction:
csharp
[Fact]
public async Task TodoList_AddItem_AppearsInList()
{
var inputSource = new VirtualInputSource();
// Setup host...
_ = Task.Run(async () =>
{
await Task.Delay(100);
inputSource.EnqueueKey(ConsoleKey.A); // Start adding
inputSource.EnqueueString("New Task");
inputSource.EnqueueKey(ConsoleKey.Enter);
inputSource.EnqueueKey(ConsoleKey.Escape);
inputSource.Complete();
});
await host.RunAsync();
// Verify via ViewModel state
}Timing Considerations
Input processing is asynchronous. Use delays between related actions:
csharp
// Good - allows processing time
inputSource.EnqueueKey(ConsoleKey.A);
await Task.Delay(50);
inputSource.EnqueueString("text");
// Risky - might race
inputSource.EnqueueKey(ConsoleKey.A);
inputSource.EnqueueString("text"); // Might process before mode changeContinuous Integration
Example GitHub Actions workflow:
yaml
- name: Run Demo Tests
run: |
dotnet run --project demos/Termina.Demo.RegionBased -- --test
dotnet run --project demos/Termina.Demo.Streaming -- --testThe --test flag ensures demos exit automatically after scripted input.