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:
-
Workflow JSON is loaded, parameters resolved via
@appsetting(). -
Actions are rewritten to hit
http://localhost:7075(in-memory mock server). - Retry policies are set to none - failures surface immediately (no waiting).
- 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"
}
}
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
π¦ Built-In Connectors β Converted
// workflow.json
{
"type": "ServiceProvider",
"inputs": {
"serviceProviderConfiguration": {
"operationId": "uploadBlob" // β Key!
}
}
}
Framework: Converts to HTTP POST β localhost:7075/uploadblob
// Your mock
testRunner.AddMockResponse(
MockRequestMatcher.Create()
.UsingPost()
.WithPath(PathMatchType.Exact, "/uploadblob") // operationId becomes path!
.FromAction("uploadblob"))
π 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"
}
}
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')"
}
}
}
// 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));
β‘ 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
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')" } }
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"
}
}
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
}
}
Critical Rules:
-
externalApiUrlsToMock- β
DO: Base URLs only (
https://api.com) - β DON'T: localhost or paths
- β
DO: Base URLs only (
-
builtInConnectorsToMock- β
DO: operationIds (
uploadBlob) - β DON'T: Generic names (
ServiceProvider)
- β
DO: operationIds (
-
Managed API connectors
- β
DO: Configure in
connections.unittest.json - β DON'T: Add to
testConfiguration.json
- β
DO: Configure in
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')"
}
}
}
π§ 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
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.jsonfiles 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();
}
}
How the Helper Works:
-
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
- Backs up
-
Dispose (runs after each test, even on failure):
- Restores
parameters.jsonfrom backup - Restores
connections.jsonfrom backup
- Restores
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());
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"));
}
}
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()inparameters.json -
Missing
externalApiUrlsToMockβ Add base URLs totestConfiguration.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
β "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")
Azurite Requirement
Always required, even if not using storage directly (framework dependency):
npx azurite --silent --location ./.azurite
Pre-Flight Checklist
Before running tests:
- [ ] All parameters use
@appsetting()(no hardcoded values) - [ ]
builtInConnectorsToMockhas operationIds (not generic names) - [ ] Managed API connectors use
"type": "Raw"inconnections.unittest.json - [ ] Test config files swapped (
.unittest.jsonversions active) - [ ] Azurite running on :10000/10001/10002
- [ ] Mock responses point to
localhost:7075 - [ ] Built-in connector responses are raw payloads (no
statusCode/bodywrapper) - [ ] Managed connector mocks use
.FromAction()with action name - [ ]
externalApiUrlsToMockhas base URLs only (no paths)
Quick Debugging
Enable Logging
// testConfiguration.json
{ "logging": { "writeMockRequestMatchingLogs": true } }
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}");
}
Run Focused Tests
dotnet test --filter "FullyQualifiedName~YourTest"
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)