컨트롤러: 컨트롤러 로직 테스트하기

등록일시: 2016-11-28 08:00,  수정일시: 2016-12-17 06:28
조회수: 6,729
이 문서는 ASP.NET Core MVC 기술을 널리 알리고자 하는 개인적인 취지로 제공되는 번역문서입니다. 이 문서에 대한 모든 저작권은 마이크로소프트에 있으며 요청이 있을 경우 언제라도 게시가 중단될 수 있습니다. 번역 내용에 오역이 존재할 수 있고 주석은 번역자 개인의 의견일 뿐이며 마이크로소프트는 이에 관한 어떠한 보장도 하지 않습니다. 번역이 완료된 이후에도 대상 제품 및 기술이 개선되거나 변경됨에 따라 원문의 내용도 변경되거나 보완되었을 수 있으므로 주의하시기 바랍니다.
본문에서는 ASP.NET Core MVC 응용 프로그램의 가장 핵심적인 부분인 컨트롤러의 단위 테스트와 통합 테스트 방법에 관해서 살펴봅니다.

ASP.NET MVC 응용 프로그램에서는 컨트롤러를 가급적 작게 유지하고, 사용자-인터페이스 관심사에 중점을 둬야합니다. 비-UI 관심사를 처리하는 큰 규모의 컨트롤러는 테스트나 유지보수가 더 어렵습니다.

GitHub에서 샘플 코드 확인 및 다운로드 받기

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

컨트롤러는 모든 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;
        }
    }
}

반면 HomeControllerHTTP 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 같은 라이브러리를 활용해서 매핑하는 것이 좋습니다.

다음은 CreateForSession 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의 인스턴스로 역직렬화 할 수 있는지 테스트 하고, 아이디어의 컬렉션에 정상적으로 아이디어가 추가됐는지 확인하고 있습니다.

더 많은 통합 테스트 예제는 본문의 예제 프로젝트를 참고하시기 바랍니다.