Moq와 xUnit을 이용한 ASP.NET Core Web API 단위 테스트 (컨트롤러 + 서비스)

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

Source: Dev.to

Moq란?

Moq는 실제 종속성을 가벼운 테스트 더블로 교체하여 로직을 격리된 상태에서 테스트할 수 있게 해줍니다.

핵심 메서드

  • Setup() → 모의 동작 정의
  • ReturnsAsync() → 비동기 메서드에 대한 반환값 지정
  • ThrowsAsync() → 실패 상황 시뮬레이션
  • Verify() → 종속성이 호출되었는지 검증

이 가이드의 모든 내용은 Mock → Execute → Validate 패턴을 따릅니다.

웹 API 컨트롤러를 테스트해야 하는 이유는?

컨트롤러는 HTTP 요청을 처리하고 응답을 반환합니다. 테스트를 통해 다음을 보장할 수 있습니다:

  • ✅ 올바른 HTTP 상태 코드(200, 404, 400, 201)
  • ✅ 서비스가 올바른 매개변수로 호출되는지
  • ✅ 검증이 정상적으로 동작하는지
  • ✅ 오류가 우아하게 처리되는지

규칙: 컨트롤러는 얇게 유지해야 합니다 – 비즈니스 로직을 구현하지 않고 오케스트레이션만 수행합니다.

설정 (2분)

dotnet new xunit -n YourApi.Tests
cd YourApi.Tests
dotnet add package Moq --version 4.20.72
dotnet add package FluentAssertions --version 6.12.2
dotnet add reference ../YourApi/YourApi.csproj

패턴: Mock → Execute → Validate

예제 컨트롤러 (ProductsController)

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ILogger _logger;

    public ProductsController(IProductService productService, ILogger logger)
    {
        _productService = productService;
        _logger = logger;
    }

    [HttpGet("{id}")]
    public async Task> GetById(int id)
    {
        var product = await _productService.GetProductAsync(id);

        if (product == null)
            return NotFound(new { message = $"Product with ID {id} not found" });

        var response = new ProductResponse
        {
            Id = product.Id,
            Name = product.Name,
            SKU = product.SKU,
            Description = product.Description,
            Price = product.Price,
            IsActive = product.IsActive,
            Category = product.Category != null
                ? new CategoryResponse { Id = product.Category.Id, Name = product.Category.Name }
                : new CategoryResponse()
        };

        return Ok(response);
    }

    [HttpPost]
    public async Task> Create([FromBody] CreateProductRequest request)
    {
        try
        {
            var product = await _productService.CreateProductAsync(request);

            var response = new ProductResponse
            {
                Id = product.Id,
                Name = product.Name,
                SKU = product.SKU,
                Price = product.Price,
                IsActive = product.IsActive
            };

            return CreatedAtAction(nameof(GetById), new { id = product.Id }, response);
        }
        catch (InvalidOperationException ex)
        {
            return Conflict(new { message = ex.Message });
        }
    }
}

컨트롤러 테스트

컨트롤러는 서비스를 모킹하고 액션을 호출한 뒤 ActionResult(상태 코드 + 응답 본문)를 검증함으로써 테스트합니다.

테스트 클래스 설정

using CommonComps.Models;
using CommonComps.Models.Requests;
using CommonComps.Models.Responses;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using ProductWebAPI.Controllers;
using ProductWebAPI.Services;
using Xunit;

public class ProductsControllerTests
{
    private readonly Mock _mockService;
    private readonly Mock> _mockLogger;
    private readonly ProductsController _controller;

    public ProductsControllerTests()
    {
        _mockService = new Mock();
        _mockLogger = new Mock>();
        _controller = new ProductsController(_mockService.Object, _mockLogger.Object);
    }
}

GET 엔드포인트 테스트

[Fact]
public async Task GetById_ReturnsOk_WhenProductExists()
{
    // Arrange
    var product = new Product
    {
        Id = 1,
        Name = "Laptop",
        SKU = "LAP-001",
        Price = 999.99m,
        IsActive = true,
        Category = new Category { Id = 1, Name = "Electronics" }
    };

    _mockService.Setup(s => s.GetProductAsync(1))
        .ReturnsAsync(product);

    // Act
    var result = await _controller.GetById(1);

    // Assert
    var okResult = result.Result.Should().BeOfType().Subject;
    var response = okResult.Value.Should().BeOfType().Subject;
    response.Id.Should().Be(1);
    response.Name.Should().Be("Laptop");
    _mockService.Verify(s => s.GetProductAsync(1), Times.Once);
}

[Fact]
public async Task GetById_ReturnsNotFound_WhenProductDoesNotExist()
{
    // Arrange
    _mockService.Setup(s => s.GetProductAsync(999))
        .ReturnsAsync((Product?)null);

    // Act
    var result = await _controller.GetById(999);

    // Assert
    result.Result.Should().BeOfType();
}

POST 엔드포인트 테스트

[Fact]
public async Task Create_ReturnsCreated_WhenSuccessful()
{
    // Arrange
    var request = new CreateProductRequest
    {
        Name = "New Product",
        SKU = "NEW-001",
        Price = 49.99m,
        CategoryId = 1
    };

    var createdProduct = new Product
    {
        Id = 42,
        Name = "New Product",
        SKU = "NEW-001",
        Price = 49.99m,
        IsActive = true
    };

    _mockService.Setup(s => s.CreateProductAsync(It.IsAny()))
        .ReturnsAsync(createdProduct);

    // Act
    var result = await _controller.Create(request);

    // Assert
    var createdResult = result.Result.Should().BeOfType().Subject;
    createdResult.StatusCode.Should().Be(201);
    createdResult.ActionName.Should().Be(nameof(ProductsController.GetById));

    var response = createdResult.Value.Should().BeOfType().Subject;
    response.Id.Should().Be(42);
    _mockService.Verify(s => s.CreateProductAsync(It.IsAny()), Times.Once);
}

[Fact]
public async Task Create_ReturnsConflict_WhenDuplicateSKU()
{
    // Arrange
    var request = new CreateProductRequest { Name = "Test", SKU = "DUP-001" };

    _mockService.Setup(s => s.CreateProductAsync(It.IsAny()))
        .ThrowsAsync(new InvalidOperationException("Product with SKU DUP-001 already exists"));

    // Act
    var result = await _controller.Create(request);

    // Assert
    result.Result.Should().BeOfType();
}

데모 GIF

주요 Moq 기법

반환값 설정

// Return specific value
_mockService.Setup(s => s.GetProductAsync(1)).ReturnsAsync(product);

// Return null
_mockService.Setup(s => s.GetProductAsync(999)).ReturnsAsync((Product?)null);

// Return for any argument
_mockService.Setup(s => s.GetProductAsync(It.IsAny())).ReturnsAsync(product);
Back to Blog

관련 글

더 보기 »

체크박스 Aria TagHelper

소개 ASP.NET Core 페이지에 존재하는 모든 체크박스 입력을 사용자 정의 TagHelper를 사용해 초기화하는 방법을 배웁니다. TagHelper는 checked attr를 평가합니다.