Runic.Application.Testing 0.7.0-preview.5

This is a prerelease version of Runic.Application.Testing.
There is a newer prerelease version of this package available.
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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Runic.Application.Testing" Version="0.7.0-preview.5" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Runic.Application.Testing" Version="0.7.0-preview.5" />
                    
Directory.Packages.props
<PackageReference Include="Runic.Application.Testing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Runic.Application.Testing --version 0.7.0-preview.5
                    
#r "nuget: Runic.Application.Testing, 0.7.0-preview.5"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Runic.Application.Testing@0.7.0-preview.5
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Runic.Application.Testing&version=0.7.0-preview.5&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Runic.Application.Testing&version=0.7.0-preview.5&prerelease
                    
Install as a Cake Tool

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:

  • ExecuteAsync of 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();
    

    Start returns at admission, so an operation can be inspected while it runs.

  • Only code that takes the TimeProvider follows it. Task.Delay(TimeSpan), DateTime.UtcNow and 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's TestScheduler.

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(): a RunicViewState<T> with Read(vm => vm.Title) for scalars, Get for the raw JSON, Reference, References, Keys of a [RunicCollection], CanExecute, IsExecuting and FieldVersion.
  • Set(vm => vm.Title, value), encoded as the generated client encodes it: an int, bool or non-nullable string as is, and other scalars as wire JSON (Int64 and decimal as strings, enums by wire name, null for nullable values). Use SetJson with the wire JSON of a DTO, list or union.
  • ExecuteAsync(vm => vm.SaveCommand, argumentJson), which waits for an asynchronous command, and CanExecute(command, argumentJson) for commands with an argument. A reply has Ok, ErrorKind, ErrorMessage and State; EnsureOk() throws for a failed call. A command's declared failure ([RunicFailure]) replies with ErrorKind domain-failed and the encoded failure as Failure, such as {"$case":"titleRequired"}.
  • Start(command, argumentJson, requestId) for a recoverable operation, with Status(), WaitAsync() and Cancel(). A status has Kind, Result, ErrorMessage and, for a domain-failed operation, Failure.
  • Write(vm => vm.Title, value, expectedVersion) for a checked write. It uses the current value and version as the baseline unless expectedVersion is given, so a stale version tests the conflict receipt.
  • Mount() and Unmount(token) acknowledge a browser presentation.
  • TakePublications() and NextPublicationAsync() return this route's pushed frames in order: State, Delta with its RunicCollectionChanges, or Failure.

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.UnretiredEntryCount counts entries that have not finished retiring (zero after DisposeAsync), and host.Content.RetainedContentModelCount(), an extension on the window's WindowContentSession, counts the content models whose model-context leases the window holds.
  • Pass a FakeTimeProvider as RunicNavigatorOptions.TimeProvider to 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 TaskCompletionSource that a guard, InitializeAsync or ResumeAsync awaits. 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 Current is set, execute the dialog's command through its driver, and then await the Back. A command driver's Start returns 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.UnretiredEntryCount falls as entries retire, and is zero after DisposeAsync. RetainedContentModelCount() falls when owned content's leases are released. Assert Retired states and disposal counts of your own test content too.
  • Make overruns and timeouts happen by advancing the FakeTimeProvider: 5 seconds for event 1067, and CloseTimeout for 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 after DisposeAsync.

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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