ASP.NET Core에서 컨트롤러 논리 단위 테스트

작성자 Steve Smith

단위 테스트는 앱의 일부분을 인프라 및 종속성과 분리하여 테스트를 수행합니다. 컨트롤러 논리를 단위 테스트할 때 단일 작업의 콘텐츠만 테스트되고, 작업의 종속성 또는 프레임워크 자체의 동작은 테스트되지 않습니다.

컨트롤러 단위 테스트

컨트롤러의 동작에 초점을 맞춰 컨트롤러 동작의 단위 테스트를 설정합니다. 컨트롤러 단위 테스트는 필터, 라우팅, 모델 바인딩 같은 시나리오를 방지합니다. 전체적으로 요청에 응답하는 구성 요소 간 상호 작용을 포함하는 테스트는 ‘통합 테스트’에서 처리합니다. 통합 테스트에 대한 자세한 내용은 ASP.NET Core의 통합 테스트를 참조하세요.

사용자 지정 필터 및 경로를 작성할 때, 특정 컨트롤러 작업에 대한 테스트의 일부로서가 아니라, 별도로 단위 테스트를 수행하세요.

컨트롤러 단위 테스트를 시연하려면 예제 앱에서 다음 컨트롤러를 검토하세요.

샘플 코드 보기 및 다운로드(다운로드 방법)

Home 컨트롤러는 브레인스토밍 세션 목록을 표시하고 POST 요청을 사용하여 새로운 브레인스토밍 세션을 만들 수 있습니다.

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

이전 컨트롤러는 다음과 같습니다.

  • 명시적 종속성 원칙을 따릅니다.
  • DI(종속성 주입)에서 IBrainstormSessionRepository의 인스턴스를 제공할 것으로 기대합니다.
  • Moq와 같은 모의 개체 프레임워크를 사용하여 모의 IBrainstormSessionRepository 서비스로 테스트할 수 있습니다. ‘모의 개체’는 테스트에 사용되는 사전 결정된 속성 및 메서드 동작 집합이 있는 제작된 개체입니다. 자세한 내용은 통합 테스트 소개를 참조하세요.

HTTP GET Index 메서드는 반복 또는 분기가 없으며 한 가지 메서드만 호출합니다. 이 작업의 단위 테스트는 다음을 수행합니다.

  • GetTestSessions 메서드를 사용하여 IBrainstormSessionRepository 서비스를 모방합니다. GetTestSessions는 날짜 및 세션 이름이 있는 두 개의 모의 브레인스토밍 세션을 작성합니다.
  • Index 메서드를 실행합니다.
  • 메서드에서 반환된 결과에 대해 어설션을 만듭니다.
    • ViewResult가 반환됩니다.
    • ViewDataDictionary.ModelStormSessionViewModel입니다.
    • ViewDataDictionary.Model에 저장된 두 개의 브레인스토밍 세션이 있습니다.
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(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;
}

Home 컨트롤러의 HTTP POST Index 메서드 테스트는 다음을 확인합니다.

  • ModelState.IsValidfalse 인 경우 작업 메서드가 적절한 데이터와 함께 400 잘못된 요청ViewResult를 반환합니다.
  • 시기 ModelState.IsValidtrue:
    • 리포지토리의 Add 메서드를 호출합니다.
    • RedirectToActionResult가 올바른 인수와 함께 반환됩니다.

아래의 첫 번째 테스트처럼 AddModelError로 오류를 추가하여 잘못된 모델 상태를 테스트할 수 있습니다.

