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

주요 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);