ASP.NET Core에서 컨트롤러 논리 테스트Test controller logic in ASP.NET Core

작성자: Steve SmithBy Steve Smith

ASP.NET MVC 앱의 컨트롤러는 크기가 작아야 하고 사용자 인터페이스 문제에 초점을 맞춰야 합니다.Controllers in ASP.NET MVC apps should be small and focused on user-interface concerns. UI 이외의 문제를 처리하는 대형 컨트롤러는 테스트 및 유지 관리가 더 어렵습니다.Large controllers that deal with non-UI concerns are more difficult to test and maintain.

GitHub에서 샘플 보기 또는 다운로드View or download sample from GitHub

컨트롤러 테스트Testing controllers

컨트롤러는 모든 ASP.NET Core MVC 응용 프로그램의 핵심 요소입니다.Controllers are a central part of any ASP.NET Core MVC application. 따라서 앱에서 의도한 대로 동작한다고 확신할 수 있어야 합니다.As such, you should have confidence they behave as intended for your app. 자동화된 테스트를 통해 이러한 확신을 얻고 오류가 발생하기 전에 미리 검색할 수 있습니다.Automated tests can provide you with this confidence and can detect errors before they reach production. 컨트롤러 내에 불필요한 책임을 지우는 것을 피하고 테스트의 초점을 컨트롤러 책임으로 집중하는 것이 중요합니다.It's important to avoid placing unnecessary responsibilities within your controllers and ensure your tests focus only on controller responsibilities.

컨트롤러 논리는 최소화되어야 하고 비즈니스 논리나 인프라 문제(예: 데이터 액세스)에 집중하면 안 됩니다.Controller logic should be minimal and not be focused on business logic or infrastructure concerns (for example, data access). 프레임워크가 아닌 컨트롤러 논리를 테스트합니다.Test controller logic, not the framework. 유효한 입력 또는 잘못된 입력을 통해 컨트롤러의 동작 방식을 테스트합니다.Test how the controller behaves based on valid or invalid inputs. 컨트롤러가 수행하는 비즈니스 작업의 결과를 바탕으로 컨트롤러 응답을 테스트합니다.Test controller responses based on the result of the business operation it performs.

컨트롤러의 일반적인 책임:Typical controller responsibilities:

  • ModelState.IsValid를 확인합니다.Verify ModelState.IsValid.
  • ModelState가 올바르지 않으면 오류 응답을 반환합니다.Return an error response if ModelState is invalid.
  • 지속성 저장소에서 비즈니스 엔터티를 검색합니다.Retrieve a business entity from persistence.
  • 비즈니스 엔터티에서 작업을 수행합니다.Perform an action on the business entity.
  • 보관할 비즈니스 엔터티를 저장합니다.Save the business entity to persistence.
  • 적절한 IActionResult를 반환합니다.Return an appropriate IActionResult.

유닛 테스트Unit testing

단위 테스트는 인프라 및 종속성으로부터 격리된 상태에서 앱의 일부를 테스트하는 것입니다.Unit testing involves testing a part of an app in isolation from its infrastructure and dependencies. 컨트롤러 논리를 단위 테스트하는 경우 단일 작업의 콘텐츠만 테스트되고, 종속성 또는 프레임워크 자체의 동작은 테스트되지 않습니다.When unit testing controller logic, only the contents of a single action is tested, not the behavior of its dependencies or of the framework itself. 컨트롤러 작업을 단위 테스트할 때에는 동작에만 집중해야 합니다.As you unit test your controller actions, make sure you focus only on its behavior. 컨트롤러 단위 테스트는 필터, 라우팅, 모델 바인딩 같은 것들이 필요 없습니다.A controller unit test avoids things like filters, routing, or model binding. 단위 테스트는 하나를 테스트하는 데 집중하기 때문에 일반적으로 작성 방법이 간단하고 신속하게 실행할 수 있습니다.By focusing on testing just one thing, unit tests are generally simple to write and quick to run. 잘 작성된 단위 테스트 집합은 많은 오버헤드 없이 자주 실행할 수 있습니다.A well-written set of unit tests can be run frequently without much overhead. 그러나 단위 테스트는 구성 요소 간의 상호 작용 문제를 검색하지 않습니다. 이 문제는 통합 테스트의 목적입니다.However, unit tests don't detect issues in the interaction between components, which is the purpose of integration tests.

