DEV Community

Daniel Jonathan
Daniel Jonathan

Posted on

Testing Azure Logic Apps: LogicAppUnit Framework Complete Guide

TL;DR

Testing Azure Logic Apps Standard requires LogicAppUnit - a framework that rewrites workflows in-memory, converting built-in/managed connectors to HTTP calls. Success hinges on understanding HTTP interception vs connector conversion and proper configuration across 4 files. Master @appsetting() usage, and remember: managed connectors need "Raw" authentication for testing (not ManagedServiceIdentity).


What You'll Learn

βœ… How LogicAppUnit rewrites workflows in-memory
βœ… HTTP interception vs built-in/managed connector conversion
βœ… Managed connector authentication limitation (Raw only!)
βœ… Essential 4-file configuration pattern
βœ… Why @appsetting() is critical
βœ… Framework capabilities and assertions
βœ… Top 3 failure modes and fixes


The Challenge

Logic Apps are declarative JSON workflows, not code. Standard testing frameworks can't parse workflow definitions, intercept connector actions, or provide workflow-specific assertions. You need LogicAppUnit.


How LogicAppUnit Executes Your Workflow

Understanding how the framework works is critical:

  1. Workflow JSON is loaded, parameters resolved via @appsetting().
  2. Actions are rewritten to hit http://localhost:7075 (in-memory mock server).
  3. Retry policies are set to none - failures surface immediately (no waiting).
  4. Your mocks respond; anything not rewritten calls the real service (this is usually a mistake).

This rewriting process depends on the action type, which brings us to the most important concept...


The Critical Concept: Interception vs Conversion

This is the #1 thing to understand (with a special gotcha for managed connectors):

πŸ“Œ HTTP Actions β†’ Intercepted

// workflow.json
{
  "type": "Http",
  "inputs": {
    "uri": "@{parameters('apiUrl')}/Products",
    "method": "POST"
  }
}
Enter fullscreen mode Exit fullscreen mode

Framework: Intercepts the HTTP call as-is β†’ redirects to localhost:7075/Products

// Your mock
testRunner.AddMockResponse(
    MockRequestMatcher.Create()
        .UsingPost()
        .WithPath(PathMatchType.Contains, "/Products"))  // Match actual endpoint
Enter fullscreen mode Exit fullscreen mode

πŸ“¦ Built-In Connectors β†’ Converted

