컨트롤러: 컨트롤러 로직 테스트하기
- 본 번역문서의 원문은 Testing Controller Logic docs.microsoft.com 입니다.
- 본 번역문서는 MVC : Controller - 컨트롤러 로직을 단위 테스트하기 www.taeyo.net 에서도 함께 제공됩니다.
ASP.NET MVC 응용 프로그램에서는 컨트롤러를 가급적 작게 유지하고, 사용자-인터페이스 관심사에 중점을 둬야합니다. 비-UI 관심사를 처리하는 큰 규모의 컨트롤러는 테스트나 유지보수가 더 어렵습니다.
컨트롤러를 테스트 해야 하는 이유
컨트롤러는 모든 ASP.NET Core MVC 응용 프로그램의 가장 핵심적인 부분입니다. 따라서, 응용 프로그램이 의도하는 바대로 컨트롤러가 동작한다는 확신을 갖는 것은 개발자에게 매우 중요합니다. 자동화된 테스트를 사용하면 그런 자신감을 얻는데 도움이 되며, 운영 환경에 배포하기 전에 미리 오류를 감지할 수 있습니다. 이때, 컨트롤러 내부에서 불필요한 처리를 수행하지 않음으로써, 컨트롤러가 본래 수행해야 할 역할에만 테스트를 집중할 수 있는 구조를 만드는 것이 매우 중요합니다.
컨트롤러의 로직은 최소한으로 유지해야 하고, 업무 로직이나 기반구조와 관련된 사항에 (예, 데이터 접근 등) 관여하지 말아야 합니다. 컨트롤러를 테스트 할 때는 컨트롤러의 로직만 테스트해야 하며 프레임워크까지 테스트하면 안됩니다. 유효한 입력이나 유효하지 않은 입력을 기반으로 컨트롤러의 동작 방식을 테스트하고, 컨트롤러가 수행하는 업무 작업의 결과를 기반으로 컨트롤러의 응답을 테스트합니다.
일반적인 컨트롤러의 역할은 다음과 같습니다:
ModelState.IsValid
속성을 검사합니다.ModelState
가 유효하지 않으면 오류 응답을 반환합니다.- 영속화된 저장소에서 업무 엔터티를 조회합니다.
- 업무 엔터티를 대상으로 작업을 수행합니다.
- 업무 엔터티를 영속화된 저장소에 저장합니다.
- 적절한
IActionResult
를 반환합니다.
단위 테스트
단위 테스트(Unit testing)는 응용 프로그램 기반구조나 의존성과는 독립적으로 응용 프로그램의 일부분에 대한 테스트를 수행합니다. 컨트롤러의 로직을 단위 테스트 할 경우, 단일 액션의 내용만 테스트 하며, 의존성의 동작이나 프레임워크 자체는 테스트에 포함되지 않습니다. 컨트롤러 액션을 단위 테스트 할 때는 그 자체의 동작에만 집중해야 합니다. 컨트롤러 단위 테스트에서는 필터, 라우팅, 모델 바인딩 같은 요소들은 다루지 않습니다. 이렇게 한 부분의 테스트에만 집중해야 일반적으로 단위 테스트를 작성하기도 간단하고 신속하게 실행됩니다. 잘 작성된 단위 테스트 모음은 별다른 부하 없이도 빈번하게 실행할 수 있습니다. 그러나 단위 테스트로는 구성 요소들 간의 문제점은 발견할 수 없으며, 이는 통합 테스트(Integration Testing)의 영역입니다.
만약, 사용자 정의 필터, 라우트 등을 구현해서 사용하고 있다면, 해당 구성 요소들에 대해서도 단위 테스트를 수행해야 하지만, 그렇다고 특정 컨트롤러 액션에 대한 테스트의 일부가 되어서는 안됩니다. 각각 독립적으로 테스트해야 합니다.
단위 테스트를 살펴보기 위해서 다음과 같은 컨트롤러를 가정해보겠습니다. 이 컨트롤러는 브레인스토밍 세션들의 목록을 출력하고 POST 방식으로 새로운 브레인스토밍 세션을 생성할 수 있습니다:
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.ViewModels;
namespace TestingControllersSample.Controllers
{
public class HomeController : Controller
{
private readonly IBrainstormSessionRepository _sessionRepository;
public HomeController(IBrainstormSessionRepository sessionRepository)
{
_sessionRepository = sessionRepository;
}
public async Task<IActionResult> Index()
{
var sessionList = await _sessionRepository.ListAsync();
var model = sessionList.Select(session => new StormSessionViewModel()
{
Id = session.Id,
DateCreated = session.DateCreated,
Name = session.Name,
IdeaCount = session.Ideas.Count
});
return View(model);
}
public class NewSessionModel
{
[Required]
public string SessionName { get; set; }
}
[HttpPost]
public async Task<IActionResult> Index(NewSessionModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await _sessionRepository.AddAsync(new BrainstormSession()
{
DateCreated = DateTimeOffset.Now,
Name = model.SessionName
});
return RedirectToAction("Index");
}
}
}
이 컨트롤러는 명시적 의존성 원칙(Explicit Dependencies Principle)을 준수하고 있으며, 의존성 주입을 통해서 IBrainstormSessionRepository
인터페이스의 인스턴스를 제공받습니다.
이는 Moq 같은 모의 개체 프레임워크를 이용한 테스트를 매우 손쉽게 만들어줍니다.
HTTP GET Index
메서드에는 루프나 분기가 존재하지 않으며, 오직 메서드 하나만 호출하고 있습니다.
Index
메서드를 테스트하려면 리파지터리의 List
메서드를 이용해서 가져온 ViewModel
이 설정된 ViewResult
가 반환되는지 여부를 확인하면 됩니다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using TestingControllersSample.Controllers;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.ViewModels;
using Xunit;
namespace TestingControllersSample.Tests.UnitTests
{
public class HomeControllerTests
{
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
var controller = new HomeController(mockRepo.Object);
// Act
var result = await controller.Index();
// Assert
var viewResult = Assert.IsType<ViewResult>(result);
var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
viewResult.ViewData.Model);
Assert.Equal(2, model.Count());
}
private List<BrainstormSession> GetTestSessions()
{
var sessions = new List<BrainstormSession>();
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 2),
Id = 1,
Name = "Test One"
});
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 1),
Id = 2,
Name = "Test Two"
});
return sessions;
}
}
}
반면 HomeController
의 HTTP POST Index
메서드는 다음과 같은 동작들을 테스트해야 합니다:
-
ModelState.IsValid
속성이false
인 경우, 액션 메서드에서 적절한 데이터와 함께 잘못된 요청(Bad Request)ViewResult
를 반환됩니다. -
ModelState.IsValid
속성이true
인 경우, 리파지터리의Add
메서드가 호출되고 올바른 인자와 함께RedirectToActionResult
가 반환됩니다.
유효하지 않은 모델 상태는 다음 예제 코드의 첫 번째 테스트에서 볼 수 있는 것처럼 AddModelError
메서드를 이용해서 오류를 추가하는 방식으로 테스트가 가능합니다.
[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
var controller = new HomeController(mockRepo.Object);
controller.ModelState.AddModelError("SessionName", "Required");
var newSession = new HomeController.NewSessionModel();
// Act
var result = await controller.Index(newSession);
// Assert
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.IsType<SerializableError>(badRequestResult.Value);
}
[Fact]
public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
.Returns(Task.CompletedTask)
.Verifiable();
var controller = new HomeController(mockRepo.Object);
var newSession = new HomeController.NewSessionModel()
{
SessionName = "Test Name"
};
// Act
var result = await controller.Index(newSession);
// Assert
var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Null(redirectToActionResult.ControllerName);
Assert.Equal("Index", redirectToActionResult.ActionName);
mockRepo.Verify();
}
첫 번째 테스트는 ModelState
가 유효하지 않은 경우, GET
요청 시 반환되는 ViewResult
와 동일한 ViewResult
가 반환됨을 확인합니다.
이 테스트에서 유효하지 않은 모델을 전달하려고 시도하지 않는 것에 주목하시기 바랍니다.
설령 시도하더라도 모델 바인딩이 수행되지 않기 때문에 예상한 대로 동작하지 않습니다
(반면 통합 테스트에서는 모델 바인딩을 테스트 합니다).
여기서 모델 바인딩은 테스트되지 않습니다.
이런 단위 테스트는 액션 메서드에서 수행하는 코드만 테스트합니다.
두 번째 테스트는 ModelState
가 유효한 경우, 새로운 BrainstormSession
이 추가되고 (리파지터리를 통해서), 기대하는 속성값이 설정된 RedirectToActionResult
가 메서드에서 반환되는지 여부를 검증합니다.
일반적으로 호출되지 않은 모의 호출은 무시되지만, 모의 개체 설정 과정의 마지막 부분에서 호출된 Verifiable
메서드로 인해서 테스트에서 호출 여부에 대한 검증이 가능해집니다.
즉, 실제 검증은 mockRepo.Verify
메서드 호출을 통해서 수행되는데, 만약 기대하는 메서드가 호출되지 않았으면 테스트가 실패하게 됩니다.
노트
이번 예제에 사용된 Moq 라이브러리를 활용하면 Verifiable (또는 "Strict") 목과 비-Verifiable 목을 (또는 "Loose" 목이나 스텁을) 손쉽게 섞어서 사용할 수 있습니다. 보다 자세한 정보는 Customizing Mock Behavior with Moq 문서를 참고하시기 바랍니다.
응용 프로그램의 또 다른 컨트롤러는 특정 브레인스토밍 세션과 관련된 정보를 출력합니다. 이 컨트롤러에는 유효하지 않은 id 값을 처리하기 위한 약간의 로직이 포함되어 있습니다:
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.ViewModels;
namespace TestingControllersSample.Controllers
{
public class SessionController : Controller
{
private readonly IBrainstormSessionRepository _sessionRepository;
public SessionController(IBrainstormSessionRepository sessionRepository)
{
_sessionRepository = sessionRepository;
}
public async Task<IActionResult> Index(int? id)
{
if (!id.HasValue)
{
return RedirectToAction("Index", "Home");
}
var session = await _sessionRepository.GetByIdAsync(id.Value);
if (session == null)
{
return Content("Session not found.");
}
var viewModel = new StormSessionViewModel()
{
DateCreated = session.DateCreated,
Name = session.Name,
Id = session.Id
};
return View(viewModel);
}
}
}
이 컨트롤러 액션에는 각각의 return
구문마다 한 가지씩, 세 가지 테스트 사례가 존재합니다:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using TestingControllersSample.Controllers;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.ViewModels;
using Xunit;
namespace TestingControllersSample.Tests.UnitTests
{
public class SessionControllerTests
{
[Fact]
public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
{
// Arrange
var controller = new SessionController(sessionRepository: null);
// Act
var result = await controller.Index(id: null);
// Arrange
var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Home", redirectToActionResult.ControllerName);
Assert.Equal("Index", redirectToActionResult.ActionName);
}
[Fact]
public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound()
{
// Arrange
int testSessionId = 1;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.Returns(Task.FromResult((BrainstormSession)null));
var controller = new SessionController(mockRepo.Object);
// Act
var result = await controller.Index(testSessionId);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("Session not found.", contentResult.Content);
}
[Fact]
public async Task IndexReturnsViewResultWithStormSessionViewModel()
{
// Arrange
int testSessionId = 1;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.Returns(Task.FromResult(GetTestSessions().FirstOrDefault(s => s.Id == testSessionId)));
var controller = new SessionController(mockRepo.Object);
// Act
var result = await controller.Index(testSessionId);
// Assert
var viewResult = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<StormSessionViewModel>(viewResult.ViewData.Model);
Assert.Equal("Test One", model.Name);
Assert.Equal(2, model.DateCreated.Day);
Assert.Equal(testSessionId, model.Id);
}
private List<BrainstormSession> GetTestSessions()
{
var sessions = new List<BrainstormSession>();
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 2),
Id = 1,
Name = "Test One"
});
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 1),
Id = 2,
Name = "Test Two"
});
return sessions;
}
}
}
이번에는 예제 응용 프로그램에서 다음과 같이 Web API로 관련 기능들을 제공해준다고 가정해보겠습니다 (브레인스토밍 세션에 관련된 아이디어들의 목록을 제공하거나 세션에 새로운 아이디어를 추가할 수 있는 기능):
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using TestingControllersSample.ClientModels;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
namespace TestingControllersSample.Api
{
[Route("api/ideas")]
public class IdeasController : Controller
{
private readonly IBrainstormSessionRepository _sessionRepository;
public IdeasController(IBrainstormSessionRepository sessionRepository)
{
_sessionRepository = sessionRepository;
}
[HttpGet("forsession/{sessionId}")]
public async Task<IActionResult> ForSession(int sessionId)
{
var session = await _sessionRepository.GetByIdAsync(sessionId);
if (session == null)
{
return NotFound(sessionId);
}
var result = session.Ideas.Select(idea => new IdeaDTO()
{
Id = idea.Id,
Name = idea.Name,
Description = idea.Description,
DateCreated = idea.DateCreated
}).ToList();
return Ok(result);
}
[HttpPost("create")]
public async Task<IActionResult> Create([FromBody]NewIdeaModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var session = await _sessionRepository.GetByIdAsync(model.SessionId);
if (session == null)
{
return NotFound(model.SessionId);
}
var idea = new Idea()
{
DateCreated = DateTimeOffset.Now,
Description = model.Description,
Name = model.Name
};
session.AddIdea(idea);
await _sessionRepository.UpdateAsync(session);
return Ok(session);
}
}
}
ForSession
메서드는 IdeaDTO
형식의 목록을 반환해줍니다.
이런 상황에서는 API 호출을 통해서 업무 도메인 엔터티를 직접적으로 반환하는 것은 좋지 않습니다.
그럴 경우, 대부분 API 클라이언트가 필요로 하는 양 이상의 데이터가 포함되는 경우가 많고, 응용 프로그램의 내부 도메인 모델과 외부로 노출되는 API가 불필요할 정도로 강력하게 결합되기 때문입니다.
도메인 엔터티와 네트워크를 통해서 반환될 형식은, 수작업이나 (예제에서처럼 LINQ의 Select
를 이용해서) AutoMapper 같은 라이브러리를 활용해서 매핑하는 것이 좋습니다.
다음은 Create
및 ForSession
API 메서드에 대한 테스트입니다:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using TestingControllersSample.Api;
using TestingControllersSample.ClientModels;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using Xunit;
namespace TestingControllersSample.Tests.UnitTests
{
public class ApiIdeasControllerTests
{
[Fact]
public async Task Create_ReturnsBadRequest_GivenInvalidModel()
{
// Arrange & Act
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
controller.ModelState.AddModelError("error","some error");
// Act
var result = await controller.Create(model: null);
// Assert
Assert.IsType<BadRequestObjectResult>(result);
}
[Fact]
public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.Returns(Task.FromResult((BrainstormSession)null));
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.Create(new NewIdeaModel());
// Assert
Assert.IsType<NotFoundObjectResult>(result);
}
[Fact]
public async Task Create_ReturnsNewlyCreatedIdeaForSession()
{
// Arrange
int testSessionId = 123;
string testName = "test name";
string testDescription = "test description";
var testSession = GetTestSession();
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.Returns(Task.FromResult(testSession));
var controller = new IdeasController(mockRepo.Object);
var newIdea = new NewIdeaModel()
{
Description = testDescription,
Name = testName,
SessionId = testSessionId
};
mockRepo.Setup(repo => repo.UpdateAsync(testSession))
.Returns(Task.CompletedTask)
.Verifiable();
// Act
var result = await controller.Create(newIdea);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var returnSession = Assert.IsType<BrainstormSession>(okResult.Value);
mockRepo.Verify();
Assert.Equal(2, returnSession.Ideas.Count());
Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name);
Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description);
}
private BrainstormSession GetTestSession()
{
var session = new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 2),
Id = 1,
Name = "Test One"
};
var idea = new Idea() { Name = "One" };
session.AddIdea(idea);
return session;
}
}
}
이미 살펴봤던 것처럼, ModelState
가 유효하지 않은 상태의 메서드 동작을 테스트하려면 테스트의 일부로 컨트롤러에 모델 오류를 추가하면 됩니다.
컨트롤러를 대상으로 한 단위 테스트에서는 유효성 검사나 모델 바인딩에 대한 테스트는 시도하지 마십시오.
특정 ModelState
값에 대한 액션 메서드의 동작만 테스트하면 됩니다.
두 번째 테스트는 리파지터리에서 null이 반환되는 경우가 테스트의 대상이므로, null을 반환하도록 모의 리파지터리를 구성합니다. 이 때, 테스트 데이터베이스를 (메모리 상으로, 혹은 다른 방법으로) 생성할 필요는 없으며, 필요한 결과를 반환하는 쿼리만 생성하면 되는데, 예제에서 볼 수 있는 것처럼 이 작업은 단일 구문만으로 처리할 수 있습니다.
마지막 테스트는 리파지터리의 Update
메서드가 호출되는지를 검사합니다.
이미 앞에서 해봤던 것처럼, 목 설정에서 Verifiable
메서드를 호출해서 검증 대상으로 지정한 다음, 모의 리파지터리의 Verify
메서드를 호출해서 해당 메서드의 실행 여부를 확인합니다.
Update
메서드가 정상적으로 데이터를 저장하는지 여부를 확인하는 것은 컨트롤러 단위 테스트의 역할이 아니며, 이는 통합 테스트를 통해서 수행할 수 있는 작업입니다.
통합 테스트
통합 테스트(Integration Testing)는 응용 프로그램의 개별적인 모듈들이 함께 어울려서 정상적으로 동작하는지를 검토하기 위해서 수행됩니다. 일반적으로 단위 테스트를 통해서 수행할 수 있는 거의 모든 테스트는 통합 테스트에서도 테스트 할 수 있지만, 그 반대는 불가능합니다. 그러나 통합 테스트는 단위 테스트에 비해 상당히 느린 경향이 있습니다. 따라서, 단위 테스트로 수행할 수 있는 테스트는 단위 테스트로 수행하고, 여러 모듈들이 함께 동작하는 시나리오에서는 통합 테스트를 사용하는 것이 바람직합니다.
통합 테스트에서도 모의 개체를 유용하게 사용할 수는 있겠지만, 실제로 사용되는 경우는 거의 드뭅니다. 단위 테스트에서는 모의 개체를 이용해서 단위 외부의 관련 모듈들이 동작하는 방식을 테스트에 적합하게 효과적으로 제어할 수 있습니다. 반면 통합 테스트에서는 실제 관련 모듈들을 이용해서 연관된 모든 하부 시스템들이 정상적으로 함께 동작하는지를 확인합니다.
응용 프로그램 상태
통합 테스트를 수행할 때 고려해야 할 한 가지 중요한 사항은 응용 프로그램의 상태를 어떻게 설정할 것인지 입니다.
테스트는 서로 독립적으로 수행돼야 하므로, 각 테스트는 응용 프로그램이 의도한 특정 상태에 존재하는 상황에서부터 시작되어야 합니다.
만약 응용 프로그램이 데이터베이스나 기타 영속화된 저장소를 사용하지 않는다면, 이 점은 별다른 문제가 되지 않습니다.
그러나 대부분의 실제 응용 프로그램들은 자신의 상태를 특정 유형의 데이터 저장소에 저장하기 때문에, 데이터 저장소를 재설정하지 않는 이상 특정 테스트로 인해 발생하는 모든 변경사항은 다른 테스트에 영향을 주게 됩니다.
내장 TestServer
를 사용하면 매우 간단하게 ASP.NET Core 응용 프로그램과 통합 테스트를 호스트 할 수 있지만, 사용하고자 하는 데이터에 항상 접근이 허용된다는 보장은 없습니다.
반면, 실제 데이터베이스를 사용하는 경우 선택할 수 있는 한 가지 방법은, 테스트가 접근할 수 있고 매번 테스트를 수행하기 전에 재설정되는 것이 보장되는 테스트 데이터베이스에 응용 프로그램을 연결하는 것입니다.
본문의 예제 응용 프로그램에서는 Entity Framework Core의 InMemoryDatabase 기능을 사용하기 때문에, 테스트 프로젝트에서는 연결이 불가능합니다.
그 대신, 응용 프로그램의 Startup
클래스에서 응용 프로그램이 Development
환경에서 구동될 때마다 호출되는 InitializeDatabase
라는 메서드를 노출할 것입니다.
따라서 환경이 Development
로 설정되어 있기만 하면, 통합 테스트는 자동으로 그 효과를 누릴 수 있습니다.
데이터베이스를 재설정하는 문제도 신경쓸 필요가 없는데, 응용 프로그램이 재시작 될 때마다 InMemoryDatabase가 재설정되기 때문입니다.
다음은 Startup
클래스를 보여줍니다:
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.Infrastructure;
namespace TestingControllersSample
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<AppDbContext>(
optionsBuilder => optionsBuilder.UseInMemoryDatabase());
services.AddMvc();
services.AddScoped<IBrainstormSessionRepository,
EFStormSessionRepository>();
}
public void Configure(IApplicationBuilder app,
IHostingEnvironment env,
ILoggerFactory loggerFactory)
{
loggerFactory.AddConsole(LogLevel.Warning);
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
var repository = app.ApplicationServices.GetService<IBrainstormSessionRepository>();
InitializeDatabaseAsync(repository).Wait();
}
app.UseStaticFiles();
app.UseMvcWithDefaultRoute();
}
public async Task InitializeDatabaseAsync(IBrainstormSessionRepository repo)
{
var sessionList = await repo.ListAsync();
if (!sessionList.Any())
{
await repo.AddAsync(GetTestSession());
}
}
public static BrainstormSession GetTestSession()
{
var session = new BrainstormSession()
{
Name = "Test Session 1",
DateCreated = new DateTime(2016, 8, 1)
};
var idea = new Idea()
{
DateCreated = new DateTime(2016, 8, 1),
Description = "Totally awesome idea",
Name = "Awesome idea"
};
session.AddIdea(idea);
return session;
}
}
}
지금부터 살펴보게 될 통합 테스트에서는 GetTestSession
메서드가 사용되는 것을 자주 확인할 수 있을 것입니다.
뷰에 접근하기
각각의 통합 테스트 클래스는 ASP.NET Core 응용 프로그램이 실행될 TestServer
를 구성합니다.
기본적으로 TestServer
는 웹 응용 프로그램이 실행되는 폴더에서 웹 응용 프로그램을 호스트하는데, 지금과 같은 경우에는 테스트 프로젝트의 폴더가 됩니다.
따라서 ViewResult
를 반환하는 컨트롤러 액션을 테스트하려고 시도하면, 다음과 같은 오류가 발생합니다:
The view 'Index' was not found. The following locations were searched:
(list of locations)
이 문제점을 해결하려면, 테스트 되는 프로젝트의 뷰를 검색할 수 있도록 서버의 콘텐트 루트를 구성해야 합니다.
이 작업은 다음과 같이 TestFixture
클래스에서 UseContentRoot
메서드를 호출해서 수행할 수 있습니다:
using System;
using System.IO;
using System.Net.Http;
using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.PlatformAbstractions;
namespace TestingControllersSample.Tests.IntegrationTests
{
/// <summary>
/// A test fixture which hosts the target project (project we wish to test) in an in-memory server.
/// </summary>
/// <typeparam name="TStartup">Target project's startup type</typeparam>
public class TestFixture<TStartup> : IDisposable
{
private const string SolutionName = "TestingControllersSample.sln";
private readonly TestServer _server;
public TestFixture()
: this(Path.Combine("src"))
{
}
protected TestFixture(string solutionRelativeTargetProjectParentDir)
{
var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
var contentRoot = GetProjectPath(solutionRelativeTargetProjectParentDir, startupAssembly);
var builder = new WebHostBuilder()
.UseContentRoot(contentRoot)
.ConfigureServices(InitializeServices)
.UseEnvironment("Development")
.UseStartup(typeof(TStartup));
_server = new TestServer(builder);
Client = _server.CreateClient();
Client.BaseAddress = new Uri("http://localhost");
}
public HttpClient Client { get; }
public void Dispose()
{
Client.Dispose();
_server.Dispose();
}
protected virtual void InitializeServices(IServiceCollection services)
{
var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
// Inject a custom application part manager. Overrides AddMvcCore() because that uses TryAdd().
var manager = new ApplicationPartManager();
manager.ApplicationParts.Add(new AssemblyPart(startupAssembly));
manager.FeatureProviders.Add(new ControllerFeatureProvider());
manager.FeatureProviders.Add(new ViewComponentFeatureProvider());
services.AddSingleton(manager);
}
/// <summary>
/// Gets the full path to the target project path that we wish to test
/// </summary>
/// <param name="solutionRelativePath">
/// The parent directory of the target project.
/// e.g. src, samples, test, or test/Websites
/// </param>
/// <param name="startupAssembly">The target project's assembly.</param>
/// <returns>The full path to the target project.</returns>
private static string GetProjectPath(string solutionRelativePath, Assembly startupAssembly)
{
// Get name of the target project which we want to test
var projectName = startupAssembly.GetName().Name;
// Get currently executing test project path
var applicationBasePath = PlatformServices.Default.Application.ApplicationBasePath;
// Find the folder which contains the solution file. We then use this information to find the target
// project which we want to test.
var directoryInfo = new DirectoryInfo(applicationBasePath);
do
{
var solutionFileInfo = new FileInfo(Path.Combine(directoryInfo.FullName, SolutionName));
if (solutionFileInfo.Exists)
{
return Path.GetFullPath(Path.Combine(directoryInfo.FullName, solutionRelativePath, projectName));
}
directoryInfo = directoryInfo.Parent;
}
while (directoryInfo.Parent != null);
throw new Exception($"Solution root could not be located using application root {applicationBasePath}.");
}
}
}
TestFixture
클래스는 TestServer
를 생성 및 구성하고, TestServer
와 통신하기 위한 HttpClient
를 설정하는 역할을 수행합니다.
각각의 통합 테스트는 Client
속성을 이용해서 테스트 서버에 연결하고 요청을 만들어냅니다.
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;
namespace TestingControllersSample.Tests.IntegrationTests
{
public class HomeControllerTests : IClassFixture<TestFixture<TestingControllersSample.Startup>>
{
private readonly HttpClient _client;
public HomeControllerTests(TestFixture<TestingControllersSample.Startup> fixture)
{
_client = fixture.Client;
}
[Fact]
public async Task ReturnsInitialListOfBrainstormSessions()
{
// Arrange - get a session known to exist
var testSession = Startup.GetTestSession();
// Act
var response = await _client.GetAsync("/");
// Assert
response.EnsureSuccessStatusCode();
var responseString = await response.Content.ReadAsStringAsync();
Assert.True(responseString.Contains(testSession.Name));
}
[Fact]
public async Task PostAddsNewBrainstormSession()
{
// Arrange
string testSessionName = Guid.NewGuid().ToString();
var data = new Dictionary<string, string>();
data.Add("SessionName", testSessionName);
var content = new FormUrlEncodedContent(data);
// Act
var response = await _client.PostAsync("/", content);
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal("/", response.Headers.Location.ToString());
}
}
}
이번 예제의 첫 번째 테스트에서는 뷰를 통해서 실제로 렌더된 HTML이 responseString
변수에 담겨지며, 기대하는 결과가 담겨 있는지 확인하기 위해서 그 내용을 검토할 수 있습니다.
두 번째 테스트는 유일한 세션 이름을 사용해서 POST 양식을 구성하고 이를 응용 프로그램으로 POST 한 다음, 기대하고 있는 재전송이 반환되는지를 검사합니다.
API 메서드
만약 응용 프로그램에서 Web API를 제공하고 있다면, 자동화된 테스트를 이용해서 API가 기대하는 대로 수행되는지 확인하는 것이 좋습니다.
내장 TestServer
를 사용하면 손쉽게 Web API를 테스트 할 수 있습니다.
만약 API 메서드에서 모델 바인딩을 사용하고 있다면, 항상 ModelState.IsValid
속성을 확인해야 합니다.
통합 테스트는 모델의 유효성 검사가 절적히 동작하고 있는지 확인하기에 적합한 장소입니다.
다음 테스트 모음은 앞에서 살펴본 IdeasController 클래스의 Create
메서드를 대상으로 합니다:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using TestingControllersSample.ClientModels;
using TestingControllersSample.Core.Model;
using Xunit;
namespace TestingControllersSample.Tests.IntegrationTests
{
public class ApiIdeasControllerTests : IClassFixture<TestFixture<TestingControllersSample.Startup>>
{
internal class NewIdeaDto
{
public NewIdeaDto(string name, string description, int sessionId)
{
Name = name;
Description = description;
SessionId = sessionId;
}
public string Name { get; set; }
public string Description { get; set; }
public int SessionId { get; set; }
}
private readonly HttpClient _client;
public ApiIdeasControllerTests(TestFixture<TestingControllersSample.Startup> fixture)
{
_client = fixture.Client;
}
[Fact]
public async Task CreatePostReturnsBadRequestForMissingNameValue()
{
// Arrange
var newIdea = new NewIdeaDto("", "Description", 1);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task CreatePostReturnsBadRequestForMissingDescriptionValue()
{
// Arrange
var newIdea = new NewIdeaDto("Name", "", 1);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task CreatePostReturnsBadRequestForSessionIdValueTooSmall()
{
// Arrange
var newIdea = new NewIdeaDto("Name", "Description", 0);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task CreatePostReturnsBadRequestForSessionIdValueTooLarge()
{
// Arrange
var newIdea = new NewIdeaDto("Name", "Description", 1000001);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task CreatePostReturnsNotFoundForInvalidSession()
{
// Arrange
var newIdea = new NewIdeaDto("Name", "Description", 123);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task CreatePostReturnsCreatedIdeaWithCorrectInputs()
{
// Arrange
var testIdeaName = Guid.NewGuid().ToString();
var newIdea = new NewIdeaDto(testIdeaName, "Description", 1);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
response.EnsureSuccessStatusCode();
var returnedSession = await response.Content.ReadAsJsonAsync<BrainstormSession>();
Assert.Equal(2, returnedSession.Ideas.Count);
Assert.True(returnedSession.Ideas.Any(i => i.Name == testIdeaName));
}
[Fact]
public async Task ForSessionReturnsNotFoundForBadSessionId()
{
// Arrange & Act
var response = await _client.GetAsync("/api/ideas/forsession/500");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task ForSessionReturnsIdeasForValidSessionId()
{
// Arrange
var testSession = Startup.GetTestSession();
// Act
var response = await _client.GetAsync("/api/ideas/forsession/1");
// Assert
response.EnsureSuccessStatusCode();
var ideaList = JsonConvert.DeserializeObject<List<IdeaDTO>>(
await response.Content.ReadAsStringAsync());
var firstIdea = ideaList.First();
Assert.Equal(testSession.Ideas.First().Name, firstIdea.Name);
}
}
}
마지막 테스트에서 확인할 수 있는 것처럼, HTML 뷰를 반환하는 일반적인 액션의 통합 테스트와는 달리, Web API 메서드가 반환하는 결과는 대부분 강력한 형식의 개체로 역직렬화 될 수 있습니다.
여기서는 결과를 BrainstormSession
의 인스턴스로 역직렬화 할 수 있는지 테스트 하고, 아이디어의 컬렉션에 정상적으로 아이디어가 추가됐는지 확인하고 있습니다.
더 많은 통합 테스트 예제는 본문의 예제 프로젝트를 참고하시기 바랍니다.
- 컨트롤러: 컨트롤러, 액션 및 액션 결과 2016-10-24 08:00
- 컨트롤러: 필터 2016-10-31 08:00
- 컨트롤러: 의존성 주입과 컨트롤러 2016-11-07 08:00
- 컨트롤러: 영역 2016-11-14 08:00
- 컨트롤러: 컨트롤러 액션으로 라우팅하기 2016-11-21 08:00
- 컨트롤러: 컨트롤러 로직 테스트하기 2016-11-28 08:00