[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(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가 반환됩니다. 이 테스트는 잘못된 모델을 전달하려고 시도하지 않습니다. 모델 바인딩이 실행되지 않기 때문에(통합 테스트는 모델 바인딩을 사용하는 반면), 잘못된 모델을 전달하는 것은 유효한 접근 방식이 아닙니다. 이번 경우에는 모델 바인딩을 테스트하지 않습니다. 이러한 단위 테스트는 작업 메서드의 코드만 테스트합니다.

두 번째 테스트는 ModelState가 유효한 시기를 확인합니다.

  • BrainstormSession이 (리포지토리를 통해) 추가됩니다.
  • 메서드가 예상 속성과 함께 RedirectToActionResult를 반환함.

호출되지 않은 모의 호출은 일반적으로 무시되지만, 설정 호출의 끝부분에서 Verifiable을 호출하면 테스트에서 모의 유효성 검사가 가능합니다. 이는 mockRepo.Verify 호출을 통해 수행되며, 예상된 메서드가 호출되지 않았으면 테스트가 실패합니다.

참고 항목

이 샘플에 사용된 Moq 라이브러리를 사용하면 확인 가능한 또는 “엄격한” 모의 개체를 확인 불가능한 모의 개체(“느슨한” 모의 개체 또는 스텁이라고도 함)와 혼합할 수 있습니다. Moq를 사용하여 모의 동작 사용자 지정에 대해 자세히 알아보세요.

예제 앱의 SessionController는 특정 브레인스토밍 세션과 관련된 정보를 표시합니다. 이 컨트롤러에는 잘못된 id 값을 처리하는 논리가 포함되어 있습니다(다음 예제에는 이러한 시나리오를 다루는 두 가지 return 시나리오가 있습니다). 최종 return 문은 뷰Controllers/SessionController.cs()에 대한 새 StormSessionViewModel 값을 반환합니다.

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

단위 테스트에는 세션 컨트롤러 Index 작업에 각 return 시나리오에 대한 하나의 테스트가 포함되어 있습니다.

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

Ideas 컨트롤러로 이동해보면 앱은 api/ideas 경로에서 기능을 웹 API로 노출합니다.

  • 브레인스토밍 세션과 연관된 아이디어(IdeaDTO)의 목록이 ForSession 메서드에 의해 반환됩니다.
  • Create 메서드는 세션에 새 아이디어를 추가합니다.
[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);
}

API 호출을 통해 직접 비즈니스 도메인 엔터티를 반환하지 마세요. 도메인 엔터티는 다음과 같습니다.

  • 종종 클라이언트에 필요한 것보다 많은 데이터를 포함합니다.
  • 공개적으로 노출된 API와 앱의 내부 도메인 모델을 불필요하게 결합합니다.

도메인 엔터티와 클라이언트에 반환되는 형식 간의 매핑을 수행할 수 있습니다.

  • 예제 앱에서 사용하는 것처럼 LINQ Select를 사용하여 수동으로 수행합니다. 자세한 내용은 LINQ(Language-Integrated Query)를 참조하세요.
  • AutoMapper와 같은 라이브러리를 사용하여 자동으로 수행합니다.

다음으로 예제 앱은 Ideas 컨트롤러의 CreateForSession API 메서드에 대한 단위 테스트를 보여줍니다.

예제 앱에는 두 개의 ForSession 테스트가 포함되어 있습니다. 첫 번째 테스트는 ForSession이 잘못된 세션에 대해 NotFoundObjectResult(HTTP를 찾을 수 없음)를 반환하는지 여부를 판별합니다.

[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((BrainstormSession)null);
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSession(testSessionId);

    // Assert
    var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
    Assert.Equal(testSessionId, notFoundObjectResult.Value);
}

두 번째 ForSession 테스트는 ForSession에서 유효한 세션에 대한 세션 아이디어(<List<IdeaDTO>>) 목록을 반환하는지 여부를 판별합니다. 또한 이 검사는 첫 번째 아이디어를 검사하여 Name 속성이 올바른지도 확인합니다.

[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSession());
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSession(testSessionId);

    // Assert
    var okResult = Assert.IsType<OkObjectResult>(result);
    var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
    var idea = returnValue.FirstOrDefault();
    Assert.Equal("One", idea.Name);
}

ModelState가 유효하지 않을 때 Create 메서드의 동작을 테스트하기 위해 예제 앱은 테스트의 일부로 컨트롤러에 모델 오류를 추가합니다. 단위 테스트에서 모델 유효성 검사 또는 모델 바인딩을 테스트하지 말고, 잘못된 ModelState에 직면했을 때의 작업 메서드 동작만 테스트하세요.

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

두 번째 Create 테스트는 null을 반환하는 리포지토리에 의존하므로 모의 리포지토리가 null을 반환하도록 구성됩니다. 테스트 데이터베이스를 만들고(메모리 내부에 또는 다른 위치에) 이 결과를 반환하는 쿼리를 작성할 필요가 없습니다. 예제 코드에서 볼 수 있는 것처럼 단일 명령문으로 테스트할 수 있습니다.

[Fact]
public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((BrainstormSession)null);
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.Create(new NewIdeaModel());

    // Assert
    Assert.IsType<NotFoundObjectResult>(result);
}

