Runic.Application.Testing
0.7.0-preview.5
See the version list below for details.
dotnet add package Runic.Application.Testing --version 0.7.0-preview.5
NuGet\Install-Package Runic.Application.Testing -Version 0.7.0-preview.5
<PackageReference Include="Runic.Application.Testing" Version="0.7.0-preview.5" />
<PackageVersion Include="Runic.Application.Testing" Version="0.7.0-preview.5" />
<PackageReference Include="Runic.Application.Testing" />
paket add Runic.Application.Testing --version 0.7.0-preview.5
#r "nuget: Runic.Application.Testing, 0.7.0-preview.5"
#:package Runic.Application.Testing@0.7.0-preview.5
#addin nuget:?package=Runic.Application.Testing&version=0.7.0-preview.5&prerelease
#tool nuget:?package=Runic.Application.Testing&version=0.7.0-preview.5&prerelease
Runic.Application.Testing
Test a generated Runic Window contract and its routed Views without a browser or
native window. RunicWindowTestHost<TViewModel> owns an in-memory transport, a real
WindowContentSession, and the generated root Bridge attachment, so tests run the
real ViewModels and generated .NET handlers. The caller owns the ViewModel and its
dependency injection scope.
Testing
Resolve the window's ViewModel and the generated attachment from the application's own services, with a test clock in place of the system clock:
var clock = new FakeTimeProvider(); // Microsoft.Extensions.Time.Testing
using var services = new ServiceCollection()
.AddSingleton<TimeProvider>(clock)
.AddNotes() // the application's registrations
.AddRunicViews() // generated by RunicBridgeCompositionType
.BuildServiceProvider();
using var scope = services.CreateScope();
using var host = new RunicWindowTestHost<ShellViewModel>(
scope.ServiceProvider.GetRequiredService<ShellViewModel>(),
scope.ServiceProvider.GetRequiredService<Func<IBridgeTransport, WindowContentSession, ShellViewModel, IDisposable>>(),
new RunicWindowTestHostOptions
{
ViewLocator = scope.ServiceProvider.GetRequiredService<IRunicViewLocator>(),
TimeProvider = clock,
});
Then drive the Window by ViewModel member, as the generated TypeScript client does:
var sidebar = host.Root.View<SidebarViewModel>(vm => vm.Sidebar);
(await sidebar.ExecuteAsync(vm => vm.OpenNotesCommand)).EnsureOk();
var editor = host.Root.View<DocumentViewModel>(vm => vm.Main)
.View<EditorViewModel>(vm => vm.CurrentPane);
editor.Set(vm => vm.Title, "Groceries").EnsureOk();
var save = editor.Start(vm => vm.SaveCommand);
Assert.True(editor.Snapshot().IsExecuting(vm => vm.SaveCommand));
clock.Advance(TimeSpan.FromMilliseconds(250)); // storage waits on the injected clock
Assert.Equal("succeeded", (await save.WaitAsync()).Kind);
Assert.Equal("Saved Groceries", editor.Snapshot().Read(vm => vm.SavedMessage));
The CommunityToolkit Notes example is a complete xUnit project.
Deterministic time and ids
With RunicWindowTestHostOptions, the window uses a
FakeTimeProvider
from Microsoft.Extensions.TimeProvider.Testing, which this package depends on, unless
the options supply another TimeProvider. Its time passes only through Advance or
SetUtcNow, which fire due timers, CancellationTokenSource timeouts and
Task.Delay(TimeSpan, TimeProvider) in order; a timer due now fires at once. The
window uses it for interaction deadlines and BeginCloseAsync timeouts; register
the same instance in the application's services for its own time. host.Time
returns it.
Work that waits on the clock does not finish by itself:
ExecuteAsyncof an awaited command that waits on the clock completes only after the test advances it. Start the call, advance the clock, then await the call:var save = editor.ExecuteAsync(vm => vm.SaveCommand); clock.Advance(TimeSpan.FromMilliseconds(250)); (await save).EnsureOk();Startreturns at admission, so an operation can be inspected while it runs.Only code that takes the
TimeProviderfollows it.Task.Delay(TimeSpan),DateTime.UtcNowand ReactiveUI's schedulers (Throttle,Timer,RxApp.MainThreadScheduler) keep using real time. Give such code the clock, for example through a scheduler built on it, or test it with ReactiveUI'sTestScheduler.
Content ids are 1, 2, ... in presentation order, so routes are content1,
content2, ... and snapshots are stable. Set CreateId for another sequence. The
drivers issue mount tokens test:mount1, ... and operation request ids request1,
....
The constructor without options keeps the system clock and random ids.
Drivers
host.Root drives the root ViewModel. View<T>(vm => vm.Content) and
Views<T>(vm => vm.Items) follow the content references in the current snapshot
and check that they present T; host.View<T>(reference) drives a reference you
already have. A RunicViewDriver<T> has:
Snapshot(): aRunicViewState<T>withRead(vm => vm.Title)for scalars,Getfor the raw JSON,Reference,References,Keysof a[RunicCollection],CanExecute,IsExecutingandFieldVersion.Set(vm => vm.Title, value), encoded as the generated client encodes it: anint,boolor non-nullablestringas is, and other scalars as wire JSON (Int64anddecimalas strings, enums by wire name,nullfor nullable values). UseSetJsonwith the wire JSON of a DTO, list or union.ExecuteAsync(vm => vm.SaveCommand, argumentJson), which waits for an asynchronous command, andCanExecute(command, argumentJson)for commands with an argument. A reply hasOk,ErrorKind,ErrorMessageandState;EnsureOk()throws for a failed call. A command's declared failure ([RunicFailure]) replies withErrorKinddomain-failedand the encoded failure asFailure, such as{"$case":"titleRequired"}.Start(command, argumentJson, requestId)for a recoverable operation, withStatus(),WaitAsync()andCancel(). A status hasKind,Result,ErrorMessageand, for adomain-failedoperation,Failure.Write(vm => vm.Title, value, expectedVersion)for a checked write. It uses the current value and version as the baseline unlessexpectedVersionis given, so a stale version tests theconflictreceipt.Mount()andUnmount(token)acknowledge a browser presentation.TakePublications()andNextPublicationAsync()return this route's pushed frames in order:State,Deltawith itsRunicCollectionChanges, orFailure.
Navigation (experimental)
A NavigationRegion<TContent> slot is an ordinary content slot, so the same host
and drivers apply. Create the navigator with the window's model context and pass
that context as RunicWindowTestHostOptions.ModelContext; a generated Bridge
rejects a session whose context is not the navigator's.
using Runic.Navigation; // RunicNavigator and RunicModelContext
#pragma warning disable RUNICNAV001
await using var context = new RunicModelContext();
await using var navigator = new RunicNavigator(new() { ModelContext = context, TimeProvider = clock });
var shell = new ShellViewModel(navigator);
using var host = new RunicWindowTestHost<ShellViewModel>(shell, attach,
new RunicWindowTestHostOptions { ModelContext = context, TimeProvider = clock });
// A scripted guard: the test page's CanDepartAsync returns decision.Task, which
// holds the transition in Guarding until the test decides.
var decision = new TaskCompletionSource<bool>();
editor.Departure = decision.Task;
var back = shell.Main.BackAsync();
decision.SetResult(true);
await back;
await navigator.WhenIdleAsync();
var home = host.Root.View<HomeViewModel>(vm => vm.Main); // the resumed entry
RunicNavigator.WhenIdleAsync()waits until no transition or retirement is running.navigator.UnretiredEntryCountcounts entries that have not finished retiring (zero afterDisposeAsync), andhost.Content.RetainedContentModelCount(), an extension on the window'sWindowContentSession, counts the content models whose model-context leases the window holds.- Pass a
FakeTimeProviderasRunicNavigatorOptions.TimeProviderto drive the close timeout and the overrun warning (event 1067) without waiting.
With AddRunicNavigation(), create the window scope with CreateAsyncScope(),
because the navigator is only IAsyncDisposable. Pass the scope's
IRunicModelContext as RunicWindowTestHostOptions.ModelContext:
await using var scope = services.CreateAsyncScope();
var shell = scope.ServiceProvider.GetRequiredService<ShellViewModel>();
using var host = new RunicWindowTestHost<ShellViewModel>(shell, attach, new RunicWindowTestHostOptions
{
ModelContext = scope.ServiceProvider.GetRequiredService<IRunicModelContext>(),
TimeProvider = clock,
});
Write navigation tests so they are deterministic:
- Hold a transition in a phase with a
TaskCompletionSourcethat a guard,InitializeAsyncorResumeAsyncawaits. Signal a second one from the hook when the test must know that the hook started. Then the test decides when the hook continues, instead of racing it. - Don't await a request whose guard waits for the test. Start it, wait for the
observable effect, answer, then await it. For example, start the Back, wait
until the dialog region's
Currentis set, execute the dialog's command through its driver, and then await the Back. A command driver'sStartreturns at admission, which suits a command that navigates. - Wait for region changes with
PropertyChanged, not with delays. Every change is raised inside a model turn. await navigator.WhenIdleAsync()before you assert on retirement: departing entries retire after the commit turn, and owned content is disposed then.- Assert lifetimes through the counters.
navigator.UnretiredEntryCountfalls as entries retire, and is zero afterDisposeAsync.RetainedContentModelCount()falls when owned content's leases are released. AssertRetiredstates and disposal counts of your own test content too. - Make overruns and timeouts happen by advancing the
FakeTimeProvider: 5 seconds for event 1067, andCloseTimeoutfor disposal. - To provoke a race, drive overlapping requests from a seeded
Random, and assert invariants that don't depend on timing. For example, every request ends, an entry retires at most once, and nothing is left unretired afterDisposeAsync.
The Notes tests
cover a guard that asks in a dialog region with PushForResult, resuming a
retained entry, retirement of a parent's child regions, racing navigation and
closing the window while the dialog asks.
Collections and deltas
Track() follows a route from a snapshot through its frames, as the generated
client does: newer full states replace the state, deltas must continue the tracked
revision and name the row keys they change, and failure notices are collected.
A frame the client could not apply throws, naming the problem: a lost or reordered
frame, an index outside the rows, a row key that does not match, an item count that
does not match the keys, or an empty or duplicate key.
var tracker = host.Root.View<HomeViewModel>(vm => vm.Main).Track();
library.Record("Groceries", "Milk, eggs");
var state = await tracker.WaitUntilAsync(state => state.Keys(vm => vm.RecentNotes)[0] == "Groceries");
Assert.Equal(0, tracker.FullStates); // only keyed changes were sent
Assert.Contains(tracker.Changes, change => change.Kind == "move");
tracker.Verify(); // the frames match a fresh snapshot
Publications are delivered off the calling thread, so waits use real time,
RunicWindowTestHostOptions.PublicationTimeout (five seconds by default). A route's
frames go to its tracker or to TakePublications, not both.
Lower level
host.Transport is the in-memory transport: Call(...) and CallAsync(...) invoke
any route with ViewTestArguments, and DrainPublications() returns raw
publications (drivers then miss them). Mount, Unmount and
Content.ReleaseConnection test View lifetimes and client disconnects. Await
BeginCloseAsync before disposing a host that has accepted background operations;
with a fake clock, its timeout passes only when the clock advances.
The host does not instantiate the application's native Window wrapper or run the
generated TypeScript client or a rendering framework. Test the frontend with the
generated typed mocks of @runic-artifex/views/mock; see its
Testing guide.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- Microsoft.Extensions.TimeProvider.Testing (>= 10.10.0)
- Runic.Application (= 0.7.0-preview.5)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.7.0-preview.9 | 0 | 10/14/2026 |
| 0.7.0-preview.8 | 73 | 10/12/2026 |
| 0.7.0-preview.7 | 50 | 10/12/2026 |
| 0.7.0-preview.6 | 394 | 10/9/2026 |
| 0.7.0-preview.5 | 126 | 10/9/2026 |
| 0.7.0-preview.4 | 71 | 10/8/2026 |
| 0.7.0-preview.3 | 61 | 10/8/2026 |
| 0.7.0-preview.2 | 64 | 10/7/2026 |
| 0.7.0-preview.1 | 58 | 10/7/2026 |
| 0.6.0-preview.1 | 557 | 10/5/2026 |
| 0.5.0-preview.3 | 68 | 10/1/2026 |
| 0.5.0-preview.2 | 65 | 9/25/2026 |
| 0.4.0-preview.1 | 66 | 9/22/2026 |
| 0.3.0-preview.1 | 79 | 9/11/2026 |
| 0.2.0-preview.1 | 93 | 9/9/2026 |