Skip to content

Testing

Termina provides VirtualInputSource for automated testing without a real terminal.

Overview

Testing TUI applications typically requires:

  1. Simulating keyboard/mouse input
  2. Verifying state changes
  3. 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

MethodDescription
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 -- --test

Testing 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 change

Continuous 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 -- --test

The --test flag ensures demos exit automatically after scripted input.

Released under the Apache 2.0 License.