Azure Functions Testing Guide: From Unit Tests to Live Triggers
Azure Functions is Microsoft's serverless compute platform, and like all serverless environments it creates a testing challenge: your code runs inside a host process you don't own, responds to triggers from Azure services, and integrates deeply with the Azure ecosystem. Getting a reliable test suite in place requires understanding the Azure Functions host model, the right mocking libraries, and how to build an integration test setup that doesn't require a live Azure subscription for every test run.
This guide covers the full testing stack for Azure Functions — from fast isolated unit tests through integration tests with real trigger types to a CI/CD pipeline that catches regressions before deployment.
Understanding the Azure Functions Host Model
Azure Functions runs inside a host process (func.exe) that handles trigger binding, scaling, and runtime management. Your function receives a strongly-typed input (from the trigger) and can output via return value or output bindings.
In C#, a function looks like this:
// GetUser.cs
using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
public class GetUserFunction
{
private readonly IUserRepository _userRepo;
private readonly ILogger<GetUserFunction> _logger;
public GetUserFunction(IUserRepository userRepo, ILogger<GetUserFunction> logger)
{
_userRepo = userRepo;
_logger = logger;
}
[Function("GetUser")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "users/{userId}")] HttpRequestData req,
string userId)
{
_logger.LogInformation("Getting user {UserId}", userId);
var user = await _userRepo.GetByIdAsync(userId);
if (user == null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { error = "User not found" });
return notFound;
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(user);
return response;
}
}Notice the dependency injection — IUserRepository and ILogger are injected via constructor. This is the key to testability. Azure Functions .NET Isolated Worker model supports full DI, which means your functions are just classes with dependencies, and you can test them exactly like any other .NET class.
Unit Testing with xUnit
Unit tests for Azure Functions should test the function class directly, with all dependencies mocked. You don't need the Azure Functions host running.
Install test dependencies:
<!-- YourFunctions.Tests.csproj -->
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.21.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />Write the test:
// GetUserFunctionTests.cs
using System.Net;
using Moq;
using Microsoft.Extensions.Logging;
using Xunit;
public class GetUserFunctionTests
{
private readonly Mock<IUserRepository> _mockRepo;
private readonly Mock<ILogger<GetUserFunction>> _mockLogger;
private readonly GetUserFunction _function;
public GetUserFunctionTests()
{
_mockRepo = new Mock<IUserRepository>();
_mockLogger = new Mock<ILogger<GetUserFunction>>();
_function = new GetUserFunction(_mockRepo.Object, _mockLogger.Object);
}
[Fact]
public async Task Run_ReturnsUser_WhenUserExists()
{
// Arrange
var expectedUser = new User { Id = "user-123", Name = "Alice" };
_mockRepo.Setup(r => r.GetByIdAsync("user-123")).ReturnsAsync(expectedUser);
var context = new MockFunctionContext();
var request = new MockHttpRequestData(context, HttpMethod.Get,
new Uri("http://localhost/api/users/user-123"));
// Act
var response = await _function.Run(request, "user-123");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_mockRepo.Verify(r => r.GetByIdAsync("user-123"), Times.Once);
}
[Fact]
public async Task Run_Returns404_WhenUserNotFound()
{
// Arrange
_mockRepo.Setup(r => r.GetByIdAsync("ghost")).ReturnsAsync((User?)null);
var context = new MockFunctionContext();
var request = new MockHttpRequestData(context, HttpMethod.Get,
new Uri("http://localhost/api/users/ghost"));
// Act
var response = await _function.Run(request, "ghost");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}The MockFunctionContext and MockHttpRequestData require some setup. The Azure Functions SDK doesn't provide test doubles out of the box, so you'll create them:
// TestHelpers/MockHttpRequestData.cs
using System.Security.Claims;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
public class MockHttpRequestData : HttpRequestData
{
private readonly MemoryStream _body;
public MockHttpRequestData(FunctionContext context, HttpMethod method, Uri url, string? body = null)
: base(context)
{
Method = method.Method;
Url = url;
_body = body != null
? new MemoryStream(System.Text.Encoding.UTF8.GetBytes(body))
: new MemoryStream();
Headers = new HttpHeadersCollection();
Cookies = new List<IHttpCookie>();
Identities = new List<ClaimsIdentity>();
}
public override Stream Body => _body;
public override HttpHeadersCollection Headers { get; }
public override IReadOnlyCollection<IHttpCookie> Cookies { get; }
public override Uri Url { get; }
public override IEnumerable<ClaimsIdentity> Identities { get; }
public override string Method { get; }
public override HttpResponseData CreateResponse()
{
return new MockHttpResponseData(FunctionContext);
}
}Testing Queue-Triggered Functions
Queue triggers are common in Azure Functions for background processing. The function receives a string or strongly-typed message:
// ProcessOrderFunction.cs
public class ProcessOrderFunction
{
private readonly IOrderService _orderService;
private readonly ILogger<ProcessOrderFunction> _logger;
public ProcessOrderFunction(IOrderService orderService, ILogger<ProcessOrderFunction> logger)
{
_orderService = orderService;
_logger = logger;
}
[Function("ProcessOrder")]
public async Task Run(
[QueueTrigger("orders", Connection = "AzureWebJobsStorage")] Order order)
{
_logger.LogInformation("Processing order {OrderId}", order.Id);
if (string.IsNullOrEmpty(order.UserId))
{
throw new ArgumentException("Order must have a UserId");
}
await _orderService.FulfillAsync(order);
}
}Unit test for the queue function:
public class ProcessOrderFunctionTests
{
private readonly Mock<IOrderService> _mockOrderService;
private readonly ProcessOrderFunction _function;
public ProcessOrderFunctionTests()
{
_mockOrderService = new Mock<IOrderService>();
var mockLogger = new Mock<ILogger<ProcessOrderFunction>>();
_function = new ProcessOrderFunction(_mockOrderService.Object, mockLogger.Object);
}
[Fact]
public async Task Run_CallsFulfill_ForValidOrder()
{
var order = new Order { Id = "order-123", UserId = "user-456", Amount = 99.99m };
await _function.Run(order);
_mockOrderService.Verify(s => s.FulfillAsync(order), Times.Once);
}
[Fact]
public async Task Run_ThrowsArgumentException_WhenUserIdMissing()
{
var order = new Order { Id = "order-123", UserId = "", Amount = 99.99m };
await Assert.ThrowsAsync<ArgumentException>(() => _function.Run(order));
_mockOrderService.Verify(s => s.FulfillAsync(It.IsAny<Order>()), Times.Never);
}
}Testing Timer-Triggered Functions
Timer functions run on a schedule. They're easy to unit test since they typically don't receive meaningful input data:
// CleanupFunction.cs
public class CleanupFunction
{
private readonly ISessionRepository _sessionRepo;
private readonly ILogger<CleanupFunction> _logger;
public CleanupFunction(ISessionRepository sessionRepo, ILogger<CleanupFunction> logger)
{
_sessionRepo = sessionRepo;
_logger = logger;
}
[Function("Cleanup")]
public async Task Run([TimerTrigger("0 0 * * * *")] TimerInfo timer)
{
if (timer.IsPastDue)
{
_logger.LogWarning("Timer is running late");
}
var deleted = await _sessionRepo.DeleteExpiredAsync();
_logger.LogInformation("Deleted {Count} expired sessions", deleted);
}
}public class CleanupFunctionTests
{
[Fact]
public async Task Run_DeletesExpiredSessions()
{
var mockRepo = new Mock<ISessionRepository>();
mockRepo.Setup(r => r.DeleteExpiredAsync()).ReturnsAsync(42);
var mockLogger = new Mock<ILogger<CleanupFunction>>();
var function = new CleanupFunction(mockRepo.Object, mockLogger.Object);
// TimerInfo with IsPastDue = false
var timerInfo = new TimerInfo();
await function.Run(timerInfo);
mockRepo.Verify(r => r.DeleteExpiredAsync(), Times.Once);
}
}Testing Blob-Triggered Functions
Blob triggers fire when a file is uploaded to Azure Blob Storage:
// ProcessImageFunction.cs
public class ProcessImageFunction
{
private readonly IImageProcessor _processor;
public ProcessImageFunction(IImageProcessor processor)
{
_processor = processor;
}
[Function("ProcessImage")]
[BlobOutput("processed/{name}", Connection = "AzureWebJobsStorage")]
public async Task<byte[]> Run(
[BlobTrigger("uploads/{name}", Connection = "AzureWebJobsStorage")] byte[] imageData,
string name)
{
return await _processor.ResizeAsync(imageData, maxWidth: 1200);
}
}Unit test with mocked processor:
[Fact]
public async Task Run_ReturnsProcessedImage()
{
var inputData = new byte[] { 0xFF, 0xD8, 0xFF }; // JPEG header
var expectedOutput = new byte[] { 0x01, 0x02, 0x03 };
var mockProcessor = new Mock<IImageProcessor>();
mockProcessor.Setup(p => p.ResizeAsync(inputData, 1200)).ReturnsAsync(expectedOutput);
var function = new ProcessImageFunction(mockProcessor.Object);
var result = await function.Run(inputData, "photo.jpg");
Assert.Equal(expectedOutput, result);
}Local Development with Azure Functions Core Tools
The Azure Functions Core Tools (func) let you run the full function host locally:
# Install Core Tools
npm install -g azure-functions-core-tools@4
# Run functions locally
cd YourFunctionsProject
func startFor local settings, use local.settings.json (not committed to source control):
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"CosmosDbConnection": "AccountEndpoint=https://localhost:8081/;AccountKey=..."
}
}Use UseDevelopmentStorage=true to point storage bindings at the local Azurite emulator:
# Start Azurite (Azure Storage emulator)
npx azurite --location ./azurite-data --debug ./azurite.log
# Now start your functions
func startWith Azurite running, Queue triggers, Blob triggers, and Table storage all work locally.
Integration Testing with Azurite and the Azure SDK
For integration tests, spin up Azurite in Docker and use the Azure SDK pointed at it:
# docker-compose.yml
services:
azurite:
image: mcr.microsoft.com/azure-storage/azurite
ports:
- "10000:10000" # blob
- "10001:10001" # queue
- "10002:10002" # table// Integration/ProcessOrderIntegrationTests.cs
using Azure.Storage.Queues;
using System.Text.Json;
public class ProcessOrderIntegrationTests : IAsyncLifetime
{
private const string ConnectionString =
"DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;" +
"AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;" +
"BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;" +
"QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;";
private QueueClient _queueClient = null!;
public async Task InitializeAsync()
{
_queueClient = new QueueClient(ConnectionString, "orders");
await _queueClient.CreateIfNotExistsAsync();
}
public async Task DisposeAsync()
{
await _queueClient.DeleteIfExistsAsync();
}
[Fact]
public async Task QueueMessage_TriggersOrderProcessing()
{
var order = new Order { Id = "order-integration-1", UserId = "user-123", Amount = 59.99m };
var message = JsonSerializer.Serialize(order);
await _queueClient.SendMessageAsync(
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(message)));
// In a real integration test, wait for the function to process
// then assert against a database or output queue
await Task.Delay(3000);
// Assert the order was processed (check CosmosDB, another queue, etc.)
}
}Testing Error Handling and Retry Logic
Azure Functions has built-in retry policies. Test that your functions handle errors correctly and are idempotent:
[Fact]
public async Task Run_IsIdempotent_WhenCalledTwice()
{
var order = new Order { Id = "order-idempotent", UserId = "user-123" };
// First call
await _function.Run(order);
// Second call with same order (simulating retry)
await _function.Run(order);
// Should have been fulfilled exactly once, or gracefully handled duplicate
_mockOrderService.Verify(s => s.FulfillAsync(order), Times.Exactly(2));
// Verify no duplicate records in the database
}
[Fact]
public async Task Run_ThrowsException_WhenServiceFails()
{
var order = new Order { Id = "order-fail", UserId = "user-123" };
_mockOrderService.Setup(s => s.FulfillAsync(It.IsAny<Order>()))
.ThrowsAsync(new ExternalServiceException("Payment service unavailable"));
// The function should propagate the exception to trigger Azure's retry mechanism
await Assert.ThrowsAsync<ExternalServiceException>(() => _function.Run(order));
}CI/CD Pipeline with GitHub Actions
name: Azure Functions Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
azurite:
image: mcr.microsoft.com/azure-storage/azurite
ports:
- 10000:10000
- 10001:10001
- 10002:10002
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run unit tests
run: dotnet test tests/UnitTests --no-build --configuration Release --logger trx
- name: Run integration tests
run: dotnet test tests/IntegrationTests --no-build --configuration Release --logger trx
env:
AzureWebJobsStorage: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;"
- name: Publish test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: "**/*.trx"Monitoring Production Functions
Local testing and CI catch most issues, but production Azure Functions can encounter problems at scale — cold starts under load, storage throttling, downstream service degradation. Services like HelpMeTest can run continuous synthetic tests against your HTTP-triggered functions in production, alerting you the moment a function starts returning errors or responding slowly. Combining that with Azure Application Insights gives you a complete observability picture.
NUnit Alternative
If your team prefers NUnit:
[TestFixture]
public class GetUserFunctionNUnitTests
{
private Mock<IUserRepository> _mockRepo;
private GetUserFunction _function;
[SetUp]
public void SetUp()
{
_mockRepo = new Mock<IUserRepository>();
var mockLogger = new Mock<ILogger<GetUserFunction>>();
_function = new GetUserFunction(_mockRepo.Object, mockLogger.Object);
}
[Test]
public async Task Run_ReturnsOK_WhenUserExists()
{
_mockRepo.Setup(r => r.GetByIdAsync("user-123"))
.ReturnsAsync(new User { Id = "user-123", Name = "Alice" });
var context = new MockFunctionContext();
var request = new MockHttpRequestData(context, HttpMethod.Get,
new Uri("http://localhost/api/users/user-123"));
var response = await _function.Run(request, "user-123");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
}Key Takeaways
Azure Functions are testable with standard .NET testing tools once you embrace dependency injection. The patterns are straightforward:
- Inject all dependencies — never instantiate services inside your function constructor
- Unit test function classes directly with Moq, without the Functions host
- Use Azurite for integration tests involving storage bindings
- Use
func startfor full local development and manual testing - Test idempotency for all trigger types — Azure may deliver messages more than once
- Test error paths — verify your functions propagate exceptions correctly to allow Azure's retry mechanism to work
With this approach, you can develop and test Azure Functions confidently, knowing the behavior in CI matches what will run in production.