세 번째 Create 테스트인 Create_ReturnsNewlyCreatedIdeaForSession에서는 리포지토리의 UpdateAsync 메서드가 호출되는지 확인합니다. Verifiable을 통해 모의 개체가 호출된 후 확인 가능한 메서드가 실행되었는지 확인하기 위해 모의 리포지토리의 Verify 메서드가 호출됩니다. UpdateAsync 메서드가 데이터를 저장하도록 보장하는 것은 단위 테스트의 책임이 아니며 통합 테스트로 수행할 수 있습니다.

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

테스트 ActionResult<T>

ActionResult<T>(ActionResult<TValue>)는 ActionResult에서 파생된 형식을 반환하거나 특정 형식을 반환할 수 있습니다.

예제 앱에는 지정된 세션 id에 대한 List<IdeaDTO>를 반환하는 메서드가 포함되어 있습니다. 세션 id가 없으면 컨트롤러는 NotFound를 반환합니다.

[HttpGet("forsessionactionresult/{sessionId}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(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 result;
}

ForSessionActionResult 컨트롤러에 대한 두 테스트는 ApiIdeasControllerTests에 포함되어 있습니다.

첫 번째 테스트는 컨트롤러가 ActionResult를 반환하지만 존재하지 않는 세션 id에 대한 존재하지 않는 아이디어 목록을 반환하는지 확인합니다.

[Fact]
public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);
    var nonExistentSessionId = 999;

    // Act
    var result = await controller.ForSessionActionResult(nonExistentSessionId);

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

유효한 세션 id에 대한 두 번째 테스트는 메서드가 다음을 반환하는지 확인합니다.

  • ActionResult 형식이 List<IdeaDTO>입니다.
  • ActionResult<T>.ValueList<IdeaDTO> 유형임.
  • 목록의 첫 번째 항목은 모의 세션(GetTestSession 호출로 얻음)에 저장된 아이디어와 일치하는 유효한 아이디어입니다.
[Fact]
public async Task ForSessionActionResult_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSession());
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSessionActionResult(testSessionId);

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
    var idea = returnValue.FirstOrDefault();
    Assert.Equal("One", idea.Name);
}

예제 앱에는 지정된 세션에 대한 새 Idea를 작성하는 메서드도 포함되어 있습니다. 컨트롤러는 다음을 반환합니다.

[HttpPost("createactionresult")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([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 CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
}

세 개의 CreateActionResult 테스트는 ApiIdeasControllerTests에 포함되어 있습니다.

첫 번째 테스트는 잘못된 모델에 대해 BadRequest가 반환되는지 확인합니다.

[Fact]
public async Task CreateActionResult_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.CreateActionResult(model: null);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<BadRequestObjectResult>(actionResult.Result);
}

두 번째 테스트는 세션이 존재하지 않는 경우 NotFound가 반환되는지 확인합니다.

[Fact]
public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var nonExistentSessionId = 999;
    string testName = "test name";
    string testDescription = "test description";
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);

    var newIdea = new NewIdeaModel()
    {
        Description = testDescription,
        Name = testName,
        SessionId = nonExistentSessionId
    };

    // Act
    var result = await controller.CreateActionResult(newIdea);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

유효한 세션 id에 대한, 마지막 테스트는 다음을 확인합니다.

  • 메서드가 BrainstormSession 유형의 ActionResult를 반환함.
  • ActionResult<T>.ResultCreatedAtActionResult임. CreatedAtActionResultLocation 헤더가 있는 201 생성됨 응답과 유사함.
  • ActionResult<T>.ValueBrainstormSession 유형임.
  • 세션을 업데이트하기 위한 모의 호출 UpdateAsync(testSession)가 실행됨. Verifiable 메서드 호출은 어설션에서 mockRepo.Verify()를 실행하여 확인됩니다.
  • 세션에 대해 두 개의 Idea 개체가 반환됩니다.
  • 마지막 항목(UpdateAsync에 대한 모의 호출에 의해 추가된 Idea)이 테스트의 세션에 추가된 newIdea와 일치합니다.
[Fact]
public async Task CreateActionResult_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))
        .ReturnsAsync(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.CreateActionResult(newIdea);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
    var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
    mockRepo.Verify();
    Assert.Equal(2, returnValue.Ideas.Count());
    Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
    Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}