사용자 지정 필터, 경로 등을 작성할 때 단위 테스트를 수행해야 하지만, 특정 컨트롤러 작업에 대한 테스트의 일부로 수행하면 안 됩니다.If you're writing custom filters, routes, etc, you should unit test them, but not as part of your tests on a particular controller action. 격리된 상태에서 테스트해야 합니다.They should be tested in isolation.

단위 테스트를 시연하려면 다음 컨트롤러를 검토합니다.To demonstrate unit testing, review the following controller. 이 컨트롤러는 브레인스토밍 세션 목록을 표시하며 POST를 사용하여 새로운 브레인스토밍 세션을 만들 수 있습니다.It displays a list of brainstorming sessions and allows new brainstorming sessions to be created with a 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);
            }
            else
            {
                await _sessionRepository.AddAsync(new BrainstormSession()
                {
                    DateCreated = DateTimeOffset.Now,
                    Name = model.SessionName
                });
            }

            return RedirectToAction(actionName: nameof(Index));
        }
    }
}

이 컨트롤러는 명시적 종속성 원칙을 따르며, IBrainstormSessionRepository 인스턴스를 제공하는 종속성 주입을 예상합니다.The controller is following the explicit dependencies principle, expecting dependency injection to provide it with an instance of IBrainstormSessionRepository. 따라서 Moq 같은 모의 개체 프레임워크를 사용하여 매우 간편하게 테스트할 수 있습니다.This makes it fairly easy to test using a mock object framework, like Moq. HTTP GET Index 메서드는 반복 또는 분기가 없으며 한 가지 메서드만 호출합니다.The HTTP GET Index method has no looping or branching and only calls one method. Index 메서드를 테스트하려면 리포지토리의 List 메서드에서 ViewModel를 사용하여 ViewResult가 반환되는지 확인해야 합니다.To test this Index method, we need to verify that a ViewResult is returned, with a ViewModel from the repository's List method.

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 메서드는 다음을 확인해야 합니다.The HomeController HTTP POST Index method (shown above) should verify:

  • ModelState.IsValidfalse이면 작업 메서드는 적절한 데이터와 함께 잘못된 요청 ViewResult를 반환합니다.The action method returns a Bad Request ViewResult with the appropriate data when ModelState.IsValid is false

  • ModelState.IsValid가 true이면 리포지토리의 Add 메서드가 호출되고 올바른 인수와 함께 RedirectToActionResult가 반환됩니다.The Add method on the repository is called and a RedirectToActionResult is returned with the correct arguments when ModelState.IsValid is true.

아래의 첫 번째 테스트처럼 AddModelError로 오류를 추가하여 잘못된 모델 상태를 테스트할 수 있습니다.Invalid model state can be tested by adding errors using AddModelError as shown in the first test below.

