Модульное тестирование логики контроллера в ASP.NET Core

Стив Смит

Модульное тестирование предполагает тестирование части приложения изолированно от его инфраструктуры и зависимостей. При модульном тестировании логики контроллера тестируется только содержимое отдельного действия, но не поведение его зависимостей или самой платформы.

Модульное тестирование контроллеров

Настройте модульные тесты для действий контроллера, которые фокусируются на поведении контроллера. В модульных тестах контроллера не учитываются такие аспекты, как фильтрация, маршрутизация и (или) привязка модели. Тесты, учитывающие взаимодействие компонентов, совместно отвечающих на запрос, покрываются интеграционными тестами. Дополнительные сведения об тестах интеграции см. в разделе "Тесты интеграции" в 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.
  • Допускает проверку с помощью имитированной службы IBrainstormSessionRepository с использованием фреймворка mock-объектов, например Moq. Мок-объект представляет собой специально созданный объект с определенным набором свойств и методов поведения, который предназначен для тестирования. Дополнительные сведения см. в разделе Введение в интеграционные тесты.

В методе HTTP GET Index нет циклов или ветвления, и он вызывает лишь один метод. Модульный тест для этого действия позволяет выполнить следующие задачи:

  • Имитирует службу IBrainstormSessionRepository с помощью метода GetTestSessions. GetTestSessions создает два макета сессий мозгового штурма с датами и именами сеансов;
  • Выполняет метод Index
  • Проверяет утверждения для результата, возвращаемого методом:
    • Возвращается ViewResult.
    • ViewDataDictionary.Model имеет тип StormSessionViewModel;
    • В 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.IsValid имеет значение false, метод действия возвращает 400 Bad Request с соответствующими данными.
  • Когда ModelState.IsValid имеет значение true:
    • Вызывается метод 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 недопустим, возвращается тот же ViewResult, что и для GET-запроса. Тест не пытается передать недопустимую модель. Передача недопустимой модели будет неправильным подходом, так как привязка модели не выполняется (хотя интеграционный тест и использует привязку модели). В этом случае привязка модели не тестируется. Эти модульные тесты проверяют только код в методе действия.

Второй тест проверяет, что, когда ModelState допустим:

  • добавляется новый BrainstormSession (через репозиторий);
  • метод возвращает RedirectToActionResult с ожидаемыми свойствами.

Макеты вызовов, которые не выполняются, обычно игнорируются, но вызов Verifiable в конце вызова Setup позволяет проверить макет в тесте. Для этого выполняется вызов метода mockRepo.Verify, который приводит к провалу теста, если требуемый метод не был вызван.

Note

Библиотека Moq, используемая в этом примере, позволяет сочетать проверяемые, или «строгие», моки с непроверяемыми моками (которые также называют «свободными» моками или заглушками). Узнайте больше о настройке поведения mock-объектов с помощью Moq.

SessionController в примере приложения выводит сведения, связанные с определенным сеансом мозгового штурма. Этот контроллер содержит логику для работы с недопустимыми значениями id (в следующем примере приведены два сценария return, посвященные этим сценариям). Последняя return инструкция возвращает новое StormSessionViewModel представление (Controllers/SessionController.cs):

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 в действии Index контроллера Session:

[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 для маршрута api/ideas:

  • Список идей (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.

Далее пример приложения демонстрирует модульные тесты для методов API Create и ForSession контроллера Ideas.

Этот пример приложения содержит два теста 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);
}

Чтобы протестировать поведение метода Create, когда ModelState недействителен, тестовое приложение в ходе теста добавляет в контроллер ошибку модели. Не пытайтесь тестировать валидацию модели или привязку модели в юнит-тестах — просто проверьте поведение метода действия, если ему передана недопустимая модель 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, или конкретный тип.

Пример приложения содержит метод, который возвращает List<IdeaDTO> для указанного сеанса id. Если сеанс 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;
}

В ApiIdeasControllerTests включены два теста контроллера ForSessionActionResult.

Первый из этих тестов проверяет, что контроллер возвращает 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>.Value имеет тип List<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 для указанного сеанса. Контроллер возвращает:

  • BadRequest для недопустимой модели;
  • NotFound если сеанс не существует.
  • CreatedAtAction когда сеанс обновлен новой идеей.
[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 выполняются следующие условия:

  • Метод возвращает объект ActionResult типа BrainstormSession.
  • Значение ActionResult<T>.Result имеет тип CreatedAtActionResult. CreatedAtActionResult аналогичен ответу 201 — создан ресурс с заголовком Location.
  • ActionResult<T>.Value — это тип BrainstormSession.
  • Был вызван фиктивный вызов для обновления сеанса, UpdateAsync(testSession). Вызов метода Verifiable проверяется выполнением mockRepo.Verify() в утверждениях.
  • Возвращаются два объекта Idea для сессии.
  • Последний элемент (элемент Idea, добавленный при вызове мока UpdateAsync) совпадает с 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);
}

Контроллеры играют важнейшую роль в любом приложении MVC на ASP.NET Core. Это означает, что вы должны быть полностью уверены в правильности работы контроллеров. Автоматические тесты позволяют обнаружить ошибки до развертывания приложения в рабочей среде.