컨트롤러는 모든 ASP.NET Core MVC 앱에서 중심적인 역할을 합니다. 따라서 컨트롤러가 의도한 대로 동작한다고 확신할 수 있어야 합니다. 자동화된 테스트는 앱이 프로덕션 환경에 배포되기 전에 오류를 발견할 수 있습니다.

샘플 코드 보기 및 다운로드(다운로드 방법)

컨트롤러 논리의 단위 테스트

단위 테스트는 앱의 일부분을 인프라 및 종속성과 분리하여 테스트를 수행합니다. 컨트롤러 논리를 단위 테스트할 때 단일 작업의 콘텐츠만 테스트되고, 작업의 종속성 또는 프레임워크 자체의 동작은 테스트되지 않습니다.

컨트롤러의 동작에 초점을 맞춰 컨트롤러 동작의 단위 테스트를 설정합니다. 컨트롤러 단위 테스트는 필터, 라우팅, 모델 바인딩 같은 시나리오를 방지합니다. 전체적으로 요청에 응답하는 구성 요소 간 상호 작용을 포함하는 테스트는 ‘통합 테스트’에서 처리합니다. 통합 테스트에 대한 자세한 내용은 ASP.NET Core의 통합 테스트를 참조하세요.

사용자 지정 필터 및 경로를 작성할 때, 특정 컨트롤러 작업에 대한 테스트의 일부로서가 아니라, 별도로 단위 테스트를 수행하세요.

컨트롤러 단위 테스트를 시연하려면 예제 앱에서 다음 컨트롤러를 검토하세요. Home 컨트롤러는 브레인스토밍 세션 목록을 표시하고 POST 요청을 사용하여 새로운 브레인스토밍 세션을 만들 수 있습니다.

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

이전 컨트롤러는 다음과 같습니다.

  • 명시적 종속성 원칙을 따릅니다.
  • DI(종속성 주입)에서 IBrainstormSessionRepository의 인스턴스를 제공할 것으로 기대합니다.
  • Moq와 같은 모의 개체 프레임워크를 사용하여 모의 IBrainstormSessionRepository 서비스로 테스트할 수 있습니다. ‘모의 개체’는 테스트에 사용되는 사전 결정된 속성 및 메서드 동작 집합이 있는 제작된 개체입니다. 자세한 내용은 통합 테스트 소개를 참조하세요.

HTTP GET Index 메서드는 반복 또는 분기가 없으며 한 가지 메서드만 호출합니다. 이 작업의 단위 테스트는 다음을 수행합니다.

  • GetTestSessions 메서드를 사용하여 IBrainstormSessionRepository 서비스를 모방합니다. GetTestSessions는 날짜 및 세션 이름이 있는 두 개의 모의 브레인스토밍 세션을 작성합니다.
  • Index 메서드를 실행합니다.
  • 메서드에서 반환된 결과에 대해 어설션을 만듭니다.
    • ViewResult가 반환됩니다.
    • ViewDataDictionary.ModelStormSessionViewModel입니다.
    • ViewDataDictionary.Model에 저장된 두 개의 브레인스토밍 세션이 있습니다.
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(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;
}

Home 컨트롤러의 HTTP POST Index 메서드 테스트는 다음을 확인합니다.

  • ModelState.IsValidfalse 인 경우 작업 메서드가 적절한 데이터와 함께 400 잘못된 요청ViewResult를 반환합니다.
  • 시기 ModelState.IsValidtrue:
    • 리포지토리의 Add 메서드를 호출합니다.
    • RedirectToActionResult가 올바른 인수와 함께 반환됩니다.

아래의 첫 번째 테스트처럼 AddModelError로 오류를 추가하여 잘못된 모델 상태를 테스트할 수 있습니다.