// workflow.json
{
  "type": "ServiceProvider",
  "inputs": {
    "serviceProviderConfiguration": {
      "operationId": "uploadBlob"  // ⭐ Key!
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Framework: Converts to HTTP POST β†’ localhost:7075/uploadblob

// Your mock
testRunner.AddMockResponse(
    MockRequestMatcher.Create()
        .UsingPost()
        .WithPath(PathMatchType.Exact, "/uploadblob")  // operationId becomes path!
        .FromAction("uploadblob"))
Enter fullscreen mode Exit fullscreen mode

πŸ”Œ Managed Connectors β†’ Converted (with Restrictions)

// workflow.json - Service Bus "Send message" action
{
  "type": "ApiConnection",
  "inputs": {
    "host": {
      "connection": {
        "referenceName": "servicebus"
      }
    },
    "method": "post",
    "body": {
      "ContentData": "@base64(concat('{','\n','\"Msg\":\"Hello\"','\n','}'))",
      "ContentType": "application/json",
      "Properties": {
        "msgType": "InboundOrder",
        "transactionId": "@{workflow()['name']}"
      }
    },
    "path": "/@{encodeURIComponent(encodeURIComponent('managedqueue'))}/messages"
  }
}
Enter fullscreen mode Exit fullscreen mode

Framework: Converts to HTTP POST β†’ localhost:7075/apim/servicebus/...

⚠️ CRITICAL Limitation: Only "Raw" authentication type is supported for testing!

// parameters.json - Production (ManagedServiceIdentity)
{
  "servicebus-Authentication": {
    "type": "Object",
    "value": {
      "type": "ManagedServiceIdentity"  // βœ… Works in production
    }
  }
}

// parameters.unittest.json - Testing (Raw required!)
{
  "servicebus-Authentication": {
    "type": "Object",
    "value": {
      "type": "Raw",  // ⭐ MUST be "Raw" for tests!
      "scheme": "Key",
      "parameter": "@appsetting('servicebus_connectionKey')"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// Your mock - Use .FromAction() to match the action name
testRunner
    .AddMockResponse(
        MockRequestMatcher.Create()
            .UsingPost()
            .WithPath(PathMatchType.Contains, "/apim/servicebus/")
            .FromAction("Send_message"))  // Action name from workflow!
    .RespondWith(
        MockResponseBuilder.Create()
            .WithSuccess()
            .WithContentAsJson(mockResponse));
Enter fullscreen mode Exit fullscreen mode

⚑ Quick Comparison

Type Framework Action Mock Path Auth Support
HTTP Action Intercepts Actual endpoint /api/Products N/A
Built-In Connector Converts Action name /uploadblob All types
Managed Connector Converts APIM path /apim/<connection>/ (e.g., /apim/servicebus/, /apim/office365/) Raw only

Request Flow Visualization

Workflow reads: @appsetting('apiUrl')
    ↓
Framework returns: http://localhost:7075/api
    ↓
Workflow calls: POST http://localhost:7075/api/Products
    ↓
Mock server matches and responds
Enter fullscreen mode Exit fullscreen mode

Configuration: The 4 Essential Files

1️⃣ parameters.json - Use @appsetting()

Rule: Always use @appsetting() to make parameters test-swappable.

// ❌ WRONG - Hardcoded
{ "apiUrl": { "value": "https://prod.com/api" } }

// βœ… CORRECT - Test-swappable
{ "apiUrl": { "value": "@appsetting('apiUrl')" } }
Enter fullscreen mode Exit fullscreen mode

Why: This allows the framework to substitute values from local.settings.json and makes it easy to override with parameters.unittest.json during tests.

2️⃣ local.settings.json - Real URLs

{
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "apiUrl": "https://your-api.azurewebsites.net/api",
    "servicebus-connectionKey": "dummy-for-tests"
  }
}
Enter fullscreen mode Exit fullscreen mode

Purpose: Provides actual values for the @appsetting() references. The framework will intercept these URLs during tests.

3️⃣ testConfiguration.json - Framework Control

{
  "azurite": { "enableAzuritePortCheck": true },
  "logging": { "writeMockRequestMatchingLogs": true },
  "workflow": {
    "externalApiUrlsToMock": [
      "https://your-api.azurewebsites.net"  // Base URLs only!
    ],
    "builtInConnectorsToMock": ["uploadBlob", "getBlobSASUri"]
    // Note: Managed API connectors (servicebus, office365) don't need
    // explicit configuration here - they're handled by connections.unittest.json
  }
}
Enter fullscreen mode Exit fullscreen mode

Critical Rules:

  • externalApiUrlsToMock

    • βœ… DO: Base URLs only (https://api.com)
    • ❌ DON'T: localhost or paths
  • builtInConnectorsToMock

    • βœ… DO: operationIds (uploadBlob)
    • ❌ DON'T: Generic names (ServiceProvider)
  • Managed API connectors

    • βœ… DO: Configure in connections.unittest.json
    • ❌ DON'T: Add to testConfiguration.json

4️⃣ connections.unittest.json - Test Configuration with Raw Auth

Purpose: Override connection configurations for testing, especially forcing Raw authentication for managed connectors.

{
  "serviceProviderConnections": {
    "AzureBlob": {
      "parameterValues": {
        "connectionString": "@appsetting('AzureBlob_connectionString')"
      },
      "parameterSetName": "connectionString",
      "serviceProvider": {
        "id": "/serviceProviders/AzureBlob"
      }
    }
  },
  "managedApiConnections": {
    "servicebus": {
      "api": {
        "id": "/subscriptions/@{appsetting('WORKFLOWS_SUBSCRIPTION_ID')}/providers/Microsoft.Web/locations/@{appsetting('WORKFLOWS_LOCATION_NAME')}/managedApis/servicebus"
      },
      "connection": {
        "id": "/subscriptions/@{appsetting('WORKFLOWS_SUBSCRIPTION_ID')}/resourceGroups/@{appsetting('WORKFLOWS_RESOURCE_GROUP_NAME')}/providers/Microsoft.Web/connections/servicebus"
      },
      "authentication": {
        "type": "Raw",  // ⭐ Required for testing!
        "scheme": "Key",
        "parameter": "@appsetting('servicebus-connectionKey')"
      },
      "connectionRuntimeUrl": "@parameters('servicebus-ConnectionRuntimeUrl')"
    },
    "office365": {
      "api": {
        "id": "/subscriptions/@{appsetting('WORKFLOWS_SUBSCRIPTION_ID')}/providers/Microsoft.Web/locations/@{appsetting('WORKFLOWS_LOCATION_NAME')}/managedApis/office365"
      },
      "connection": {
        "id": "/subscriptions/@{appsetting('WORKFLOWS_SUBSCRIPTION_ID')}/resourceGroups/@{appsetting('WORKFLOWS_RESOURCE_GROUP_NAME')}/providers/Microsoft.Web/connections/office365"
      },
      "authentication": {
        "type": "Raw",  // ⭐ Required for testing!
        "scheme": "Key",
        "parameter": "@appsetting('office365-connectionKey')"
      },
      "connectionRuntimeUrl": "@parameters('office365-ConnectionRuntimeUrl')"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ”§ Testing Strategy: Override Authentication for Tests

Problem: Production uses ManagedServiceIdentity, but tests require Raw authentication.

Why This Matters: The LogicAppUnit framework only supports Raw authentication for managed connectors during tests. But in production, you should use ManagedServiceIdentity for security. This means your connections.json and parameters.json files contain production settings that won't work in tests.

The Timing Challenge: The framework reads these files during Initialize(). If the production files are still in place when Initialize() is called, the test will fail or try to connect to real Azure resources.

Solution: Swap configuration files before tests run. You can do this manually or create a helper utility.

Option 1: Manual file swapping (before running tests):

# Backup production configs
cp parameters.json parameters.json.bak
cp connections.json connections.json.bak

# Use test configs
cp parameters.unittest.json parameters.json
cp connections.unittest.json connections.json

# Run tests
dotnet test

# Restore production configs
mv parameters.json.bak parameters.json
mv connections.json.bak connections.json
Enter fullscreen mode Exit fullscreen mode

Option 2: Custom helper utility (automated, recommended):

Why Use a Helper:

  • βœ… Automates swapping in test constructor (before Initialize())
  • βœ… Guarantees restoration in Dispose() (even if test fails)
  • βœ… Prevents accidentally committing .unittest.json files as production configs
  • βœ… Works consistently across all test runs and CI/CD pipelines
  • βœ… No manual steps to remember
// Example: Custom helper to automate file swapping
public class WfMngConnectorPubTests : WorkflowTestBase, IDisposable
{
    private readonly TestConfigHelper _configHelper;

    public WfMngConnectorPubTests()
    {
        // CRITICAL: Swap BEFORE Initialize()
        // Replaces production configs with test configs:
        //   parameters.json β†’ parameters.unittest.json (Raw auth)
        //   connections.json β†’ connections.unittest.json (Raw auth)
        _configHelper = new TestConfigHelper();
        _configHelper.SwapConfigs("../LABasicDemo");

        // Now Initialize() reads the test configs
        Initialize("../../../../LABasicDemo", "wf-mngconnector-pub");
    }

    public void Dispose()
    {
        // Automatically restores production configs after test
        _configHelper?.RestoreOriginals();
    }
}
Enter fullscreen mode Exit fullscreen mode

How the Helper Works:

  1. Constructor (runs before each test):

    • Backs up parameters.json β†’ parameters.json.bak
    • Backs up connections.json β†’ connections.json.bak
    • Copies parameters.unittest.json β†’ parameters.json
    • Copies connections.unittest.json β†’ connections.json
    • Returns control so Initialize() reads the test configs
  2. Dispose (runs after each test, even on failure):

    • Restores parameters.json from backup
    • Restores connections.json from backup

Key Point: The framework reads connections.json and parameters.json during Initialize(). File swapping must happen before Initialize() is called. A helper utility ensures this happens automatically and reliably.


Framework Essentials

What LogicAppUnit Does

Trigger Replacement: Non-HTTP triggers (Service Bus, Timer, etc.) are rewritten to HTTP so you can call them directly in tests.

Mocking External Dependencies: All outbound calls (REST/SOAP, DB adapters, O365, Dynamics) are intercepted and answered by the in-memory mock serverβ€”no cloud dependencies.

Mocking Child Workflows/Functions: Use .FromAction("<actionName>") to return stubbed responses for child workflow or Function calls.

Retry Policies Set to None: The framework automatically sets all retry policies to none during testsβ€”failures surface immediately without waiting for retry delays. This speeds up test execution and makes failures easier to debug.

Assertions Pattern

After TriggerWorkflow(...), you can assert:

  • Workflow status: WorkflowRunStatus.Succeeded / Failed
  • Termination: WorkflowWasTerminated (true/false)
  • HTTP response: workflowResponse.StatusCode (if workflow has Response action)
  • Action statuses: GetWorkflowActionStatus("ActionName")
  • Action outputs: GetWorkflowActionOutput("ActionName")
// Sample assertions
var workflowResponse = testRunner.TriggerWorkflow(HttpMethod.Post);
Assert.Equal(WorkflowRunStatus.Succeeded, testRunner.WorkflowRunStatus);
Assert.False(testRunner.WorkflowWasTerminated);
Assert.Equal(HttpStatusCode.OK, workflowResponse.StatusCode);
Assert.Equal(ActionStatus.Succeeded, testRunner.GetWorkflowActionStatus("Send_message"));

// Verify action output
var composeOutput = testRunner.GetWorkflowActionOutput("Compose");
Assert.Equal("Hello", composeOutput.ToString());
Enter fullscreen mode Exit fullscreen mode

Benefits & When to Use

  • Test Logic Apps locally without deploying to Azure; works in CI/CD
  • Mock external APIs/DBs/child workflows to isolate business logic
  • Validate complex flows end-to-end before production
  • Faster feedback: Retry policies automatically set to noneβ€”failures surface immediately
  • Great fit for: local dev, API mocking, pipeline gates, regression coverage

Minimal Test Example

[Fact]
public void Workflow_WhenTriggered_SendsServiceBusMessage()
{
    using (var testRunner = CreateTestRunner())
    {
        // Mock Service Bus Send_message action
        testRunner
            .AddMockResponse(
                MockRequestMatcher.Create()
                    .UsingPost()
                    .WithPath(PathMatchType.Contains, "/apim/servicebus/")
                    .FromAction("Send_message"))
            .RespondWith(
                MockResponseBuilder.Create()
                    .WithSuccess()
                    .WithContentAsJson(new
                    {
                        statusCode = 201,
                        body = new { }
                    }));

        // Trigger workflow
        var response = testRunner.TriggerWorkflow(HttpMethod.Get);

        // Assert
        Assert.Equal(WorkflowRunStatus.Succeeded, testRunner.WorkflowRunStatus);
        Assert.Equal(ActionStatus.Succeeded, testRunner.GetWorkflowActionStatus("Send_message"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: Part 2 will show complete test suites with real test results, error handling, and production examples.


Top 3 Failure Modes

❌ "No mocked requests logged" + Count = 0

Symptoms: testRunner.MockRequests.Count is 0; no HTTP calls intercepted.

Root Causes:

  • Hardcoded parameters β†’ Use @appsetting() in parameters.json
  • Missing externalApiUrlsToMock β†’ Add base URLs to testConfiguration.json
  • localhost in config β†’ Remove it (framework handles this)

❌ HTTP 401 Unauthorized

Diagnosis: Hitting real APIs instead of mocks.

Fix:

# 1. Check all parameters use @appsetting()
grep "@appsetting" parameters.json

# 2. Verify testConfiguration has the URL
cat testConfiguration.json | grep "externalApiUrlsToMock"

# 3. Ensure config files are swapped for tests
# Verify parameters.unittest.json and connections.unittest.json are active
Enter fullscreen mode Exit fullscreen mode

❌ "request absolute path not matched"

Diagnosis: Wrong mock path (common with built-in connectors).

// ❌ WRONG - Using Azure Storage endpoint
.WithPath(PathMatchType.Exact, "/devstoreaccount1/blob")

// βœ… CORRECT - Using operationId as path
.WithPath(PathMatchType.Exact, "/uploadblob")
.FromAction("uploadblob")
Enter fullscreen mode Exit fullscreen mode

Azurite Requirement

Always required, even if not using storage directly (framework dependency):

npx azurite --silent --location ./.azurite
Enter fullscreen mode Exit fullscreen mode

Pre-Flight Checklist

Before running tests:

  • [ ] All parameters use @appsetting() (no hardcoded values)
  • [ ] builtInConnectorsToMock has operationIds (not generic names)
  • [ ] Managed API connectors use "type": "Raw" in connections.unittest.json
  • [ ] Test config files swapped (.unittest.json versions active)
  • [ ] Azurite running on :10000/10001/10002
  • [ ] Mock responses point to localhost:7075
  • [ ] Built-in connector responses are raw payloads (no statusCode/body wrapper)
  • [ ] Managed connector mocks use .FromAction() with action name
  • [ ] externalApiUrlsToMock has base URLs only (no paths)

Quick Debugging

Enable Logging

// testConfiguration.json
{ "logging": { "writeMockRequestMatchingLogs": true } }
Enter fullscreen mode Exit fullscreen mode

Inspect Intercepted Requests

var mockRequests = testRunner.MockRequests;
Console.WriteLine($"Total requests intercepted: {mockRequests.Count}");
foreach (var req in mockRequests) {
    Console.WriteLine($"{req.Method} {req.RequestUri}");
}
Enter fullscreen mode Exit fullscreen mode

Run Focused Tests

dotnet test --filter "FullyQualifiedName~YourTest"
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

Core Concepts

βœ… LogicAppUnit rewrites workflows in-memory (no Azure deployment needed)
βœ… HTTP actions = intercepted as-is | Built-in/managed connectors = converted to HTTP
βœ… Mock by actual endpoint for HTTP, by operationId for built-ins, by APIM path for managed
βœ… Managed connectors MUST use "Raw" authentication type for testing

Configuration Requirements

βœ… Always @appsetting(), never hardcode values
βœ… Base URLs only in externalApiUrlsToMock (no localhost, no paths)
βœ… operationIds in builtInConnectorsToMock
βœ… Managed API auth: Override to "type": "Raw" for tests via connections.unittest.json
βœ… Production can use ManagedServiceIdentity; tests must use Raw

Debugging First Steps

βœ… Check MockRequests.Count first (should be > 0)
βœ… Enable writeMockRequestMatchingLogs to see what's being intercepted
βœ… Use pre-flight checklist to catch common mistakes


Resources

πŸ“š LogicAppUnit GitHub
πŸ“š Azure Logic Apps Docs

Top comments (0)