Модульное тестирование логики контроллера в 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.
  • Допускает проверку с помощью макета службы IBrainstormSessionRepository на платформе макетов объектов, например 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;
}

Тесты Home метода контроллера HTTP POST Index проверяют, что:

  • Если ModelState.IsValid имеет значение false, метод действия возвращает результат ViewResult400 Неверный запрос с соответствующими данными.
  • Когда 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, который устанавливает состояние непройденного теста, если требуемый метод не был вызван.

Примечание.

Библиотека Moq, используемая в этом примере, позволяет сочетать проверяемые (строгие) и непроверяемые макеты (которые также называют нестрогими макетами или заглушками). Узнайте больше о настройке поведения макетов с помощью 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 в контроллере Session действия Index:

[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.
  • Автоматически через специальную библиотеку, например 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);
}

В ApiIdeasControllerTests включены три теста CreateActionResult.

Первый из этих тестов позволяет проверить, возвращается ли 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 на платформе макетов объектов, например 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;
}

Тесты Home метода контроллера HTTP POST Index проверяют, что:

  • Если ModelState.IsValid имеет значение false, метод действия возвращает результат ViewResult400 Неверный запрос с соответствующими данными.
  • Когда 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, который устанавливает состояние непройденного теста, если требуемый метод не был вызван.

Примечание.

Библиотека Moq, используемая в этом примере, позволяет сочетать проверяемые (строгие) и непроверяемые макеты (которые также называют нестрогими макетами или заглушками). Узнайте больше о настройке поведения макетов с помощью 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 в контроллере Session действия Index:

[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.
  • Автоматически через специальную библиотеку, например 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);
}

В ApiIdeasControllerTests включены три теста CreateActionResult.

Первый из этих тестов позволяет проверить, возвращается ли 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);
}

Дополнительные ресурсы