Skip to content

Testing

Repl.Testing provides an in-memory harness that runs your full command graph without a real terminal — typed assertions, multi-session, interaction recording.

Terminal window
dotnet add package Repl.Testing
using Repl.Testing;
// Reuse your app factory
var host = ReplTestHost.Create(app =>
{
app.Map("list", (IContactStore store) => store.All());
app.Map("add {name} {email:email}", (string name, string email, IContactStore store) =>
{
store.Add(name, email);
return Results.Success($"Added {name}.");
});
}, services =>
{
services.AddSingleton<IContactStore, ContactStore>();
});

RunCommandAsync returns a CommandExecution with the output text, exit code, and the raw result object:

[TestMethod]
[Description("Verifies the full route-to-store binding: add command exits 0, and the new entry is visible in a subsequent list.")]
public async Task When_AddContact_Then_SuccessAndPersisted()
{
await using var session = await host.OpenSessionAsync();
var result = await session.RunCommandAsync("add Alice alice@example.com");
result.ExitCode.Should().Be(0);
result.OutputText.Should().Contain("Alice");
var list = await session.RunCommandAsync("list --json");
var contacts = list.ReadJson<IEnumerable<Contact>>();
contacts.Should().ContainSingle(c => c.Name == "Alice");
}

To access the typed handler return value directly, use GetResult<T>():

var execution = await session.RunCommandAsync("contacts 1 show");
var contact = execution.GetResult<Contact>();
contact.Name.Should().Be("Alice");

Scoped routes work as full paths — no special navigation method needed:

[TestMethod]
[Description("Commands in a scoped context run via the full route path.")]
public async Task When_ScopedCommand_Then_ExecutesCorrectly()
{
await using var session = await host.OpenSessionAsync();
await session.RunCommandAsync("add Alice alice@example.com");
var result = await session.RunCommandAsync("client 1 show");
result.ExitCode.Should().Be(0);
result.OutputText.Should().Contain("Alice");
}
[TestMethod]
[Description("Changes made by one session are visible to another via shared store.")]
public async Task When_TwoSessions_Then_SharedStateIsVisible()
{
await using var session1 = await host.OpenSessionAsync();
await using var session2 = await host.OpenSessionAsync();
await session1.RunCommandAsync("add Bob bob@example.com");
var result = await session2.RunCommandAsync("list --json");
var contacts = result.ReadJson<IEnumerable<Contact>>();
contacts.Should().ContainSingle(c => c.Name == "Bob");
}

Pass prompt answers as a dictionary keyed by prompt name. They are injected as --answer:name=value flags:

[TestMethod]
[Description("Interactive add command reads name and email from supplied answers.")]
public async Task When_InteractiveAdd_WithAnswers_Then_ContactCreated()
{
await using var session = await host.OpenSessionAsync();
await session.RunCommandAsync("add", new Dictionary<string, string>
{
["name"] = "Bob",
["email"] = "bob@example.com",
});
var result = await session.RunCommandAsync("list --json");
var contacts = result.ReadJson<IEnumerable<Contact>>();
contacts.Should().ContainSingle(c => c.Name == "Bob");
}

For session-wide answers (applied to every command), set them on SessionDescriptor.Answers when opening the session:

await using var session = await host.OpenSessionAsync(new SessionDescriptor
{
Answers = new Dictionary<string, string> { ["confirm"] = "yes" }
});

Inspect the interaction events recorded during execution:

var result = await session.RunCommandAsync("add", answers);
result.InteractionEvents.Should().HaveCount(2);

TimelineEvents combines output, interactions, and the final result into a chronological sequence.


  • Best Practices — test-first approach, testing at the command level, and interaction supply patterns.
  • Pipelining & Lifecycle — exit codes and how Results.* maps to observable outcomes.