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
통합 테스트 설정은 일반적으로 다음과 같은 순서로 진행됩니다:
- Testcontainers를 사용해 Azurite 컨테이너를 시작합니다.
- 컨테이너를 가리키는
BlobServiceClient를 초기화합니다. - 테스트용 블롭과 메타데이터를 채워 넣습니다.
BlobSearchService.SearchBlobsAsync를 호출하고 결과를 검증합니다.
구현 세부 내용은 간결성을 위해 여기서는 생략했습니다.