В предыдущей статье, мы узнали, как писать модульные тесты, используя xUnit и различные атрибуты, которые он предоставляет. Мы разобрались, как тестировать правила проверки внутри одного класса проверки.
А как насчет контроллеров и всех действий внутри? Можем ли мы написать для них тесты?
Конечно.
В этой статье мы объясним, как это сделать.
Использование библиотеки Moq для создания фиктивных объектов при тестировании контроллеров MVC
Прежде чем мы начнем, давайте взглянем на код конструктора EmployeesController
:
Как видите, мы используем Injection Dependency, чтобы внедрить интерфейс в наш контроллер. Таким образом, наш контроллер зависит от логики репозитория через внедренный интерфейс.
И в этом подходе нет никаких проблем. Но когда мы пишем тесты для нашего контроллера или любого другого класса в проекте, мы должны изолировать эти зависимости.
Изолирование зависимостей в тестовом коде дает несколько преимуществ:
- Нам не нужно инициализировать все зависимости, чтобы возвращать правильные значения, что значительно упрощает наш тестовый код.
- Если наш тест завершился неудачно и мы не изолировали зависимость, мы не можем быть уверены, что это не так из-за ошибки в контроллере или в этой зависимости.
- Когда зависимый код взаимодействует с реальной базой данных, как это делает наш репозиторий, то выполнение тестового кода может занять больше времени из-за проблем с подключением или просто процесса выборки данных.
Конечно, есть дополнительные причины изолировать зависимости в тестовом коде, но суть вы поняли.
Давайте установим библиотеку Moq в проекте EmployeesApp.Tests
:
После завершения установки необходимо создать новую папку Controller
в том же проекте и добавить класс EmployeesControllerTests
:
Создание фиктивного объекта
Давайте изменим класс EmployeesControllerTests
:
public class EmployeesControllerTests
{
private readonly Mock<IEmployeeRepository> _mockRepo;
private readonly EmployeesController _controller;
public EmployeesControllerTests()
{
_mockRepo = new Mock<IEmployeeRepository>();
_controller = new EmployeesController(_mockRepo.Object);
}
}
Мы создаем фиктивный объект типа IEmployeeRepository
внутри конструктора, и, поскольку мы хотим протестировать логику контроллера, мы создаем экземпляр этого контроллера с фиктивным объектом в качестве обязательного параметра.
Вот и все. Все подготовлено, зависимость смоделирована, поэтому все, что нам нужно сделать, это написать несколько тестов.
Тестирование действия Index
Если мы посмотрим на действие Index
в классе EmployeesController
, мы увидим, что получаем всех сотрудников из базы данных и возвращаем представление с этими сотрудниками:
public IActionResult Index()
{
var employees = _repo.GetAll();
return View(employees);
}
В результате мы можем написать пару тестов, чтобы убедиться, что это действие делает именно то, что должно делать.
[Fact]
public void Index_ActionExecutes_ReturnsViewForIndex()
{
var result = _controller.Index();
Assert.IsType<ViewResult>(result);
}
Итак, мы выполняем действие Index
из нашего контроллера и принимаем результат в соотв. переменную. После этого мы проверяем тип возвращаемого результата с помощью метода IsType
. Если результат относится к типу ViewResult
, тест будет пройден, в противном случае он завершится ошибкой.
Давайте запустим окно обозревателя тестов и посмотрим результат:
Мы видим, что наш тест прошел успешно и что результат имеет тип ViewResult
.
Напишем еще один тестовый метод, чтобы убедиться, что наше действие Index
возвращает точное количество сотрудников:
[Fact]
public void Index_ActionExecutes_ReturnsExactNumberOfEmployees()
{
_mockRepo.Setup(repo => repo.GetAll())
.Returns(new List<Employee>() { new Employee(), new Employee() });
var result = _controller.Index();
var viewResult = Assert.IsType<ViewResult></ViewResult>(result);
var employees = Assert.IsType<List<Employee>>(viewResult.Model);
Assert.Equal(2, employees.Count);
}
В предыдущем методе тестирования мы не использовали фиктивный объект, потому что мы не использовали ни один из наших методов репозитория. Мы только проверили тип нашего результата.
В этом методе тестирования мы извлекаем данные из базы данных с помощью метода репозитория GetAll
. Конечно, мы не хотим использовать конкретный репозиторий, а использовать имитацию, и поэтому мы используем метод Setup
, чтобы указать настройку для метода GetAll
. Кроме того, мы должны использовать метод Returns
, чтобы указать значение, возвращаемое из имитационного метода GetAll
.
После того, как мы сохраняем результат действия Index
, мы проверяем тип этого результата, тип объекта модели внутри этого результата и, наконец, количество сотрудников, используя метод Equal
. Чтобы пройти тест, необходимо пройти все три проверки, в противном случае тест не будет считаться пройденным.
Давайте посмотрим на этот тест в действии:
Тест действительно прошел, и мы убедились, что он возвращает ровно двух сотрудников.
Тестирование действия Create
У нас есть два действия Create
в нашем классе EmployeesController
: действие GET и действие POST. Первое действие просто загружает представление Create View
>, и это то, что мы должны проверить.
[Fact]
public void Create_ActionExecutes_ReturnsViewForCreate()
{
var result = _controller.Create();
Assert.IsType<ViewResult>(result);
}
У нас уже был подобный тест, только с действием Index
, поэтому в нем нет ничего нового. Если мы запустим Test Explorer, мы можем убедиться, что тест проходит:
Перейдем ко второму действию "Create" - POST. В этом действии у нас есть проверка модели, и если она недействительна, мы возвращаем представление с объектом сотрудника.
[Fact]
public void Create_InvalidModelState_ReturnsView()
{
_controller.ModelState.AddModelError("Name", "Name is required");
var employee = new Employee { Age = 25, AccountNumber = "255-8547963214-41" };
var result = _controller.Create(employee);
var viewResult = Assert.IsType<ViewResult>(result);
var testEmployee = Assert.IsType<Employee>(viewResult.Model);
Assert.Equal(employee.AccountNumber, testEmployee.AccountNumber);
Assert.Equal(employee.Age, testEmployee.Age);
}
Мы должны добавить ошибку модели в свойство ModelState
, чтобы проверить недопустимое состояние модели.
После этого мы создаем нового сотрудника без свойства Name
, что также делает его недействительным.
Наконец, мы вызываем действие Create
и выполняем несколько утверждений.
С помощью операторов Assert
мы проверяем, имеет ли результат тип ViewResult
и что модель имеет тип Employee
. Кроме того, мы гарантируем, что вернем того же сотрудника, сравнивая значения свойств из объектов testEmployee
и сотрудников:
Дополнительный тест недопустимой модели
Давайте напишем еще один тест, чтобы убедиться, что метод CreateEmployee
из нашего репозитория никогда не выполняется, если состояние модели недопустимо:
[Fact]
public void Create_InvalidModelState_CreateEmployeeNeverExecutes()
{
_controller.ModelState.AddModelError("Name", "Name is required");
var employee = new Employee { Age = 34 };
_controller.Create(employee);
_mockRepo.Verify(x => x.CreateEmployee(It.IsAny<Employee>()), Times.Never);
}
Первые три строки кода такие же, как и в предыдущем методе тестирования, мы добавляем ошибку модели, создаем недопустимый объект сотрудника и вызываем действие Create
из нашего контроллера.
В этом действии есть метод CreateEmployee
, который не должен выполняться, если модель недействительна. Это именно то, что мы проверяем с помощью метода Verify
из фиктивного объекта. Используя выражение It.IsAny
, мы заявляем, что не имеет значения, какой сотрудник передается в качестве параметра методу CreateEmployee
. Важно только то, что параметр имеет тип Employee
.
Последний параметр метода Verify
- это количество выполнений нашего метода. В этой ситуации мы используем Times.Never
, потому что мы не хотим, чтобы метод CreateEmployee
вообще выполнялся, если модель недействительна.
Вернемся в обозреватель тестов:
Как видите, тест прошел успешно.
Если мы изменим тестовый код, поместив Times.Once
вместо Times.Never
, тест обязательно завершится ошибкой. Возможно, вы захотите попробовать сами:
Сообщение поясняет, в чем проблема и почему тест не проходит.
Проверка допустимости модели в действии создания
Если состояние модели допустимо, метод CreateEmployee
следует выполнить только один раз:
public void Create_ModelStateValid_CreateEmployeeCalledOnce()
{
Employee emp = null;
_mockRepo.Setup(r => r.CreateEmployee(It.IsAny()))
.Callback(x => emp = x);
var employee = new Employee
{
Name = "Test Employee",
Age = 32,
AccountNumber = "123-5435789603-21"
};
_controller.Create(employee);
_mockRepo.Verify(x => x.CreateEmployee(It.IsAny<Employee>()), Times.Once);
Assert.Equal(emp.Name, employee.Name);
Assert.Equal(emp.Age, employee.Age);
Assert.Equal(emp.AccountNumber, employee.AccountNumber);
}
Итак, мы настраиваем метод CreateEmployee
с любым сотрудником и используемдля заполнения объекта emp
значениями из параметра employee
. После этого мы создаем локальный объект employee
, выполняем действие Create
с этим employee
и проверяем, если CreateEmployee
был выполнен только один раз.
Кроме того, мы проверяем, что объект emp
совпадает с объектом employee
, предоставленным для действия Create
.
Добавим еще один тест:
[Fact]
public void Create_ActionExecuted_RedirectsToIndexAction()
{
var employee = new Employee
{
Name = "Test Employee",
Age = 45,
AccountNumber = "123-4356874310-43"
};
var result = _controller.Create(employee);
var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirectToActionResult.ActionName);
}
Если мы посмотрим на действие Create
в нашем контроллере, то мы увидим, что после создания нового сотрудника мы перенаправляем пользователя на действие Index
.
Заключение
Вот и все. Мы использовали действия Index
и Create
, чтобы показать вам, как протестировать наш контроллер и действия внутри него, но все правила могут применяться к другим типам действий (PUT, DELETE) и др..
Итак, подводя итог, мы узнали:
- Как использовать библиотеку Moq для изоляции зависимостей в тестовом коде
- Как написать различные тесты для наших действий внутри контроллера