[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가 반환되는지 확인합니다.The first test confirms when ModelState isn't valid, the same ViewResult is returned as for a GET request. 이 테스트는 잘못된 모델을 전달하려고 시도하지 않습니다.Note that the test doesn't attempt to pass in an invalid model. 모델 바인딩이 실행되고 있지 않으므로(통합 테스트에서 연습 모델 바인딩을 사용하겠지만) 어떤 방법으로도 작동하지 않습니다.That wouldn't work anyway since model binding isn't running (though an integration test would use exercise model binding). 이 예에서는 모델 바인딩을 테스트하지 않습니다.In this case, model binding isn't being tested. 이러한 단위 테스트는 작업 메서드의 코드에서 하는 일만 테스트합니다.These unit tests are only testing what the code in the action method does.

두 번째 테스트는 ModelState가 잘못된 경우 새 BrainstormSession이 추가되는지(리포지토리를 통해), 메서드가 예상된 속성과 함께 RedirectToActionResult를 반환하는지 확인합니다.The second test verifies that when ModelState is valid, a new BrainstormSession is added (via the repository), and the method returns a RedirectToActionResult with the expected properties. 호출되지 않은 모의 호출은 일반적으로 무시되지만, 설정 호출의 끝부분에서 Verifiable을 호출하면 테스트에서 확인할 수 있습니다.Mocked calls that aren't called are normally ignored, but calling Verifiable at the end of the setup call allows it to be verified in the test. 이것은 mockRepo.Verify 호출을 통해 수행되며, 예상된 메서드가 호출되지 않으면 테스트가 실패합니다.This is done with the call to mockRepo.Verify, which will fail the test if the expected method wasn't called.

참고

이 샘플에 사용된 Moq 라이브러리를 사용하면 확인 가능한 또는 "엄격한" 모의 개체를 확인 불가능한 모의 개체("느슨한" 모의 개체 또는 스텁이라고도 함)와 손쉽게 혼합할 수 있습니다.The Moq library used in this sample makes it easy to mix verifiable, or "strict", mocks with non-verifiable mocks (also called "loose" mocks or stubs). Moq를 사용하여 모의 동작 사용자 지정에 대해 자세히 알아보세요.Learn more about customizing Mock behavior with Moq.

앱의 다른 컨트롤러는 특정 브레인스토밍 세션과 관련된 정보를 표시합니다.Another controller in the app displays information related to a particular brainstorming session. 잘못된 id 값을 처리하는 일부 논리를 포함합니다.It includes some logic to deal with invalid id values:

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(actionName: nameof(Index), controllerName: "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 문에 하나씩, 총 세 개 있습니다.The controller action has three cases to test, one for each return statement:

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

            // Assert
            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;
        }
    }
}

앱에서는 기능을 웹 API(브레인스토밍 세션과 관련된 아이디어 목록 및 세션에 새 아이디어를 추가하기 위한 메서드)로 노출합니다.The app exposes functionality as a web API (a list of ideas associated with a brainstorming session and a method for adding new ideas to a session):

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 형식 목록을 반환합니다.The ForSession method returns a list of IdeaDTO types. API 호출을 통해 직접 비즈니스 도메인 엔터티를 반환하지 마세요. API 호출은 API 클라이언트에서 요구하는 것보다 더 많은 데이터를 포함하고 앱의 내부 도메인 모델을 외부에 노출되는 API와 불필요하게 연결하는 경우가 자주 있기 때문입니다.Avoid returning your business domain entities directly via API calls, since frequently they include more data than the API client requires, and they unnecessarily couple your app's internal domain model with the API you expose externally. 도메인 엔터티와 유선으로 반환할 형식 간의 매핑은 수동으로(여기 보이는 것처럼 LINQ Select 사용) 또는 AutoMapper 같은 라이브러리를 사용하여 수행할 수 있습니다.Mapping between domain entities and the types you will return over the wire can be done manually (using a LINQ Select as shown here) or using a library like AutoMapper

CreateForSession API 메서드에 대한 단위 테스트:The unit tests for the Create and ForSession API methods:

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가 유효하지 않을 때 메서드의 동작을 테스트하려면 테스트의 일부로 컨트롤러에 모델 오류를 추가합니다.As stated previously, to test the behavior of the method when ModelState is invalid, add a model error to the controller as part of the test. 단위 테스트에서 모델 유효성 검사 또는 모델 바인딩을 테스트하지 말고, 특정 ModelState 값이 나타나면 작업 메서드의 동작만 테스트하세요.Don't try to test model validation or model binding in your unit tests - just test your action method's behavior when confronted with a particular ModelState value.

