Introduction
Over the past years, I have spent a significant amount of time working with Azure Logic Apps. Like many others, I started with Logic Apps Consumption and gradually transitioned into Logic Apps Standard as it matured and became more powerful and flexible. Along that journey, one thing kept coming back again and again: testing.
Testing Logic Apps is not straightforward. At least, not if you want to do it properly. Not if you want confidence before deploying to production. And definitely not if you want your tests to be part of a serious CI/CD pipeline.
What started as a small experiment grew into something bigger. In my early attempts, I relied on parts of the Azure SDK and built a lightweight approach to validate workflows. That became version one. In version two, I introduced a more object-oriented approach in C#, enabling retrieval of workflows, runs, and actions, and even triggering workflows for testing purposes. It worked, but it had limitations – especially around nested structures and container actions like conditions, loops, and switches. Performance was also not where I wanted it to be.
So I started over.
For the last couple of months, in my spare time, I have been building what I now consider the third version of this framework. I have put not only time and effort into it, but also a lot of passion and love. It is the best version I have created so far.
Is it complete? No, I’m sure it’s not complete. Can you test most common scenarios? Absolutely.
And today, I am sharing it with the integration community!
Why?
If you have worked with Logic Apps Standard, you probably know that testing options are limited.
Microsoft provides several tools for testing Logic Apps Standard. This framework takes a fundamentally different approach and addresses gaps that none of the Microsoft tools cover.
| Microsoft Offering | Scope | Limitation |
|---|---|---|
| Built-in Automated Test Framework | Visual Studio Code extension | Requires manual test case creation in VS Code, no programmatic API, no CI/CD integration without custom scripting, limited assertion capability |
| Automated Test SDK | .NET SDK for unit-style tests | Runs workflows locally in isolation, not against a live deployed environment, no real connector execution |
| Unit Test Generation (VS Code) | Code generation tool | Generates unit tests for local execution only, still requires manual assertion authoring, no integration-level testing |
| Mocking / Static Results Testing | Local mock execution | Replaces connector calls with static values locally, not a substitute for real deployed integration testing |
| Data Mapper Test Executor | XSLT/map validation | Validates data mapping logic in isolation only, no workflow context |
| Local Debugging + Test Execution (VS Code) | Developer-time tooling | Manual, interactive, not automatable in a pipeline |
Where this framework is different:
- It runs against the real deployed environment in Azure, testing actual integrations instead of local or mocked executions.
- It is built for DevOps pipelines, where each scenario runs as a standard NUnit test with clear pass/fail outcomes.
- It provides a proper object model, allowing you to navigate workflow runs, inspect nested actions, and validate behavior with simple, strongly typed calls.
- It supports real end-to-end validation, including complex workflow chains with nested loops, conditions, and correlated instances.
- It enables transformation testing with real data and supports controlled mocking when needed, without impacting live systems.
Bottom line: Microsoft’s tools are valuable during development in VS Code. This framework is what you use after deployment to verify that your integrations actually work in a real environment.
What?
This open-source solution is a complete testing framework for Logic Apps Standard.
It is not just a collection of utilities. It is a structured approach to integration testing that allows teams to validate their workflows with confidence.
At a business level, what this means is simple:
- You reduce risk when deploying changes
- You catch issues before they hit production
- You gain confidence in complex integrations
- You enable teams to move faster without breaking things
It is built with real-world scenarios in mind, where workflows are not trivial and where integrations involve multiple systems like Service Bus, Storage Accounts, and external APIs.
Whether you prefer writing tests in C# or using Gherkin for more readable, business-driven scenarios, the framework supports both.
How?
The framework consists of several key building blocks that work together to enable full integration testing.
Management Framework
At the core is a management layer that allows you to:
- Retrieve Logic Apps and workflows
- Trigger workflows programmatically
- Inspect workflow runs
- Traverse actions, including deeply nested ones
This is where the biggest improvement over previous versions lies. Nested structures and container actions are now fully supported, allowing you to validate complex workflows in a structured way.
Specifications
You can define expectations and assertions using specifications. These allow you to express what should happen in a workflow in a clear and reusable manner.
Factories for dependencies
To make integration testing complete, the framework includes factories for common Azure services such as:
- Service Bus
- Storage Accounts
This allows you to set up test data, send messages, and validate outputs as part of your tests.
C# Example
Here is an example based on using the management framework directly in .NET:
Setup:
using LogicApps.Management;
using LogicApps.Management.Factory;
using LogicApps.Management.Helper;
using LogicApps.Management.Repository;
using Microsoft.Extensions.DependencyInjection;
var configuration = AppSettings.Configuration;
var baseAddress = new Uri("https://management.azure.com");
var services = new ServiceCollection();
services.AddHttpClient("AzureManagementClient", client =>
{
client.BaseAddress = baseAddress;
client.DefaultRequestHeaders.Add("accept", "application/json");
client.Timeout = TimeSpan.FromMinutes(5);
});
services.AddHttpClient("AzurePublicHttpClient", client =>
{
client.Timeout = TimeSpan.FromMinutes(5);
});
services.AddHttpClient("EntraTokenClient");
var serviceProvider = services.BuildServiceProvider();
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
var tokenClient = new EntraTokenClient(httpClientFactory);
var azureHttpClient = new AzureHttpClient(
httpClientFactory,
tokenClient,
baseAddress,
configuration["TenantId"]!,
configuration["ClientId"]!,
configuration["ClientSecret"]!);
var repository = new AzureManagementRepository(azureHttpClient, baseAddress);
var actionHelper = new ActionHelper(repository);
var actionFactory = new ActionFactory(configuration, repository, actionHelper);
var logicApp = await LogicApp.CreateAsync(
configuration,
repository,
actionFactory,
actionHelper,
loadRunsSince: DateTime.UtcNow.AddHours(-24));
Retrieving workflows and runs:
// Get all workflows in the Logic App var workflows = await logicApp.GetWorkflowsAsync(); // Find a specific workflow by name var workflow = workflows.FirstOrDefault(w => w.Name == "prc"); // Get all runs for the workflow (cached after first call) var runs = await workflow!.GetWorkflowRunsAsync(); // Filter to succeeded runs only var succeededRuns = runs.Where(r => r.Status == "Succeeded").ToList(); // Get the most recent run var latestRun = runs.MaxBy(r => r.StartTime); // Get runs by correlation ID var correlatedRun = runs.FirstOrDefault(r => r.CorrelationId == myCorrelationId); // Clear the cache and reload from Azure await workflow.ReloadAsync();
Trigger a workflow:
var trigger = await workflow!.GetTriggerAsync();
// Trigger without a body (e.g. recurrence workflows)
await trigger.Run(null);
// Trigger with a JSON body (e.g. HTTP-triggered workflows)
using var content = new StringContent("{\"key\":\"value\"}", new MediaTypeHeaderValue("application/json"));
await trigger.Run(content);
// Trigger with additional request headers
var headers = new Dictionary<string, string> { { "x-custom-header", "value" } };
await trigger.Run(content, headers);
Navigating the workflow run action tree or finding actions anywhere:
var run = runs.First();
// Get top-level actions for this run
var actions = await run.GetWorkflowRunActionsAsync();
foreach (var action in actions)
{
Console.WriteLine($"{action.DesignerName}: {action.Status}");
Console.WriteLine($" Start: {action.StartTime}, End: {action.EndTime}");
// Read the raw action output (JToken)
if (action.Output != null)
Console.WriteLine($" Output: {action.Output}");
// Read error information when the action failed
if (action.Error != null)
Console.WriteLine($" Error: {action.Error.Code} - {action.Error.Message}");
}
//
// Returns all actions matching the name, at any nesting depth
var matchingActions = await run.FindActionByNameAsync("Transform source data type to target data type");
var transformAction = matchingActions?.FirstOrDefault();
Console.WriteLine($"Status: {transformAction?.Status}");
Console.WriteLine($"Output: {transformAction?.Output}");
Build and send claim-checks with Service Bus:
var (message, properties) = ServiceBusMessageBuilder
.Create()
.WithClaimCheck(fileName)
.WithCorrelationId(correlationId)
.WithMessageType("demoMessageType") // adds messageType application property
.AddProperty("sender", "rcv") // adds arbitrary application property
.Build();
await ServiceBusMessageSender.SendAsync("sbt-sourcesystem-out", message, properties);
Put payload in storage:
var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Include
});
var content = BlobRequestBuilder.Build(json, fileName);
await BlobStorageSender.UploadAsync(container, fileName, content);
This allows you to go beyond a simple success check and actually validate what happened inside your workflow.
Gherkin Example
If you prefer a more business-readable format, you can use Gherkin:
When Workflow "prc-nestedloops-and-do-until" is triggered # 1 API call - cached
Then The workflow executed these actions: # uses cache
| StepName | Status |
| Initialize variables | Succeeded |
And The "Set variable" action has status "Succeeded" # uses cache
And In "Try": # uses cache
| StepName | Status |
| For each number | Succeeded |
And All iterations of "For each number" executed: # uses cache
| StepName | Status |
| For each letter | Succeeded |
And In "Try.Until[1].Until2[2].Until3[2]":
| StepName | Status |
| Increment variable counter3 | Succeeded |
This shows how you can validate even deeply nested structures in a clear and expressive way. For more advanced scenarios, refer to the full testing guide.
How the framework translates Gherkin to the Management library
This section explains the internal call chain from a Gherkin step down to the LogicApps.Management object model. It focuses on the most important scenarios: triggering, action lookup, loop navigation (including nested loops), condition branches, path-based navigation, and transformation output capture.
Architecture Overview
Gherkin scenario (.feature file)
│
▼
Concrete StepDefinition class
extends BaseStepDefinition (or BaseTransformationStepDefinition)
│
▼
WorkflowRunValidation
uses WorkflowRunNavigator ← depth-first search across the full action tree
uses ActionPathNavigator ← dot-path navigation for structural targeting
│
▼
LogicApps.Management object model
LogicApp → Workflow → WorkflowRun → BaseAction
(ScopeAction / ConditionAction / SwitchAction / ForEachAction / UntilAction)
│
▼
Azure Management REST API (live, deployed environment)
BaseStepDefinition is the single place that owns the LogicApp instance and the _currentWorkflowRuns list. Every [Then] step delegates to a WorkflowRunValidation instance, which uses WorkflowRunNavigator (tree search) and ActionPathNavigator (path-based) to locate and assert on actions.
It is too much of information to guide you through the approach in this article. For further details please have a look here.
Reference and Call to Action
You can find the full project here:
https://github.com/sahinozd/LogicAppsStandard.Testing
NuGet packages are available here:
https://www.nuget.org/profiles/sahinozd
I invite you to try it out and use it in your own projects, if it fits your needs.
If you see opportunities to improve it, contribute to it, have feedback, or ideas… they are welcome. Keep in mind that my time is limited, but I am always interested in seeing how others use it and how it can evolve further.
Closing
This started as a personal challenge. A way to solve a problem I kept running into. If you are working with Logic Apps Standard and you care about quality, testing, and reliability, I believe this can make a real difference.
Enjoy!


Leave a Reply