TL;DR: Test Azure Functions using Moq for unit tests (mocked service interfaces) and Testcontainers for integration tests (real Azurite blob storage). This guide shows both approaches using the BlobMetadataSearch function with the IBlobSearchService pattern.
Learn how to test HTTP-triggered Azure Functions with both isolated unit tests and full integration tests against real Azure Storage.
What You’ll Learn
This guide covers two testing approaches for Azure Functions:
- Unit Testing with Moq - Test function logic in isolation by mocking
IBlobSearchServiceinterface - Integration Testing with Testcontainers - Test the real
BlobSearchServiceagainst Azurite (Azure Storage emulator) in Docker
Example Function: BlobMetadataSearch - An HTTP-tr…
TL;DR: Test Azure Functions using Moq for unit tests (mocked service interfaces) and Testcontainers for integration tests (real Azurite blob storage). This guide shows both approaches using the BlobMetadataSearch function with the IBlobSearchService pattern.
Learn how to test HTTP-triggered Azure Functions with both isolated unit tests and full integration tests against real Azure Storage.
What You’ll Learn
This guide covers two testing approaches for Azure Functions:
- Unit Testing with Moq - Test function logic in isolation by mocking
IBlobSearchServiceinterface - Integration Testing with Testcontainers - Test the real
BlobSearchServiceagainst Azurite (Azure Storage emulator) in Docker
Example Function: BlobMetadataSearch - An HTTP-triggered function that searches blobs by metadata filters using dependency injection.
Part 1: Architecture - Making Azure Functions Testable
The Challenge with BlobServiceClient
BlobServiceClient is difficult to mock because:
GetBlobContainerClient()returns sealed types (cannot be mocked)GetBlobsAsync()returnsAsyncPageable<BlobItem>(complex to mock)
The Solution: Service Layer Pattern
Extract blob search logic into a service with an interface:
// Interface - Mockable for unit tests
public interface IBlobSearchService
{
Task<List<BlobSearchResult>> SearchBlobsAsync(
string containerName,
string? subFolder,
List<MetadataFilter>? filters);
}
// Implementation - Uses real BlobServiceClient
public class BlobSearchService : IBlobSearchService
{
private readonly BlobServiceClient _blobServiceClient;
public BlobSearchService(BlobServiceClient blobServiceClient)
{
_blobServiceClient = blobServiceClient;
}
public async Task<List<BlobSearchResult>> SearchBlobsAsync(
string containerName, string? subFolder, List<MetadataFilter>? filters)
{
var container = _blobServiceClient.GetBlobContainerClient(containerName);
var results = new List<BlobSearchResult>();
var prefix = string.IsNullOrWhiteSpace(subFolder) ? null : $"{subFolder.TrimEnd('/')}/";
await foreach (var blobItem in container.GetBlobsAsync(BlobTraits.Metadata, prefix: prefix))
{
if (MatchesFilters(blobItem.Metadata, filters))
{
results.Add(new BlobSearchResult
{
FileName = Path.GetFileName(blobItem.Name),
FilePath = blobItem.Name,
Metadata = blobItem.Metadata.ToDictionary(k => k.Key, v => v.Value)
});
}
}
return results;
}
private bool MatchesFilters(IDictionary<string, string> metadata, List<MetadataFilter>? filters)
{
if (filters == null || filters.Count == 0) return true;
var normalizedMetadata = metadata.ToDictionary(
k => k.Key.ToLowerInvariant(),
v => v.Value);
bool andMatch = true;
bool orMatch = false;
foreach (var filter in filters)
{
var key = filter.Key.ToLowerInvariant();
var matches = normalizedMetadata.TryGetValue(key, out var value) &&
(filter.FilterType == "contains"
? value.Contains(filter.Value, StringComparison.OrdinalIgnoreCase)
: string.Equals(value, filter.Value, StringComparison.OrdinalIgnoreCase));
if (filter.Condition == "or") orMatch |= matches;
else andMatch &= matches;
}
return andMatch || orMatch;
}
}
Refactored Azure Function
File: AzFunc.CoreComps/BlobMetadataSearch.cs
public class BlobMetadataSearch
{
private readonly ILogger<BlobMetadataSearch> _logger;
private readonly IBlobSearchService _searchService;
public BlobMetadataSearch(
ILogger<BlobMetadataSearch> logger,
IBlobSearchService searchService)
{
_logger = logger;
_searchService = searchService;
}
[Function("BlobMetadataSearch")]
public async Task<IActionResult> RunAsync(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req)
{
var input = JsonSerializer.Deserialize<SearchMetadataRequest>(
await new StreamReader(req.Body).ReadToEndAsync(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (string.IsNullOrWhiteSpace(input?.Container))
return new BadRequestObjectResult("Container is required.");
var results = await _searchService.SearchBlobsAsync(
input.Container,
input.SubFolder,
input.Filters);
return new OkObjectResult(results);
}
}
Dependency Injection Registration
File: Program.cs
var host = new HostBuilder()
.ConfigureFunctionsWebApplication()
.ConfigureServices(services =>
{
services.AddSingleton(sp =>
new BlobServiceClient(Environment.GetEnvironmentVariable("AzureWebJobsStorage")));
services.AddTransient<IBlobSearchService, BlobSearchService>();
})
.Build();
await host.RunAsync();
Part 2: Unit Testing with Moq
Unit tests verify the Azure Function logic in isolation by mocking IBlobSearchService.
Setup
dotnet add package Moq --version 4.20.72
dotnet add package FluentAssertions --version 6.12.2
dotnet add package xunit --version 2.9.2
Unit Test Example
File: Unit/BlobMetadataSearchTests.cs
using CommonComps;
using FluentAssertions;
using Moq;
using Xunit;
namespace CommonComps.UnitTests.Unit;
public class BlobMetadataSearchTests
{
private readonly Mock<IBlobSearchService> _mockSearchService;
public BlobMetadataSearchTests()
{
_mockSearchService = new Mock<IBlobSearchService>();
}
[Fact]
public async Task SearchBlobsAsync_WithValidContainer_ReturnsResults()
{
// Arrange
var expectedResults = new List<BlobSearchResult>
{
new BlobSearchResult
{
FileName = "report1.pdf",
FilePath = "documents/report1.pdf",
Metadata = new Dictionary<string, string> { { "department", "finance" } }
}
};
_mockSearchService
.Setup(s => s.SearchBlobsAsync("test-container", null, null))
.ReturnsAsync(expectedResults);
// Act
var results = await _mockSearchService.Object.SearchBlobsAsync("test-container", null, null);
// Assert
results.Should().HaveCount(1);
results.First().FileName.Should().Be("report1.pdf");
}
[Fact]
public async Task SearchBlobsAsync_WithFilters_PassesFiltersCorrectly()
{
// Arrange
var filters = new List<MetadataFilter>
{
new MetadataFilter { Key = "department", Value = "finance", FilterType = "equals", Condition = "and" }
};
_mockSearchService
.Setup(s => s.SearchBlobsAsync("test-container", null, filters))
.ReturnsAsync(new List<BlobSearchResult>());
// Act
await _mockSearchService.Object.SearchBlobsAsync("test-container", null, filters);
// Assert
_mockSearchService.Verify(s => s.SearchBlobsAsync("test-container", null, filters), Times.Once);
}
[Fact]
public async Task SearchBlobsAsync_WithSubFolder_PassesSubFolderCorrectly()
{
// Arrange
_mockSearchService
.Setup(s => s.SearchBlobsAsync("test-container", "documents", null))
.ReturnsAsync(new List<BlobSearchResult>());
// Act
await _mockSearchService.Object.SearchBlobsAsync("test-container", "documents", null);
// Assert
_mockSearchService.Verify(s => s.SearchBlobsAsync("test-container", "documents", null), Times.Once);
}
}
Key Points:
- ✅ Fast - No Docker containers needed
- ✅ Simple - Mock the interface, not Azure SDK
- ✅ Focused - Tests only the function’s HTTP handling and validation logic
- ❌ Limited - Doesn’t test the actual blob search implementation
Part 3: Integration Testing with Testcontainers
Integration tests use a real Azurite container (Azure Storage emulator) to test the actual BlobSearchService implementation.
Setup
dotnet add package Testcontainers.Azurite --version 4.3.0
dotnet add package xUnit --version 2.9.2
Step 1: Create Azurite Container Fixture
File: Fixtures/AzuriteContainerFixture.cs
using Azure.Storage.Blobs;
using Testcontainers.Azurite;
using Xunit;
namespace CommonComps.UnitTests.Integration.Fixtures;
public class AzuriteContainerFixture : IAsyncLifetime
{
private readonly AzuriteContainer _container;
public string ConnectionString => _container.GetConnectionString();
public BlobServiceClient BlobServiceClient { get; private set; } = null!;
public AzuriteContainerFixture()
{
_container = new AzuriteBuilder()
.WithImage("mcr.microsoft.com/azure-storage/azurite:latest")
.Build();
}
public async Task InitializeAsync()
{
await _container.StartAsync();
BlobServiceClient = new BlobServiceClient(ConnectionString);
await SeedTestDataAsync();
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
private async Task SeedTestDataAsync()
{
var containerClient = BlobServiceClient.GetBlobContainerClient("test-container");
await containerClient.CreateIfNotExistsAsync();
// Upload test blobs with metadata in documents/ folder
await UploadBlobWithMetadataAsync(containerClient, "documents/report1.pdf",
"Report 1 content", new Dictionary<string, string>
{
{ "createdby", "admin" },
{ "department", "finance" },
{ "doctype", "report" }
});
await UploadBlobWithMetadataAsync(containerClient, "documents/report2.pdf",
"Report 2 content", new Dictionary<string, string>
{
{ "createdby", "user1" },
{ "department", "finance" },
{ "doctype", "report" }
});
await UploadBlobWithMetadataAsync(containerClient, "documents/invoice1.pdf",
"Invoice content", new Dictionary<string, string>
{
{ "createdby", "admin" },
{ "department", "accounting" },
{ "doctype", "invoice" }
});
// Upload test blob in images/ folder
await UploadBlobWithMetadataAsync(containerClient, "images/logo.png",
"Logo image", new Dictionary<string, string>
{
{ "createdby", "designer" },
{ "department", "marketing" },
{ "doctype", "image" }
});
// Upload blob with base64 encoded metadata
await UploadBlobWithMetadataAsync(containerClient, "encoded.txt",
"Encoded content", new Dictionary<string, string>
{
{ "encodedfield", Convert.ToBase64String(Encoding.UTF8.GetBytes("secret-value")) }
});
}
private async Task UploadBlobWithMetadataAsync(
BlobContainerClient container,
string blobName,
string content,
Dictionary<string, string> metadata)
{
var blobClient = container.GetBlobClient(blobName);
await blobClient.UploadAsync(
BinaryData.FromString(content),
overwrite: true);
await blobClient.SetMetadataAsync(metadata);
}
}
[CollectionDefinition("Azurite")]
public class AzuriteCollection : ICollectionFixture<AzuriteContainerFixture>
{
// This class is never instantiated. It exists only to define the collection.
}
Step 2: Integration Tests for BlobSearchService
File: Integration/BlobMetadataSearchTests.cs
using CommonComps;
using CommonComps.UnitTests.Integration.Fixtures;
using FluentAssertions;
using Xunit;
namespace CommonComps.UnitTests.Integration;
/// <summary>
/// Integration tests for BlobSearchService using real Azurite container
/// Tests the actual blob search implementation against a real Azure Storage emulator
/// </summary>
[Collection("Azurite")]
public class BlobMetadataSearchTests
{
private readonly BlobSearchService _searchService;
public BlobMetadataSearchTests(AzuriteContainerFixture fixture)
{
_searchService = new BlobSearchService(fixture.BlobServiceClient);
}
[Fact]
public async Task SearchBlobs_ByCreatedBy_ReturnsMatchingBlobs()
{
// Arrange
var filters = new List<MetadataFilter>
{
new MetadataFilter { Key = "createdby", Value = "admin", FilterType = "equals", Condition = "and" }
};
// Act
var results = await _searchService.SearchBlobsAsync("test-container", null, filters);
// Assert
results.Should().HaveCount(2); // report1.pdf and invoice1.pdf
results.Should().OnlyContain(r => r.Metadata["createdby"] == "admin");
}
[Fact]
public async Task SearchBlobs_WithMultipleFiltersAnd_ReturnsMatchingBlobs()
{
// Arrange
var filters = new List<MetadataFilter>
{
new MetadataFilter { Key = "createdby", Value = "admin", FilterType = "equals", Condition = "and" },
new MetadataFilter { Key = "department", Value = "finance", FilterType = "equals", Condition = "and" }
};
// Act
var results = await _searchService.SearchBlobsAsync("test-container", null, filters);
// Assert
results.Should().HaveCount(1); // Only report1.pdf matches both
results.First().FileName.Should().Be("report1.pdf");
}
[Fact]
public async Task SearchBlobs_WithContainsFilter_ReturnsMatchingBlobs()
{
// Arrange
var filters = new List<MetadataFilter>
{
new MetadataFilter { Key = "doctype", Value = "report", FilterType = "contains", Condition = "and" }
};
// Act
var results = await _searchService.SearchBlobsAsync("test-container", null, filters);
// Assert
results.Should().HaveCount(2); // report1.pdf and report2.pdf
results.Should().OnlyContain(r => r.Metadata["doctype"].Contains("report"));
}
[Fact]
public async Task SearchBlobs_WithSubFolder_ReturnsOnlyBlobsInFolder()
{
// Arrange
var filters = new List<MetadataFilter>
{
new MetadataFilter { Key = "doctype", Value = "report", FilterType = "equals", Condition = "and" }
};
// Act
var results = await _searchService.SearchBlobsAsync("test-container", "documents", filters);
// Assert
results.Should().HaveCount(2);
results.Should().OnlyContain(r => r.FilePath.StartsWith("documents/"));
}
[Fact]
public async Task SearchBlobs_WithEncodedMetadata_DecodesAndMatches()
{
// Arrange
var filters = new List<MetadataFilter>
{
new MetadataFilter
{
Key = "encodedfield",
Value = "secret-value",
FilterType = "equals",
Condition = "and",
IsValueEncoded = true
}
};
// Act
var results = await _searchService.SearchBlobsAsync("test-container", null, filters);
// Assert
results.Should().HaveCount(1);
results.First().FileName.Should().Be("encoded.txt");
}
}
Key differences from unit tests:
| Unit Test (Moq) | Integration Test (Testcontainers) |
|---|---|
Mock IBlobSearchService | Real BlobSearchService + Azurite |
| Fake metadata responses | Real blob storage with metadata |
| Fast (milliseconds) | Slower (seconds - container startup) |
| Tests interface contract | Tests full blob search behavior |
| No Docker needed | Requires Docker runtime |
Comparison: Unit vs Integration Tests for Azure Functions
| Aspect | Unit Tests (Moq) | Integration Tests (Testcontainers) |
|---|---|---|
| Dependencies | Mocked (Mock<IBlobSearchService>) | Real Azurite container + BlobSearchService |
| Speed | Very fast (< 10ms) | Slower (~2s - container startup) |
| Scope | Interface contract validation | Full Azure Storage interaction |
| Docker Required | No | Yes |
| Complexity | Simple (mock setup) | More setup (fixtures, containers, test data) |
| Tests | Function HTTP handling | Blob operations, metadata queries, filters |
| When to use | Interface contracts, validation | Blob storage logic, complex filters |
Best Practices
✅ DO
- Extract logic into services with interfaces for mockability
- Use Moq for interface contracts - Test that functions call services correctly
- Use Testcontainers for storage operations - Test blob uploads, metadata queries, complex filters
- Share containers via collection fixtures to speed up tests
- Seed realistic test data in fixtures (various metadata combinations)
- Test edge cases in integration tests (case insensitivity, empty results, etc.)
❌ DON’T
- Don’t mock sealed Azure SDK types - Extract to services instead
- Don’t test Azure SDK itself - Focus on your service logic
- Don’t skip integration tests - They catch real-world issues
- Don’t duplicate service logic in test helpers - Use the real service
Running the Tests
# Run all tests (requires Docker for integration tests)
dotnet test
# Run only unit tests (fast, no Docker)
dotnet test --filter "FullyQualifiedName~Unit"
# Run only integration tests (requires Docker)
dotnet test --filter "FullyQualifiedName~Integration"
# Run only blob metadata tests
dotnet test --filter "FullyQualifiedName~BlobMetadata"
Test Results:
✅ Unit Tests: 3 tests in ~30ms
✅ Integration Tests: 12 tests in ~2s (includes container startup)
✅ Total: 128 tests passing
Summary
What you learned:
✅ Service Layer Pattern - Extract blob logic into IBlobSearchService for mockability
✅ Unit testing with Moq - Test interface contracts in isolation
✅ Integration testing with Testcontainers - Test real BlobSearchService against Azurite
✅ Azurite fixture setup - Shared container across tests with realistic test data
✅ Clean architecture - Functions delegate to services, making both testable
Testing strategy for Azure Functions:
| Layer | Test Type | Tools | What to Test |
|---|---|---|---|
| Function (HTTP) | Unit Tests | Moq + xUnit | Request validation, service calls |
| Service (Logic) | Integration Tests | Testcontainers + Azurite | Blob operations, metadata filtering |
| Azure SDK | (Don’t Test) | N/A | Trust Microsoft’s SDK implementation |
Key Pattern:
Azure Function (HTTP) → IBlobSearchService (Interface) → BlobSearchService (Implementation)
↓ ↓ ↓
Unit Test Mock It Integration Test
(Fast, Moq) (Interface Contract) (Real Azurite Container)