DEV Community

Daniel Jonathan
Daniel Jonathan

Posted on

Testing Azure Functions: Unit Tests with Moq & Integration Tests with Testcontainers

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:

  1. Unit Testing with Moq - Test function logic in isolation by mocking IBlobSearchService interface
  2. Integration Testing with Testcontainers - Test the real BlobSearchService against 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() returns AsyncPageable<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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Test Results:

✅ Unit Tests: 3 tests in ~30ms
✅ Integration Tests: 12 tests in ~2s (includes container startup)
✅ Total: 128 tests passing
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)