TestFramework.Azure
0.4.0
dotnet add package TestFramework.Azure --version 0.4.0
NuGet\Install-Package TestFramework.Azure -Version 0.4.0
<PackageReference Include="TestFramework.Azure" Version="0.4.0" />
<PackageVersion Include="TestFramework.Azure" Version="0.4.0" />
<PackageReference Include="TestFramework.Azure" />
paket add TestFramework.Azure --version 0.4.0
#r "nuget: TestFramework.Azure, 0.4.0"
#:package TestFramework.Azure@0.4.0
#addin nuget:?package=TestFramework.Azure&version=0.4.0
#tool nuget:?package=TestFramework.Azure&version=0.4.0
TestFramework.Azure
Introduction
TestFramework.Azure is an extension package for TestFramework.Core.
If you are new: TestFramework.Core runs your timeline, and this package adds Azure-specific triggers, events, and artifact helpers. You usually install and learn TestFramework.Core first, then add TestFramework.Azure when your tests need Azure resources.
Azure-focused test helpers for TestFramework.Core timelines.
TestFramework.Azure adds fluent building blocks for:
- Azure Function App calls (remote and in-process)
- Service Bus send/receive flows
- Azure artifacts (SQL, Cosmos DB, Table Storage, Blob Storage)
Install
dotnet add package TestFramework.Azure
Start Here
If you are new to the Azure package, learn it in this order:
- Pick stable identifier names such as
Default,MainDb, orMainSBQueueand keep those names consistent between timeline code and config. - Register the smallest config shape that matches the resource you need.
- Start with one canonical flow per resource family before mixing features.
Recommended first flows:
- Function App:
AzureExt.Trigger.FunctionApp.Http("Default") ... .Call() - Service Bus: send with
AzureExt.Trigger.ServiceBus.Send(...), then wait withAzureExt.Event.ServiceBus.MessageReceived(...) - Logic App: use
CallAndCapture()for stateless workflows,Call()plusRunCompleted(...)for stateful workflows - Data systems: start with one artifact or one finder against a single named identifier before composing larger end-to-end scenarios
Once those three ideas are clear, the broader support matrix below becomes much easier to navigate.
Minimal Setup
using TestFramework.Azure.Extensions;
using TestFramework.Config;
ConfigInstance config = ConfigInstance.FromJsonFile("local.testSettings.json")
.LoadAzureConfig()
.Build();
How Azure Config Fits The Core Config Model
ConfigInstance is still the normal setup entry point for Azure timelines.
Use it to:
- load JSON or create an empty base
- call
LoadAzureConfig()or related helpers - register extra services
- build the
IServiceProviderthat you pass intoSetupRun(...)
That means the usual shape stays:
- build the run provider with
ConfigInstance - let
LoadAzureConfig()register the shapes that describe each section - ask the run for a record when a step needs one:
context.Configured<CosmosContainerDbConfig>("MainDb")
Configured<T> is the only way to read a configured resource, and it deliberately does not tell you where the
answer came from. An entry someone wrote in a file and an address a container published while starting arrive
the same way, so a timeline that runs against a deployed Cosmos and one that runs against the emulator are the
same timeline.
If you are coming from ConfigStore<T>
Earlier versions registered a typed ConfigStore<T> per record and had you resolve it from the service
provider. That type is gone. It could only ever hold what someone had written down, so a step reading it got a
placeholder for anything a container had to start before its address existed.
// before
var config = provider.GetRequiredService<ConfigStore<CosmosContainerDbConfig>>().GetConfig("MainDb");
// now
var config = context.Configured<CosmosContainerDbConfig>("MainDb");
LoadAzureConfigs(...) no longer takes an IConfiguration either, for the same reason: the run holds the
configuration its shapes read.
Output Binding Mental Model
When Azure-oriented APIs expose callback URLs, response payloads, workflow run identifiers, or similar values, treat them as explicit result extraction from a step.
- they are not a second hidden execution model
- they are not a replacement for normal
TimelineRunassertions - they are the Azure-shaped way to surface a step result that the scenario can then register, assert, or pass forward explicitly
If a scenario starts to feel too Azure-magic-heavy, pull the extracted value into a named variable or artifact and keep the rest of the assertions generic.
Configuration Records
The Azure package reads named records from configuration sections through LoadAzureConfig() or LoadAzureConfigs(...).
Each DSL identifier such as "MainDb" or "Default" maps to one child object inside the matching section.
Supported record types:
| Section | Record type | Required fields | Optional fields | Used by |
|---|---|---|---|---|
FunctionApp:{identifier} |
FunctionAppConfig |
BaseUrl, Code |
AdminCode, RoutePrefix |
Remote Function App HTTP triggers and Function App liveness checks |
LogicApp:{identifier} |
LogicAppConfig |
no single global required field | HostingMode, WorkflowName, nested Standard and Consumption settings |
Logic App request triggers, timer triggers, run events, and Logic App liveness checks |
CosmosDb:{identifier} |
CosmosContainerDbConfig |
ConnectionString, DatabaseName, ContainerName |
None | Cosmos artifacts, query finders, and Cosmos liveness checks |
ServiceBus:{identifier} |
ServiceBusConfig |
ConnectionString plus either QueueName or TopicName |
SubscriptionName, RequiredSession |
Service Bus send triggers and message-received events |
StorageAccount:{identifier} |
StorageAccountConfig |
ConnectionString |
QueueContainerName, BlobContainerName, TableContainerName |
Blob artifacts, table artifacts, and storage liveness checks |
SqlDatabase:{identifier} |
SqlDatabaseConfig |
ConnectionString, DatabaseName |
— | SQL row artifacts, SQL query finders, and SQL liveness checks |
Breaking change:
FunctionAppConfig.BaseUrlis now strictly the site root and theapi/prefix comes from the newRoutePrefixproperty. If yourBaseUrlcurrently ends in/api/, drop that segment - otherwise every HTTP call resolves to/api/api/.... A custom[HttpTrigger(Route = ...)]now keeps the host prefix instead of losing it.
Configuration expectations:
FunctionAppConfig.BaseUrlis the site root. Do not include theapi/route prefix in it - host endpoints such asadmin/host/statusare resolved against this value directly.FunctionAppConfig.RoutePrefixdefaults toapiand mirrorsextensions.http.routePrefixinhost.json. It is applied exactly once, to every HTTP route the builders produce. Set it to""for a host configured with an empty route prefix.FunctionAppConfig.Codeis the normal trigger key;AdminCodeis the host master key, required by theadmin/...endpoints the managed trigger and host-status checks use.LogicAppConfig.HostingModedefaults toStandard.LogicAppConfig.Standardcontains the Standard-only values:BaseUrl,Code, andAdminCode.LogicAppConfig.Consumptioncontains the Consumption-only values:InvokeUrlandWorkflowResourceId.For Consumption request-trigger calls,
Consumption.InvokeUrlis enough. Think of that as invoke-only Consumption.For Consumption run polling, timer triggers, recurrence triggers, and management-backed liveness checks, set
Consumption.WorkflowResourceIdand registerILogicAppConsumptionManagementRequestAuthorizerin DI. Think of that as managed Consumption.The framework still does not discover Consumption URLs, resource IDs, or tokens from Azure on the user's behalf.
CosmosContainerDbConfigis container-specific. One identifier points to one database/container pair.ServiceBusConfigshould define either queue mode or topic mode. Queue mode usesQueueName. Topic mode usesTopicName, and fixed-subscription receives also requireSubscriptionName.StorageAccountConfigonly requires container names for the features you use. Blob and table liveness checks rely onBlobContainerNameandTableContainerNamerespectively.A
DbContextis registered through the SQL registry and nowhere else. There is noAddDbContextbeside it and noContextTypein configuration: the options handed to your callback already point at the database this run is using, whether you wrote its address down or a container published one while starting.services.AddSqlArtifactContexts(reg => reg .AddForIdentifier<AppDbContext>("MainSql", options => new AppDbContext(options)));
Logic App Support Matrix
Treat Logic App support as three capability levels instead of one large feature bucket:
| Mode | Required config | Works without ARM access | Supported features |
|---|---|---|---|
| Standard | Standard.BaseUrl, optional Standard.Code / Standard.AdminCode |
yes | invoke, run polling, timer/recurrence triggers, authenticated liveness |
| Consumption invoke-only | Consumption.InvokeUrl |
yes | manual request-trigger Call() and CallAndCapture(), basic reachability |
| Consumption managed | Consumption.InvokeUrl, Consumption.WorkflowResourceId, ILogicAppConsumptionManagementRequestAuthorizer |
no | everything from invoke-only plus RunCompleted(...), RunReachedStatus(...), timer/recurrence triggers, management-backed liveness |
Example JSON:
{
"FunctionApp": {
"Default": {
"BaseUrl": "https://my-functions.azurewebsites.net/",
"Code": "function-key",
"AdminCode": "admin-key"
}
},
"LogicApp": {
"StandardOrders": {
"WorkflowName": "OrderProcessor",
"Standard": {
"BaseUrl": "https://my-logic.azurewebsites.net/",
"Code": "workflow-key",
"AdminCode": "host-admin-key"
}
},
"ConsumptionOrders": {
"HostingMode": "Consumption",
"WorkflowName": "OrderProcessor",
"Consumption": {
"InvokeUrl": "https://prod-04.germanywestcentral.logic.azure.com/workflows/.../triggers/manual/paths/invoke?api-version=...&sp=...&sv=1.0&sig=...",
"WorkflowResourceId": "/subscriptions/.../resourceGroups/.../providers/Microsoft.Logic/workflows/OrderProcessor"
}
}
},
"CosmosDb": {
"MainDb": {
"ConnectionString": "AccountEndpoint=...;AccountKey=...;",
"DatabaseName": "AppDb",
"ContainerName": "Orders"
}
},
"ServiceBus": {
"MainSBQueue": {
"ConnectionString": "Endpoint=sb://...",
"QueueName": "orders",
"RequiredSession": false
},
"MainSBTopic": {
"ConnectionString": "Endpoint=sb://...",
"TopicName": "events",
"SubscriptionName": "integration-tests",
"RequiredSession": false
}
},
"StorageAccount": {
"MainStorage": {
"ConnectionString": "DefaultEndpointsProtocol=https;...",
"BlobContainerName": "exports",
"TableContainerName": "OrderAudit"
}
},
"SqlDatabase": {
"MainSql": {
"ConnectionString": "Server=...;Database=AppDb;...",
"DatabaseName": "AppDb"
}
}
}
Sample: Function App HTTP Call
using TestFramework.Azure;
using TestFramework.Core.Timelines;
Timeline timeline = Timeline.Create()
.Trigger(AzureExt.Trigger.FunctionApp.Http("Default").SelectEndpointWithMethod<HttpTests>(nameof(HttpTests.Run)).Call())
.Build();
TimelineRun run = await timeline.SetupRun(config.BuildServiceProvider()).RunAsync();
run.EnsureRanToCompletion();
SelectEndpointWithMethod<T>(...) expects the target method to carry both a [Function(...)] attribute and a parameter marked with [HttpTrigger(...)].
Function App Execution Modes
AzureExt.Trigger.FunctionApp exposes three different execution styles:
| Mode | Use when | Runtime dependency | Typical benefit |
|---|---|---|---|
Http(...) |
you want to call a deployed or container-hosted Function App over HTTP | reachable HTTP host plus FunctionAppConfig |
closest to production wiring |
Managed<T>(identifier, method) |
you want framework-managed invocation of a known function entry point | the function type is available to the test process | avoids hand-written HTTP request setup |
InProcessHttp<T>(...) |
you want to execute the function handler directly in-process | the function type and request delegate are available in the test process | fastest feedback and easiest offline unit-style validation |
Choose Http(...) for end-to-end behavior, Managed<T>(...) when you still want a Function App abstraction without a remote hop, and InProcessHttp<T>(...) when the test should stay entirely local to the current process.
InProcessHttp(...) Overload Guide
Use the smallest overload that matches what your function returns:
InProcessHttp<T>((request, context) => { ... })for synchronous handlers that return no result.InProcessHttp<T>((request, context) => Task.CompletedTask)for asynchronous handlers that return no result.InProcessHttp<T>((request, context) => new OkResult())for synchronous handlers that returnIActionResult.InProcessHttp<T>((request, context) => Task.FromResult<IActionResult>(...))for asynchronous handlers that returnIActionResult.
For remote calls, the equivalent decision point is different: start with Http(...), then choose either SelectEndpointWithMethod<T>(...) when the route can be inferred from the function metadata, SelectFunction(name, method) when you want the normal api/{functionName} route without spelling out the prefix yourself, or SelectEndpoint(path, method) when the test should supply the path and HTTP verb explicitly.
Example with explicit path, body, and headers:
Timeline timeline = Timeline.Create()
.Trigger(
AzureExt.Trigger.FunctionApp.Http("Default")
.SelectEndpoint(Var.Const("orders/42"), Var.Const(HttpMethod.Post))
.WithHeader(Var.Const("x-correlation-id"), Var.Const("order-42"))
.WithHeaders(Var.Const(new Dictionary<string, string> { ["x-tenant"] = "lab" }))
.WithBody(Var.Const("{\"id\":42}"))
.Call())
.Build();
Example with the default Function App route prefix:
Timeline timeline = Timeline.Create()
.Trigger(
AzureExt.Trigger.FunctionApp.Http("Default")
.SelectFunction("HttpEchoTest", HttpMethod.Post)
.WithBody(Var.Const("payload"))
.Call())
.Build();
Sample: Stateless Logic App Call And Capture
Use CallAndCapture() when the target workflow is stateless and completes inline with the callback response instead of exposing durable run history.
using TestFramework.Azure;
using TestFramework.Core.Timelines;
using TestFramework.Core.Variables;
Timeline timeline = Timeline.Create()
.Trigger(
AzureExt.Trigger.LogicApp.Http("logic")
.Workflow("StatelessOrders")
.Manual()
.WithBody(Var.Const("{\"id\":42}"))
.CallAndCapture())
.Name("logic-call")
.Build();
TimelineRun run = await timeline.SetupRun(config.BuildServiceProvider()).RunAsync();
run.EnsureRanToCompletion();
LogicAppExecutionResult result = Assert.IsType<LogicAppExecutionResult>(run.Step("logic-call").LastResult.Result);
Assert.Equal(LogicAppRunStatus.Succeeded, result.RunStatus);
Assert.Equal(HttpStatusCode.Accepted, result.StatusCode);
Use RunCompleted(...) and RunReachedStatus(...) only for stateful workflows. When Docker-hosted Logic App definitions are known to be stateless, the framework now fails fast with a message that points you to CallAndCapture().
Sample: Consumption Logic App Run Tracking
Consumption workflows now use a smaller config surface: provide the invoke URL for request triggers and add the workflow resource ID only when the test host should perform durable management operations.
using TestFramework.Azure.LogicApp;
Timeline timeline = Timeline.Create()
.Trigger(
AzureExt.Trigger.LogicApp.Http("ConsumptionOrders")
.Workflow("OrderProcessor")
.Manual()
.WithBody(Var.Const("{\"id\":42}"))
.Call())
.Name("logic-call")
.GetRunContext("logicRun")
.WaitForEvent(
AzureExt.Event.LogicApp.RunCompleted(
"ConsumptionOrders",
Var.Ref<LogicAppRunContext>("logicRun")))
.Build();
This run-tracking flow is managed Consumption. It needs Consumption.InvokeUrl plus Consumption.WorkflowResourceId, and the host must register ILogicAppConsumptionManagementRequestAuthorizer so the framework can authenticate ARM management requests for RunCompleted(...) and timer/recurrence trigger operations.
Use CallAndCapture() when the Consumption workflow is effectively stateless and returns the meaningful result directly in the callback response. That is the invoke-only path and does not require ARM access.
Live Validation Notes
- The live validation solution inside the repository now mirrors the same Consumption model: callback invoke URL plus workflow resource ID and an injected management-request authorizer.
- SQL remains the heaviest live-validation surface because it needs both a connection string and a registered
DbContextshape, which is a larger setup burden than the other Azure resources.
Service Bus Support Matrix
ServiceBusConfig supports three receive modes:
| Mode | Required fields | Notes |
|---|---|---|
| Queue | ConnectionString, QueueName |
SubscriptionName must be omitted. Works with session and non-session queues. Non-matching messages are abandoned so their lock is released. |
| Topic + subscription | ConnectionString, TopicName, SubscriptionName |
Use when the test should receive from a fixed subscription. Non-matching messages are abandoned so their lock is released. |
| Topic + temp subscription | ConnectionString, TopicName |
Call AzureExt.Event.ServiceBus.MessageReceived(..., createTempSubscription: true) to create and clean up a filtered temp subscription automatically. Filtering happens server-side, so nothing is abandoned. |
Message settlement:
- A matching message is completed by default. Pass
completeMessage: falseto opt out. - Leaving a matched message uncompleted returns it to the entity, where it can satisfy a later run's wait - a cross-run false pass. Only opt out when another consumer owns the message.
- In the shared modes, a non-matching message is abandoned rather than ignored. Ignoring it would hold its lock for the SDK's renewal window and starve concurrent runs; abandoning increases the message's
DeliveryCount, which is the accepted tradeoff. The first abandon of a run is logged as a warning.
Sample: Service Bus Queue Send + Wait
using Azure.Messaging.ServiceBus;
using TestFramework.Azure;
using TestFramework.Core.Timelines;
Timeline timeline = Timeline.Create()
.Trigger(AzureExt.Trigger.ServiceBus.Send("MainSBQueue", new ServiceBusMessage("Test message") { CorrelationId = "order-42" }))
.WaitForEvent(AzureExt.Event.ServiceBus.MessageReceived("MainSBQueue", correlationId: "order-42", completeMessage: true))
.WithTimeOut(TimeSpan.FromSeconds(10))
.Build();
Sample: Service Bus Topic Send + Temp Subscription Wait
Timeline timeline = Timeline.Create()
.WaitForEvent(AzureExt.Event.ServiceBus.MessageReceived("MainSBTopic", correlationId: "topic-1234", createTempSubscription: true, completeMessage: true))
.WithTimeOut(TimeSpan.FromSeconds(10))
.Trigger(AzureExt.Trigger.ServiceBus.Send("MainSBTopic", new ServiceBusMessage("Test message") { CorrelationId = "topic-1234" }))
.Build();
Cosmos Client Options Note
Use ConfigureCosmosClientOptions(...) when you need to customize the underlying Cosmos SDK client.
For AzureExt.Trigger.IsLive.Cosmos(...), timeout control comes from the normal timeline step timeout, for example via .WithTimeOut(...) on the builder. The optional AlivenessLevel lets you choose whether the check should stop at endpoint reachability, account authentication, or require the configured container to exist.
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.DependencyInjection;
using TestFramework.Azure;
using TestFramework.Azure.Extensions;
using TestFramework.Config;
using TestFramework.Core.Timelines;
ConfigInstance config = ConfigInstance.FromJsonFile("local.testSettings.json")
.AddService((services, configuration) =>
{
services.LoadAzureConfigs(configuration)
.ConfigureCosmosClientOptions(_ => new CosmosClientOptions
{
ConnectionMode = ConnectionMode.Gateway,
});
})
.Build();
Timeline timeline = Timeline.Create()
.Trigger(AzureExt.Trigger.IsLive.Cosmos("MainDb", AlivenessLevel.Authenticated))
.WithTimeOut(TimeSpan.FromSeconds(5))
.Build();
Sample: Find Data Artifact
using Microsoft.Azure.Cosmos;
using TestFramework.Azure;
Timeline timeline = Timeline.Create()
.FindArtifacts(
"cosmosItemQuery",
AzureExt.ArtifactFinder.DB.CosmosQuery<MyCosmosItem>(
"MainDb",
new QueryDefinition("SELECT * FROM c WHERE c.number = 1")))
.Build();
Target Framework
- .NET 8 (
net8.0)
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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
- Azure.Data.Tables (>= 12.9.1)
- Azure.Messaging.ServiceBus (>= 7.20.1)
- Azure.Storage.Blobs (>= 12.26.0)
- Microsoft.Azure.Cosmos (>= 3.59.0)
- Microsoft.Azure.Functions.Worker (>= 2.1.0)
- Microsoft.Azure.Functions.Worker.Extensions.Timer (>= 4.3.1)
- Microsoft.EntityFrameworkCore (>= 8.0.26)
- Microsoft.EntityFrameworkCore.SqlServer (>= 8.0.26)
- Newtonsoft.Json (>= 13.0.4)
- TestFramework.Config (>= 0.3.0)
- TestFramework.Core (>= 0.4.0)
-
net8.0
- Azure.Data.Tables (>= 12.9.1)
- Azure.Messaging.ServiceBus (>= 7.20.1)
- Azure.Storage.Blobs (>= 12.26.0)
- Microsoft.Azure.Cosmos (>= 3.59.0)
- Microsoft.Azure.Functions.Worker (>= 2.1.0)
- Microsoft.Azure.Functions.Worker.Extensions.Timer (>= 4.3.1)
- Microsoft.EntityFrameworkCore (>= 8.0.26)
- Microsoft.EntityFrameworkCore.SqlServer (>= 8.0.26)
- Microsoft.Extensions.Configuration (>= 10.0.0)
- Microsoft.Extensions.DependencyInjection (>= 10.0.0)
- Newtonsoft.Json (>= 13.0.4)
- TestFramework.Config (>= 0.3.0)
- TestFramework.Core (>= 0.4.0)
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 |
|---|
BREAKING - teardown now deletes what a run discovered. An artifact this package produces is deletable, and the timeline decides whether it may be deleted: chain MarkReadonly() onto the FindArtifact / FindArtifacts / RegisterArtifact call for a resource the run only reads. Requires TestFramework.Core 0.3.0.
Before this release the Cosmos item finder and the Table Storage entity finder handed back references teardown deleted, while a SQL row located by the EF Core finder was never deleted. That split was invisible at the call site and inconsistent between resource families. Now all three behave the same way and the decision is written where the artifact is declared.
What to change: a timeline that discovers Azure SQL rows and relies on them surviving the run must add MarkReadonly(), or teardown will delete rows the application under test owns. A timeline that already treated discovery as deleting needs no change. Cosmos and Table discovery is unchanged in effect - it deleted before and it deletes now.