두 번째 테스트는 null을 반환하는 리포지토리를 사용하므로 모의 리포지토리가 null을 반환하도록 구성됩니다.The second test depends on the repository returning null, so the mock repository is configured to return null. 테스트 데이터베이스를 만들고(메모리 내부에 또는 다른 위치에) 이 결과를 반환하는 쿼리를 작성할 필요가 없습니다. 다음과 같이 단일 명령문으로 수행할 수 있습니다.There's no need to create a test database (in memory or otherwise) and construct a query that will return this result - it can be done in a single statement as shown.

마지막 테스트는 리포지토리의 Update 메서드가 호출되는지 확인합니다.The last test verifies that the repository's Update method is called. 앞에서 살펴본 것처럼, Verifiable을 통해 모의 개체가 호출된 후 확인 가능한 메서드가 실행되었는지 확인하기 위해 모의 리포지토리의 Verify 메서드가 호출됩니다.As we did previously, the mock is called with Verifiable and then the mocked repository's Verify method is called to confirm the verifiable method was executed. Update 메서드가 데이터를 저장하도록 보장하는 것은 단위 테스트의 책임이 아니며, 통합 테스트로 수행할 수 있습니다.It's not a unit test responsibility to ensure that the Update method saved the data; that can be done with an integration test.

통합 테스트Integration testing

통합 테스트는 앱 내의 개별 모듈이 함께 올바르게 작동하도록 보장하기 위해 수행됩니다.Integration tests is done to ensure separate modules within your app work correctly together. 일반적으로 단위 테스트로 테스트할 수 있는 것은 통합 테스트로도 테스트할 수 있지만, 그 반대는 아닙니다.Generally, anything you can test with a unit test, you can also test with an integration test, but the reverse isn't true. 그러나 통합 테스트는 단위 테스트보다 훨씬 느린 경향이 있습니다.However, integration tests tend to be much slower than unit tests. 따라서 되도록이면 단위 테스트를 사용하고, 여러 협력자가 참여하는 시나리오에는 통합 테스트를 사용하는 것이 좋습니다.Thus, it's best to test whatever you can with unit tests, and use integration tests for scenarios that involve multiple collaborators.

모의 개체는 여전히 유용할 수도 있지만, 통합 테스트에 사용되는 경우는 거의 없습니다.Although they may still be useful, mock objects are rarely used in integration tests. 단위 테스트에서, 모의 개체는 테스트되는 단위 외부의 협력자가 테스트 목적에 맞게 행동하려면 어떻게 해야 하는지 제어하는 효율적인 방법입니다.In unit testing, mock objects are an effective way to control how collaborators outside of the unit being tested should behave for the purposes of the test. 통합 테스트에서, 실제 협력자는 전체 하위 시스템이 정상적으로 함께 작동하는지 확인하는 데 사용됩니다.In an integration test, real collaborators are used to confirm the whole subsystem works together correctly.

응용 프로그램 상태Application state

통합 테스트를 수행할 때 중요한 고려 사항 중 하나는 앱 상태를 설정하는 방법입니다.One important consideration when performing integration testing is how to set your app's state. 테스트는 서로 독립적으로 실행되어야 하므로 각 테스트에서 알려진 상태의 앱을 시작해야 합니다.Tests need to run independent of one another, and so each test should start with the app in a known state. 앱에서 데이터베이스를 사용하지 않거나 지속성이 없는 경우에는 이것이 문제가 되지 않을 수 있습니다.If your app doesn't use a database or have any persistence, this may not be an issue. 그러나 대부분의 실제 앱은 데이터 저장소에 상태를 저장하므로 데이터 저장소를 다시 설정하지 않는 한, 한 테스트에서 수정된 내용이 다른 테스트에 영향을 줄 수 있습니다.However, most real-world apps persist their state to some kind of data store, so any modifications made by one test could impact another test unless the data store is reset. 기본 제공 TestServer를 사용하면 통합 테스트 내에서 매우 간단하게 ASP.NET Core 앱을 호스트할 수 있지만, 사용할 데이터에 대한 액세스 권한을 반드시 부여하지는 않습니다.Using the built-in TestServer, it's very straightforward to host ASP.NET Core apps within our integration tests, but that doesn't necessarily grant access to the data it will use. 실제 데이터베이스를 사용하는 경우 테스트에서 액세스할 수 있는 테스트 데이터베이스에 앱을 연결하고, 각 테스트가 실행되기 전에 테스트를 알려진 상태로 다시 설정하는 방법이 있습니다.If you're using an actual database, one approach is to have the app connect to a test database, which your tests can access and ensure is reset to a known state before each test executes.

