From best kept secret to Open Source: My Logic Apps Standard Testing framework

From best kept secret to Open Source: My Logic Apps Standard Testing framework

8 minutes

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 |

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.

Reference and Call to Action

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

Your email address will not be published. Required fields are marked *