Skip to content

Testing

The ConsoleMVC.Testing package runs a whole application in a test: it types the user's input from a script, records every screen as plain text, and compares screens with snapshot files kept beside your tests. It works with xUnit, NUnit and MSTest.

New applications can start with a test project: dotnet new consolemvc -n MyApp --tests creates MyApp/MyApp.Tests with a snapshot of the home menu and a test of the greeting form, ready for dotnet test MyApp/MyApp.Tests. For an existing application:

dotnet new xunit -n MyApp.Tests
dotnet add MyApp.Tests reference MyApp
dotnet add MyApp.Tests package ConsoleMVC.Testing

When the test project sits inside the application's folder (as the template puts it), keep its files out of the application with <DefaultItemExcludes>$(DefaultItemExcludes);MyApp.Tests/**</DefaultItemExcludes> in the application's project file.

Running the application

using ConsoleMVC.Testing;

public class GreetingTests
{
    private static MvcTestHost Host() => MvcTestHost.For<HomeController>(builder =>
    {
        builder.Services.AddSingleton<IGreetingService, GreetingService>();
        builder.Services.Configure<GreetingOptions>(o => o.Salutation = "Hi");
    });

    [Fact]
    public void Greeting_ShowsTheName()
    {
        var run = Host().Run("2", "Ada", "3", "n", "q");

        Assert.Equal(["Home/Index", "Greet/Index", "Greet/Result"], run.Routes);
        Assert.Contains("Hi, Ada!", run.LastScreen);
    }
}

MvcTestHost.For<T>() finds the controllers and views in the assembly of T. The callback configures the builder as Program.cs does, but Program.cs itself does not run: register the services the screens need, with fakes where you want them. Each Run builds a fresh application, so tests do not share state.

Setting Default Purpose
Width 80 The terminal width screens are laid out to
MaxScreens 100 Fails a run that keeps navigating without waiting for input, instead of hanging
EnvironmentName Testing builder.Environment, so appsettings.Testing.json is read when it is copied to the test output
ConfigureApplication none Configures the built application as Program.cs does after Build(): app.Use(...) middleware, app.MapKey(...) shortcuts

TestRun holds what the run produced:

Member Value
Screens Every screen, oldest first: the framework clears the console before each view, and each clear starts a new entry
LastScreen The final screen
Output All screens one after another
Routes Controller/Action for every navigation cycle, redirects included
RemainingInput Script steps the application never read

When the script ends, menus quit, so a run always finishes.

Scripting lines and keys

ScriptedInput holds typed lines and key presses in order:

var input = new ScriptedInput("2", "Ada")               // lines: a menu choice, then a name
    .Keys(ConsoleKey.DownArrow, ConsoleKey.Enter)       // a <select> answered with the arrow keys
    .Key(ScriptedInput.Char('y'))                       // a <checkbox> answered with y
    .Line("q");

var run = Host().Run(input);

A <select>, <radio> or <checkbox> uses the arrow keys when the next step of the script is a key, and a typed answer when it is a line, so both ways can be tested. A step of the wrong kind (a key where the application reads a line) fails with a message naming both.

Wait() is a pause in which nothing is typed, for screens that refresh with @refresh or RefreshEvery: the screen uses it up and is drawn again. Elsewhere a Wait() step fails the run.

var run = Host().Run(new ScriptedInput("4", "2").Wait().Line("b")); // feature tour, live status, one refresh, back

With builder.Mouse on, Click(column, row) clicks a place on the screen (counted from 0 at the top left of the current screen) and Wheel(down) turns the wheel:

var run = Host().Run(new ScriptedInput().Click(2, 3).Line("Ada")); // click the option on the fourth row

Snapshots

Snapshot.Match compares text with a file in a __snapshots__ folder beside the test file:

Snapshot.Match(run.LastScreen, "greeting-result");     // __snapshots__/greeting-result.txt
Situation Result
The file does not exist It is created and the test passes; commit it
The file does not exist and CI is set (build servers) The test fails, so a snapshot is never accepted unseen
The text is the same The test passes (line endings are ignored)
The text differs The test fails with the first different line, and the new text is saved as greeting-result.received.txt beside the snapshot
CONSOLEMVC_UPDATE_SNAPSHOTS=1 Differences replace the snapshot: CONSOLEMVC_UPDATE_SNAPSHOTS=1 dotnet test

Snapshot names may contain letters, digits, ., - and _, so a snapshot is never read or written outside its folder. Add *.received.txt to .gitignore.

Assembling a run yourself

ScreenRecorder and ScriptedInput also work without MvcTestHost, for example to keep Program.cs's own builder settings or to render one application at several widths:

var recorder = new ScreenRecorder();
var builder = MvcApplication.CreateBuilder([]);
builder.ApplicationAssembly = typeof(HomeController).Assembly;
builder.Renderer = new MarkupRenderer(recorder, () => 60);
builder.Input = new ScriptedInput("q");

using var app = builder.Build();
app.Run();

Controllers can also be tested on their own: create one with Router.ResolveController(name, services), or construct it directly and check the ViewResult it returns.