이 응용 프로그램 예제에서는 Entity Framework Core의 InMemoryDatabase 지원을 사용하기 때문에 테스트 프로젝트에서 연결할 수 없습니다.In this sample application, I'm using Entity Framework Core's InMemoryDatabase support, so I can't just connect to it from my test project. 그 대신, 저는 Development 환경에 있는 경우 앱이 시작될 때 호출되는 Startup 클래스의 InitializeDatabase 메서드를 노출하겠습니다.Instead, I expose an InitializeDatabase method from the app's Startup class, which I call when the app starts up if it's in the Development environment. 제가 만든 통합 테스트는 환경을 Development로 설정하는 한 자동으로 이 이점을 누리게 됩니다.My integration tests automatically benefit from this as long as they set the environment to Development. 앱이 다시 시작될 때마다 InMemoryDatabase가 다시 설정되므로 데이터베이스 재설정에 대해 걱정할 필요가 없습니다.I don't have to worry about resetting the database, since the InMemoryDatabase is reset each time the app restarts.

Startup 클래스:The Startup class:

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("InMemoryDb"));

            services.AddMvc();

            services.AddScoped<IBrainstormSessionRepository,
                EFStormSessionRepository>();
        }

        public void Configure(IApplicationBuilder app,
            IHostingEnvironment env,
            ILoggerFactory loggerFactory)
        {
            if (env.IsDevelopment())
            {
                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 메서드가 자주 사용되는 것을 볼 수 있습니다.You'll see the GetTestSession method used frequently in the integration tests below.

보기 액세스Accessing views

각 통합 테스트 클래스는 ASP.NET Core 앱을 실행할 TestServer를 구성합니다.Each integration test class configures the TestServer that will run the ASP.NET Core app. 기본적으로 TestServer는 현재 실행되고 있는 폴더에 웹앱을 호스트하는데, 이 예에서는 테스트 프로젝트 폴더입니다.By default, TestServer hosts the web app in the folder where it's running - in this case, the test project folder. 따라서 ViewResult를 반환하는 테스트 컨트롤러 작업을 테스트하려고 시도하면 다음과 같은 오류가 표시될 수 있습니다.Thus, when you attempt to test controller actions that return ViewResult, you may see this error:

The view 'Index' wasn't found. The following locations were searched:
(list of locations)

이 문제를 해결하려면 테스트 중인 프로젝트에 대한 보기를 찾을 수 있도록 서버의 콘텐츠 루트를 구성해야 합니다.To correct this issue, you need to configure the server's content root, so it can locate the views for the project being tested. 이 작업은 아래와 같이 TestFixture 클래스의 UseContentRoot를 호출하여 수행됩니다.This is done by a call to UseContentRoot in the TestFixture class, shown below:

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;

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 readonly TestServer _server;

        public TestFixture()
            : this(Path.Combine("src"))
        {
        }

        protected TestFixture(string relativeTargetProjectParentDir)
        {
            var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
            var contentRoot = GetProjectPath(relativeTargetProjectParentDir, 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 it 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 that we wish to test
        /// </summary>
        /// <param name="projectRelativePath">
        /// 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 projectRelativePath, 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 = System.AppContext.BaseDirectory;

            // Find the path to the target project
            var directoryInfo = new DirectoryInfo(applicationBasePath);
            do
            {
                directoryInfo = directoryInfo.Parent;

                var projectDirectoryInfo = new DirectoryInfo(Path.Combine(directoryInfo.FullName, projectRelativePath));
                if (projectDirectoryInfo.Exists)
                {
                    var projectFileInfo = new FileInfo(Path.Combine(projectDirectoryInfo.FullName, projectName, $"{projectName}.csproj"));
                    if (projectFileInfo.Exists)
                    {
                        return Path.Combine(projectDirectoryInfo.FullName, projectName);
                    }
                }
            }
            while (directoryInfo.Parent != null);

            throw new Exception($"Project root could not be located using the application root {applicationBasePath}.");
        }
    }
}

TestFixture 클래스는 TestServer를 구성 및 생성하고, TestServer와 통신하도록 HttpClient를 설정하는 역할을 합니다.The TestFixture class is responsible for configuring and creating the TestServer, setting up an HttpClient to communicate with the TestServer. 각 통합 테스트에서는 Client 속성을 사용하여 테스트 서버에 연결하고 요청을 만듭니다.Each of the integration tests uses the Client property to connect to the test server and make a request.

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.Contains(testSession.Name, responseString);
        }

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

