Test activities

Unit-test activities with ActivityTestHost, test durable activities and scopes, check the packed .nupkg with package tests, and verify in Studio and on a Robot.

Test at three levels: each activity on its own with ActivityTestHost, the packed .nupkg with package tests, and the whole package in Studio and on a Robot. Create a test project# ActivityTestHost ships in Velophex.Workflow.Sdk, so a test project needs nothing from VeloPhex beyond a reference to your runtime project. Any test framework works; this page uses xUnit. tests/Contoso.Invoices.Activities.Tests/Contoso.Invoices.Activities.Tests.csprojXMLCopy<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <IsPackable>false</IsPackable> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\..\src\Contoso.Invoices.Activities\Contoso.Invoices.Activities.csproj" /> </ItemGroup> </Project> Add it to the solution with dotnet sln Contoso.Invoices.slnx add tests\Contoso.Invoices.Activities.Tests. The template's Directory.Build.props applies to it too, including TreatWarningsAsErrors. Unit tests with ActivityTestHost# ActivityTestHost (namespace Velophex.Workflow.Activities.Testing) runs one activity with the same context implementation and the same service restrictions it has in a workflow, and captures what it did. ValidateInvoiceNumberTests.csC#Copyusing Velophex.Workflow.Activities.Testing; using Xunit; namespace Contoso.Invoices.Activities.Tests; public sealed class ValidateInvoiceNumberTests { [Theory] [InlineData("INV-123456", true)] [InlineData("INV-12", false)] [InlineData("", false)] public async Task Checks_the_format(string number, bool expected) { ActivityTestHost host = new(); ActivityTestResult result = await host.RunAsync( new ValidateInvoiceNumberActivity(), new Dictionary<string, object?> { ["InvoiceNumber"] = number }); Assert.True(result.Completed); Assert.Equal(expected, result.Outputs["IsValid"]); } } RunAsync(activity, inputs) takes input values by argument name. An unknown name, an Out argument or a value of the wrong type throws ArgumentException, so a renamed argument breaks the test instead of passing silently. An exception the activity throws comes out of RunAsync. Assert on it with Assert.ThrowsAsync<BusinessRuleException>(...). ActivityTestResult has Completed, Outputs (the Out and InOut values the activity set), Bookmarks, Annotations and LogMessages (each line as "<level>: <message>", for example "Warning: looking"). Give the activity services# Pass an IServiceProvider to the constructor for anything the activity resolves with context.GetService<T>(): C#Copyprivate sealed class Services(object service) : IServiceProvider { public object? GetService(Type serviceType) => serviceType.IsInstanceOfType(service) ? service : null; } ActivityTestHost host = new(new Services(new InvoiceCache())); The host also offers RequestStop() (after which context.IsStopRequested is true), LogFields, Verifications (what RecordVerification recorded) and ProtectedSecrets (secrets an activity handed to ProtectSecret). Test a durable activity# ResumeAsync resumes a waiting result's bookmark on a new activity object, as a Robot does after the workflow was saved: WaitForInvoiceApprovalTests.csC#Copy[Fact] public async Task Waits_then_completes_on_resume() { ActivityTestHost host = new(); ActivityTestResult waiting = await host.RunAsync( new WaitForInvoiceApprovalActivity(), new Dictionary<string, object?> { ["InvoiceNumber"] = "INV-123456", ["WaitHours"] = 1 }); Assert.False(waiting.Completed); Assert.Equal(["invoice-approval"], waiting.Bookmarks); ActivityTestResult done = await host.ResumeAsync(waiting, "invoice-approval", true); Assert.True(done.Completed); Assert.Equal(true, done.Outputs["Approved"]); } Test a scope# The host's Scopes and Resources are shared by every activity it runs, so run the open step first and then the child: GreetScopeTests.csC#Copy[Fact] public async Task Greet_uses_the_enclosing_scope() { ActivityTestHost host = new(); // The open step pushes a frame and registers the greeter on the host. await host.RunAsync(new UseGreeterActivity(), new Dictionary<string, object?> { ["Salutation"] = "Hi" }); ActivityTestResult result = await host.RunAsync( new GreetActivity(), new Dictionary<string, object?> { ["Name"] = "Ada" }); Assert.Equal("Hi, Ada! (#1)", result.Outputs["Greeting"]); } You can also push a frame yourself with host.Scopes.Push(kind, reference) and host.Resources.Register(reference, resource) to give a child a fake session. What the test host does not cover# It runs one activity, not a workflow. A ContainerActivity cannot schedule its slots, and compile-time checks (validators, [Range], required arguments) do not run. It does not apply Timeout, retries or Continue On Error; those are the engine's. It calls real external systems if your activity does. Put the external call behind an interface you resolve with GetService<T>(), or point the activity at a test endpoint. Package tests# Package tests open the packed .nupkg and check what Studio and the Robot will see. Mark them with a category so you can run them after dotnet pack: PackageTests.csC#Copyusing System.IO.Compression; using System.Text.Json; using Xunit; namespace Contoso.Invoices.Activities.Tests; // Run after packing: dotnet pack Contoso.Invoices -c Release -o out [Trait("Category", "Package")] public sealed class PackageTests { private static string PackagePath => Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "out"), "Contoso.Invoices.Activities.*.nupkg") .OrderByDescending(File.GetLastWriteTimeUtc) .First(); [Fact] public void Manifest_declares_every_activity_with_an_icon() { using ZipArchive package = ZipFile.OpenRead(PackagePath); using Stream stream = package.GetEntry("velophex/activity-manifest.json")!.Open(); using JsonDocument manifest = JsonDocument.Parse(stream); JsonElement root = manifest.RootElement; Assert.Equal("Contoso.Invoices.Activities", root.GetProperty("packageId").GetString()); string[] typeIds = [.. root.GetProperty("activities").EnumerateArray().Select(a => a.GetProperty("typeId").GetString()!)]; Assert.Contains("contoso.invoices.validateInvoiceNumber", typeIds); Assert.All(typeIds, id => Assert.StartsWith("contoso.invoices.", id)); foreach (JsonElement activity in root.GetProperty("activities").EnumerateArray()) { string icon = activity.GetProperty("icon").GetString()!; Assert.NotNull(package.GetEntry($"icons/{icon}.svg")); } } } Useful things to assert: The type ids you have shipped are all still there, with the same argument names and directions. A missing one breaks saved workflows. Every type id uses your prefix. lib/ holds only your assemblies, and the .nuspec declares every third-party dependency. engineVersionRange is what you expect. PowerShellCopydotnet pack Contoso.Invoices -c Release -o out dotnet test Contoso.Invoices --filter Category=Package Test in Studio and on a Robot# Install the package in a Studio project from a trusted local source (see Create an activity package). Check the toolbox entry, the icon, the card and the Properties panel, including conditions and validation in the Error List. Build a small workflow that uses each activity and run it. Log output appears in the Output panel. See Run and debug. Publish the project and run it as a job on a Robot that can restore your package (see Publish and install). This is the only test of the full restore path, including your dependencies. Next steps# Package, version and sign

Create a test project

Unit tests with ActivityTestHost

Give the activity services

Test a durable activity

Test a scope

What the test host does not cover

Package tests

Test in Studio and on a Robot

Next steps