[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(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가 반환됩니다. 이 테스트는 잘못된 모델을 전달하려고 시도하지 않습니다. 모델 바인딩이 실행되지 않기 때문에(통합 테스트는 모델 바인딩을 사용하는 반면), 잘못된 모델을 전달하는 것은 유효한 접근 방식이 아닙니다. 이번 경우에는 모델 바인딩을 테스트하지 않습니다. 이러한 단위 테스트는 작업 메서드의 코드만 테스트합니다.

두 번째 테스트는 ModelState가 유효한 시기를 확인합니다.

  • BrainstormSession이 (리포지토리를 통해) 추가됩니다.
  • 메서드가 예상 속성과 함께 RedirectToActionResult를 반환함.

호출되지 않은 모의 호출은 일반적으로 무시되지만, 설정 호출의 끝부분에서 Verifiable을 호출하면 테스트에서 모의 유효성 검사가 가능합니다. 이는 mockRepo.Verify 호출을 통해 수행되며, 예상된 메서드가 호출되지 않았으면 테스트가 실패합니다.

참고 항목

이 샘플에 사용된 Moq 라이브러리를 사용하면 확인 가능한 또는 “엄격한” 모의 개체를 확인 불가능한 모의 개체(“느슨한” 모의 개체 또는 스텁이라고도 함)와 혼합할 수 있습니다. Moq를 사용하여 모의 동작 사용자 지정에 대해 자세히 알아보세요.

예제 앱의 SessionController는 특정 브레인스토밍 세션과 관련된 정보를 표시합니다. 이 컨트롤러에는 잘못된 id 값을 처리하는 논리가 포함되어 있습니다(다음 예제에는 이러한 시나리오를 다루는 두 가지 return 시나리오가 있습니다). 최종 return 문은 뷰Controllers/SessionController.cs()에 대한 새 StormSessionViewModel 값을 반환합니다.

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

단위 테스트에는 세션 컨트롤러 Index 작업에 각 return 시나리오에 대한 하나의 테스트가 포함되어 있습니다.

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

Ideas 컨트롤러로 이동해보면 앱은 api/ideas 경로에서 기능을 웹 API로 노출합니다.

  • 브레인스토밍 세션과 연관된 아이디어(IdeaDTO)의 목록이 ForSession 메서드에 의해 반환됩니다.
  • Create 메서드는 세션에 새 아이디어를 추가합니다.
[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);
}

API 호출을 통해 직접 비즈니스 도메인 엔터티를 반환하지 마세요. 도메인 엔터티는 다음과 같습니다.

  • 종종 클라이언트에 필요한 것보다 많은 데이터를 포함합니다.
  • 공개적으로 노출된 API와 앱의 내부 도메인 모델을 불필요하게 결합합니다.

도메인 엔터티와 클라이언트에 반환되는 형식 간의 매핑을 수행할 수 있습니다.

  • 예제 앱에서 사용하는 것처럼 LINQ Select를 사용하여 수동으로 수행합니다. 자세한 내용은 LINQ(Language-Integrated Query)를 참조하세요.
  • AutoMapper와 같은 라이브러리를 사용하여 자동으로 수행합니다.

다음으로 예제 앱은 Ideas 컨트롤러의 CreateForSession API 메서드에 대한 단위 테스트를 보여줍니다.

예제 앱에는 두 개의 ForSession 테스트가 포함되어 있습니다. 첫 번째 테스트는 ForSession이 잘못된 세션에 대해 NotFoundObjectResult(HTTP를 찾을 수 없음)를 반환하는지 여부를 판별합니다.

[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((BrainstormSession)null);
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSession(testSessionId);

    // Assert
    var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
    Assert.Equal(testSessionId, notFoundObjectResult.Value);
}

두 번째 ForSession 테스트는 ForSession에서 유효한 세션에 대한 세션 아이디어(<List<IdeaDTO>>) 목록을 반환하는지 여부를 판별합니다. 또한 이 검사는 첫 번째 아이디어를 검사하여 Name 속성이 올바른지도 확인합니다.

[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSession());
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSession(testSessionId);

    // Assert
    var okResult = Assert.IsType<OkObjectResult>(result);
    var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
    var idea = returnValue.FirstOrDefault();
    Assert.Equal("One", idea.Name);
}

ModelState가 유효하지 않을 때 Create 메서드의 동작을 테스트하기 위해 예제 앱은 테스트의 일부로 컨트롤러에 모델 오류를 추가합니다. 단위 테스트에서 모델 유효성 검사 또는 모델 바인딩을 테스트하지 말고, 잘못된 ModelState에 직면했을 때의 작업 메서드 동작만 테스트하세요.

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

두 번째 Create 테스트는 null을 반환하는 리포지토리에 의존하므로 모의 리포지토리가 null을 반환하도록 구성됩니다. 테스트 데이터베이스를 만들고(메모리 내부에 또는 다른 위치에) 이 결과를 반환하는 쿼리를 작성할 필요가 없습니다. 예제 코드에서 볼 수 있는 것처럼 단일 명령문으로 테스트할 수 있습니다.

[Fact]
public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((BrainstormSession)null);
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.Create(new NewIdeaModel());

    // Assert
    Assert.IsType<NotFoundObjectResult>(result);
}