Просмотреть или скачать образец кода (как скачать)

Модульное тестирование логики контроллера

Модульное тестирование предполагает тестирование части приложения изолированно от его инфраструктуры и зависимостей. При модульном тестировании логики контроллера тестируется только содержимое отдельного действия, но не поведение его зависимостей или самого фреймворка.

Настройте модульные тесты для действий контроллера, ориентированные на поведение контроллера. В модульных тестах контроллера не учитываются такие аспекты, как фильтрация, маршрутизация и (или) привязка модели. Тесты, учитывающие взаимодействие компонентов, участвующих в ответе на запрос, охватываются интеграционными тестами. Дополнительные сведения об тестах интеграции см. в разделе "Тесты интеграции" в 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.
  • Можно протестировать с использованием имитированной службы IBrainstormSessionRepository и фреймворка mock-объектов, например Moq. Макет объекта представляет собой специально созданный объект с определенным набором свойств и методов поведения, который предназначен для тестирования. Дополнительные сведения см. в разделе Introduction to integration tests (Введение в интеграционные тесты).

В методе HTTP GET Index нет циклов или ветвления, и он вызывает лишь один метод. Модульный тест для этого действия позволяет выполнить следующие задачи:

  • Создание макета службы IBrainstormSessionRepository с помощью метода GetTestSessions. GetTestSessions создает два макета сессий мозгового штурма с датами и именами сеансов;
  • Выполнение метода Index
  • Делает утверждения о результатах, возвращаемых методом:
    • Возвращается ViewResult.
    • ViewDataDictionary.Model — это StormSessionViewModel.
    • в 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;
}

Метод HTTP POST Index контроллера Home проверяет, что:

  • При метод действия возвращает false с соответствующими данными.
  • Когда ModelState.IsValid равно true:
    • вызывается метод репозитория 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 недопустим, возвращается тот же ViewResult, что и при GET-запросе. В этом тесте не выполняются попытки передать недопустимую модель. Передача недопустимой модели будет неправильным подходом, так как привязка модели не выполняется (хотя интеграционный тест и использует привязку модели). В этом случае привязка модели не тестируется. Эти модульные тесты проверяют только код в методе действия.

Второй тест проверяет, выполняются ли при допустимом значении ModelState следующие условия:

  • Добавляется новый BrainstormSession (через репозиторий).
  • метод возвращает RedirectToActionResult с ожидаемыми свойствами.

Макеты вызовов, которые не выполняются, обычно игнорируются, но вызов Verifiable в конце вызова Setup позволяет проверить макет в тесте. Для этого выполняется вызов метода mockRepo.Verify, который приводит к провалу теста, если требуемый метод не был вызван.

Note

Библиотека Moq, используемая в этом примере, позволяет сочетать проверяемые, или «строгие», моки с непроверяемыми моками (которые также называют «свободными» моками или заглушками). Узнайте больше о настройке поведения mock-объектов с помощью Moq.

SessionController в примере приложения выводит сведения, связанные с определенным сеансом мозгового штурма. Этот контроллер содержит логику для работы с недопустимыми значениями id (в следующем примере приведены два сценария return, посвященные этим сценариям). Последний return оператор возвращает в представление новый StormSessionViewModel (Controllers/SessionController.cs):

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 в действии Index контроллера Session:

[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 для маршрута api/ideas:

  • Список идей (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.

Далее пример приложения демонстрирует модульные тесты для методов API Create и ForSession контроллера Ideas.

Этот пример приложения содержит два теста 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);
}

Чтобы протестировать поведение метода Create, когда ModelState недействителен, тестовое приложение в ходе теста добавляет в контроллер ошибку модели. Не пытайтесь тестировать валидацию модели или привязку модели в юнит-тестах — просто проверьте поведение метода действия, если ему передана недопустимая модель 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, или конкретный тип.

Пример приложения содержит метод, который возвращает List<IdeaDTO> для указанного сеанса id. Если сеанс 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;
}

В ApiIdeasControllerTests включены два теста контроллера ForSessionActionResult.

Первый из этих тестов проверяет, что контроллер возвращает 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>.Value — это тип List<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 для заданного сеанса. Контроллер возвращает:

  • BadRequest для недопустимой модели;
  • NotFound если сеанс не существует.
  • CreatedAtAction когда сеанс обновлен новой идеей.
[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 следующие условия:

  • Метод возвращает объект ActionResult типа BrainstormSession.
  • Значение ActionResult<T>.Result имеет тип CreatedAtActionResult. CreatedAtActionResult аналогичен ответу 201 Created с заголовком Location.
  • ActionResult<T>.Value — это тип BrainstormSession.
  • Имитированный вызов для обновления сеанса, UpdateAsync(testSession), был выполнен. Вызов метода Verifiable проверяется выполнением mockRepo.Verify() в утверждениях.
  • Возвращаются два объекта Idea для сеанса.
  • Последний элемент (Idea, добавленный мок-вызовом UpdateAsync) совпадает со значением 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);
}