Azure Functions 테스트: Moq를 사용한 단위 테스트 및 Testcontainers를 사용한 통합 테스트

발행: (2025년 12월 8일 오전 05:09 GMT+9)
5 min read
원문: Dev.to

Source: Dev.to

개요

Moq를 사용한 단위 테스트(서비스 인터페이스 모킹)와 Testcontainers를 사용한 통합 테스트(실제 Azurite Blob 저장소)로 Azure Functions를 테스트합니다. 이 가이드는 BlobMetadataSearch 함수와 IBlobSearchService 패턴을 예제로 보여줍니다.

테스트 접근 방식

접근 방식설명
Moq를 이용한 단위 테스트IBlobSearchService를 모킹하여 함수 로직을 격리된 상태에서 테스트합니다.
Testcontainers를 이용한 통합 테스트Docker에서 Azurite(Azure Storage 에뮬레이터)를 실행하고 실제 BlobSearchService를 테스트합니다.

예제 함수: BlobMetadataSearch

의존성 주입을 사용해 메타데이터 필터로 블롭을 검색하는 HTTP 트리거 함수입니다.

서비스 인터페이스

// Interface - Mockable for unit tests
public interface IBlobSearchService
{
    Task> SearchBlobsAsync(
        string containerName,
        string? subFolder,
        List? filters);
}

서비스 구현

using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;

public class BlobSearchService : IBlobSearchService
{
    private readonly BlobServiceClient _blobServiceClient;

    public BlobSearchService(BlobServiceClient blobServiceClient)
    {
        _blobServiceClient = blobServiceClient;
    }

    public async Task> SearchBlobsAsync(
        string containerName,
        string? subFolder,
        List? filters)
    {
        var container = _blobServiceClient.GetBlobContainerClient(containerName);
        var results = new List();
        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 metadata, List? 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;
    }
}

Azure Function 코드 (BlobMetadataSearch.cs)

using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using System.Text.Json;

public class BlobMetadataSearch
{
    private readonly ILogger _logger;
    private readonly IBlobSearchService _searchService;

    public BlobMetadataSearch(
        ILogger logger,
        IBlobSearchService searchService)
    {
        _logger = logger;
        _searchService = searchService;
    }

    [Function("BlobMetadataSearch")]
    public async Task RunAsync(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
    {
        var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        var input = JsonSerializer.Deserialize(
            requestBody,
            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);
    }
}

애플리케이션 시작 (Program.cs)

using Azure.Storage.Blobs;

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .ConfigureServices(services =>
    {
        services.AddSingleton(sp =>
            new BlobServiceClient(Environment.GetEnvironmentVariable("AzureWebJobsStorage")));

        services.AddTransient();
    })
    .Build();

await host.RunAsync();

단위 테스트 (Moq를 이용한 격리)

필요한 패키지를 추가합니다:

dotnet add package Moq --version 4.20.72
dotnet add package FluentAssertions --version 6.12.2
dotnet add package xunit --version 2.9.2

테스트 클래스 (BlobMetadataSearchTests.cs)

using FluentAssertions;
using Moq;
using Xunit;

public class BlobMetadataSearchTests
{
    private readonly Mock _mockSearchService;

    public BlobMetadataSearchTests()
    {
        _mockSearchService = new Mock();
    }

    [Fact]
    public async Task SearchBlobsAsync_WithValidContainer_ReturnsResults()
    {
        // Arrange
        var expectedResults = new List
        {
            new BlobSearchResult
            {
                FileName = "report1.pdf",
                FilePath = "documents/report1.pdf",
                Metadata = new Dictionary { { "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
        {
            new MetadataFilter { Key = "department", Value = "finance", FilterType = "equals", Condition = "and" }
        };

        _mockSearchService
            .Setup(s => s.SearchBlobsAsync("test-container", null, filters))
            .ReturnsAsync(new List());

        // 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());

        // Act
        await _mockSearchService.Object.SearchBlobsAsync("test-container", "documents", null);

        // Assert
        _mockSearchService.Verify(s => s.SearchBlobsAsync("test-container", "documents", null), Times.Once);
    }
}

단위 테스트의 주요 장점

  • 빠름 – Docker 컨테이너가 필요 없습니다.
  • 단순 – Azure SDK가 아니라 인터페이스만 모킹합니다.
  • 집중 – 함수의 HTTP 처리 및 검증 로직만 테스트합니다.

제한 사항

  • 실제 블롭 검색 구현은 테스트되지 않습니다.

통합 테스트 (실제 Azurite 컨테이너)

필요한 패키지를 추가합니다:

dotnet add package Testcontainers.Azurite --version 4.3.0
dotnet add package xunit --version 2.9.2

통합 테스트 설정은 일반적으로 다음과 같은 순서로 진행됩니다:

  1. Testcontainers를 사용해 Azurite 컨테이너를 시작합니다.
  2. 컨테이너를 가리키는 BlobServiceClient를 초기화합니다.
  3. 테스트용 블롭과 메타데이터를 채워 넣습니다.
  4. BlobSearchService.SearchBlobsAsync를 호출하고 결과를 검증합니다.

구현 세부 내용은 간결성을 위해 여기서는 생략했습니다.

Back to Blog

관련 글

더 보기 »