세 번째 Create 테스트인 Create_ReturnsNewlyCreatedIdeaForSession에서는 리포지토리의 UpdateAsync 메서드가 호출되는지 확인합니다. Verifiable을 통해 모의 개체가 호출된 후 확인 가능한 메서드가 실행되었는지 확인하기 위해 모의 리포지토리의 Verify 메서드가 호출됩니다. UpdateAsync 메서드가 데이터를 저장하도록 보장하는 것은 단위 테스트의 책임이 아니며 통합 테스트로 수행할 수 있습니다.

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

테스트 ActionResult<T>

ASP.NET Core 2.1 이상에서 ActionResult<T>(ActionResult<TValue>)는 ActionResult에서 파생된 형식을 반환하거나 특정 형식을 반환할 수 있습니다.

예제 앱에는 지정된 세션 id에 대한 List<IdeaDTO>를 반환하는 메서드가 포함되어 있습니다. 세션 id가 없으면 컨트롤러는 NotFound를 반환합니다.

[HttpGet("forsessionactionresult/{sessionId}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(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 result;
}

ForSessionActionResult 컨트롤러에 대한 두 테스트는 ApiIdeasControllerTests에 포함되어 있습니다.

첫 번째 테스트는 컨트롤러가 ActionResult를 반환하지만 존재하지 않는 세션 id에 대한 존재하지 않는 아이디어 목록을 반환하는지 확인합니다.

[Fact]
public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);
    var nonExistentSessionId = 999;

    // Act
    var result = await controller.ForSessionActionResult(nonExistentSessionId);

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

유효한 세션 id에 대한 두 번째 테스트는 메서드가 다음을 반환하는지 확인합니다.

  • ActionResult 형식이 List<IdeaDTO>입니다.
  • ActionResult<T>.ValueList<IdeaDTO> 유형임.
  • 목록의 첫 번째 항목은 모의 세션(GetTestSession 호출로 얻음)에 저장된 아이디어와 일치하는 유효한 아이디어입니다.
[Fact]
public async Task ForSessionActionResult_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSession());
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSessionActionResult(testSessionId);

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
    var idea = returnValue.FirstOrDefault();
    Assert.Equal("One", idea.Name);
}

예제 앱에는 지정된 세션에 대한 새 Idea를 작성하는 메서드도 포함되어 있습니다. 컨트롤러는 다음을 반환합니다.

[HttpPost("createactionresult")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([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 CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
}

세 개의 CreateActionResult 테스트는 ApiIdeasControllerTests에 포함되어 있습니다.

첫 번째 테스트는 잘못된 모델에 대해 BadRequest가 반환되는지 확인합니다.

[Fact]
public async Task CreateActionResult_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.CreateActionResult(model: null);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<BadRequestObjectResult>(actionResult.Result);
}

두 번째 테스트는 세션이 존재하지 않는 경우 NotFound가 반환되는지 확인합니다.

[Fact]
public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var nonExistentSessionId = 999;
    string testName = "test name";
    string testDescription = "test description";
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);

    var newIdea = new NewIdeaModel()
    {
        Description = testDescription,
        Name = testName,
        SessionId = nonExistentSessionId
    };

    // Act
    var result = await controller.CreateActionResult(newIdea);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

유효한 세션 id에 대한, 마지막 테스트는 다음을 확인합니다.

  • 메서드가 BrainstormSession 유형의 ActionResult를 반환함.
  • ActionResult<T>.ResultCreatedAtActionResult임. CreatedAtActionResultLocation 헤더가 있는 201 생성됨 응답과 유사함.
  • ActionResult<T>.ValueBrainstormSession 유형임.
  • 세션을 업데이트하기 위한 모의 호출 UpdateAsync(testSession)가 실행됨. Verifiable 메서드 호출은 어설션에서 mockRepo.Verify()를 실행하여 확인됩니다.
  • 세션에 대해 두 개의 Idea 개체가 반환됩니다.
  • 마지막 항목(UpdateAsync에 대한 모의 호출에 의해 추가된 Idea)이 테스트의 세션에 추가된 newIdea와 일치합니다.
[Fact]
public async Task CreateActionResult_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))
        .ReturnsAsync(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.CreateActionResult(newIdea);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
    var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
    mockRepo.Verify();
    Assert.Equal(2, returnValue.Ideas.Count());
    Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
    Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}

추가 리소스