위의 첫 번째 테스트에서, responseString은 보기에서 렌더링된 실제 HTML을 보관하며, 이것을 검사하여 예상 결과를 포함하고 있는지 확인할 수 있습니다.In the first test above, the responseString holds the actual rendered HTML from the View, which can be inspected to confirm it contains expected results.

두 번째 테스트는 고유한 세션 이름으로 POST 양식을 만들어서 앱에 게시한 다음, 예상한 리디렉션이 반환되는지 확인합니다.The second test constructs a form POST with a unique session name and POSTs it to the app, then verifies that the expected redirect is returned.

API 메서드API methods

앱에서 웹 API를 노출하는 경우 자동화된 테스트를 통해 웹 API가 예상대로 실행되는지 확인하는 것이 좋습니다.If your app exposes web APIs, it's a good idea to have automated tests confirm they execute as expected. 기본 제공 TestServer를 사용하면 간편하게 웹 API를 테스트할 수 있습니다.The built-in TestServer makes it easy to test web APIs. API 메서드에서 모델 바인딩을 사용하는 경우 항상 ModelState.IsValid를 확인해야 하며, 통합 테스트를 통해 모델 유효성 검사가 제대로 작동하는지 확인할 수 있습니다.If your API methods are using model binding, you should always check ModelState.IsValid, and integration tests are the right place to confirm that your model validation is working properly.

다음 테스트 집합은 위에 보이는 IdeasController 클래스의 Create 메서드를 대상으로 합니다.The following set of tests target the Create method in the IdeasController class shown above:

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.Contains(testIdeaName, returnedSession.Ideas.Select(i => i.Name).ToList());
        }

        [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 보기를 반환하는 작업 통합 테스트와는 달리, 결과를 반환하는 웹 API 메서드는 일반적으로 위에 보이는 마지막 테스트처럼 강력한 형식의 개체로 deserialize할 수 있습니다.Unlike integration tests of actions that returns HTML views, web API methods that return results can usually be deserialized as strongly typed objects, as the last test above shows. 이 예의 테스트는 결과를 BrainstormSession 인스턴스로 deserialize하고, 아이디어가 아이디어 컬렉션에 올바르게 추가되었는지 확인합니다.In this case, the test deserializes the result to a BrainstormSession instance, and confirms that the idea was correctly added to its collection of ideas.

이 문서의 샘플 프로젝트에서 더 많은 통합 테스트 예제를 찾을 수 있습니다.You'll find additional examples of integration tests in this article's sample project.