Поделиться через


Включение автоматического модульного тестирования

от Корпорации Майкрософт

Загрузить PDF-файл

Это шаг 12 бесплатного руководства по приложению "NerdDinner" , в которых показано, как создать небольшое, но полное веб-приложение с помощью ASP.NET MVC 1.

На шаге 12 показано, как разработать набор автоматизированных модульных тестов, которые проверяют наши функции NerdDinner и которые дадут нам уверенность в том, что в будущем будут вносить изменения и улучшения в приложение.

Если вы используете ASP.NET MVC 3, рекомендуем следовать руководствам по начало работы С MVC 3 или MVC Music Store.

NerdDinner, шаг 12. Модульное тестирование

Давайте разработаем набор автоматизированных модульных тестов, которые проверяют возможности NerdDinner и придадут нам уверенность в том, что в будущем будут вносить изменения и улучшения в приложение.

Почему модульный тест?

На диске в работу однажды утром у вас есть внезапная вспышка вдохновения о приложении вы работаете над. Вы понимаете, что есть изменения, которые можно реализовать, которые значительно улучшит приложение. Это может быть рефакторинг, который очищает код, добавляет новую функцию или исправляет ошибку.

Вопрос, который стоит перед вами, когда вы прибываете на свой компьютер: "Насколько безопасно сделать это улучшение?" Что делать, если изменение имеет побочные эффекты или нарушает что-то? Это изменение может быть простым и занять всего несколько минут, но что делать, если на ручное тестирование всех сценариев приложения потребуется несколько часов? Что делать, если вы забыли охватить сценарий и неработающее приложение перейдет в рабочую среду? Действительно ли сделать это улучшение стоит всех усилий?

Автоматизированные модульные тесты могут обеспечить систему безопасности, которая позволяет постоянно улучшать приложения и не бояться кода, над которым вы работаете. Автоматизированные тесты, которые быстро проверяют функциональные возможности, позволяют уверенно кодировать и вносить улучшения, которые в противном случае могли бы не чувствовать себя комфортно. Они также помогают создавать решения, которые более пригодны для обслуживания и имеют более длительный срок службы, что приводит к гораздо более высокой рентабельности инвестиций.

Платформа ASP.NET MVC позволяет легко и естественно выполнять модульное тестирование функций приложения. Он также включает рабочий процесс разработки на основе тестирования (TDD), который обеспечивает разработку на основе тестирования.

Проект NerdDinner.Tests

Когда мы создали приложение NerdDinner в начале этого руководства, нам было предложено диалоговое окно с вопросом, нужно ли создать проект модульного тестирования для выполнения проекта приложения:

Снимок экрана: диалоговое окно

Мы оставили переключатель "Да, создать проект модульного теста", что привело к добавлению проекта "NerdDinner.Tests" в наше решение:

Снимок экрана: дерево навигации Обозреватель решений. Выбран пункт Тесты Nerd Dinner.

Проект NerdDinner.Tests ссылается на сборку проекта приложения NerdDinner и позволяет легко добавлять в него автоматические тесты, проверяющие функциональные возможности приложения.

Создание модульных тестов для класса модели Dinner

Давайте добавим несколько тестов в проект NerdDinner.Tests, которые проверяют класс Dinner, созданный при создании слоя модели.

Начнем с создания папки в тестовом проекте с именем Models, в которой будут размещаться тесты, связанные с моделью. Затем щелкните папку правой кнопкой мыши и выберите команду меню Add-New Test (Добавить и> создать тест ). Откроется диалоговое окно "Добавление нового теста".

Мы создадим модульный тест и назовем его DinnerTest.cs:

Снимок экрана: диалоговое окно

При нажатии кнопки "ОК" Visual Studio добавит (и откроет) файл DinnerTest.cs в проект:

Снимок экрана: файл точки C S Dinner в Visual Studio.

Шаблон модульного теста Visual Studio по умолчанию содержит множество кодов котлов, которые я считаю немного грязным. Давайте очистим его до следующего кода:

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NerdDinner.Models;

namespace NerdDinner.Tests.Models {
 
    [TestClass]
    public class DinnerTest {

    }
}

Атрибут [TestClass] в приведенном выше классе DinnerTest определяет его как класс, который будет содержать тесты, а также необязательный код инициализации и удаления тестов. Мы можем определить тесты в ней, добавив открытые методы с атрибутом [TestMethod].

Ниже приведен первый из двух тестов, которые мы добавим, что упражнение наш класс Dinner. Первый тест проверяет, является ли наш ужин недопустимым, если новый ужин создан без правильного задания всех свойств. Второй тест проверяет допустимость ужина, если у ужина есть все свойства, заданные с допустимыми значениями:

[TestClass]
public class DinnerTest {

    [TestMethod]
    public void Dinner_Should_Not_Be_Valid_When_Some_Properties_Incorrect() {

        //Arrange
        Dinner dinner = new Dinner() {
            Title = "Test title",
            Country = "USA",
            ContactPhone = "BOGUS"
        };

        // Act
        bool isValid = dinner.IsValid;

        //Assert
        Assert.IsFalse(isValid);
    }

    [TestMethod]
    public void Dinner_Should_Be_Valid_When_All_Properties_Correct() {
        
        //Arrange
        Dinner dinner = new Dinner {
            Title = "Test title",
            Description = "Some description",
            EventDate = DateTime.Now,
            HostedBy = "ScottGu",
            Address = "One Microsoft Way",
            Country = "USA",
            ContactPhone = "425-703-8072",
            Latitude = 93,
            Longitude = -92,
        };

        // Act
        bool isValid = dinner.IsValid;

        //Assert
        Assert.IsTrue(isValid);
    }
}

Выше вы заметите, что имена тестов очень явные (и несколько подробные). Мы делаем это потому, что в конечном итоге мы можем создать сотни или тысячи небольших тестов, и мы хотим, чтобы быстро определить намерения и поведение каждого из них (особенно при просмотре списка сбоев в средстве выполнения тестов). Имена тестов должны быть названы в честь тестируемых функций. Выше мы используем шаблон именования "Noun_Should_Verb".

Мы структурируем тесты с помощью шаблона тестирования "AAA", который расшифровывается как "Arrange, Act, Assert":

  • Упорядочение: настройка тестируемого модуля
  • Действие. Выполните тестируемый урок и запишите результаты
  • Assert: проверка поведения

Когда мы пишем тесты, мы хотим избежать слишком многого выполнения отдельных тестов. Вместо этого каждый тест должен проверять только одну концепцию (что значительно упростит определение причины сбоев). Рекомендуется использовать только один оператор assert для каждого теста. Если в методе теста используется несколько операторов assert, убедитесь, что все они используются для проверки одной и той же концепции. Если вы сомневаетесь, сделайте еще один тест.

Выполнение тестов

Visual Studio 2008 Professional (и более поздние версии) включает встроенное средство выполнения тестов, которое можно использовать для запуска проектов модульного теста Visual Studio в интегрированной среде разработки. Чтобы запустить все модульные тесты, можно выбрать команду Тесты-запустить-все>> тесты в решении (или ввести клавиши CTRL R, A). Кроме того, можно разместить курсор в определенном тестовом классе или методе теста и использовать команду меню Test-Run-Tests>> в текущем контексте (или ввести ctrl R, T) для выполнения подмножества модульных тестов.

Давайте поместим курсор в класс DinnerTest и введем "Ctrl R, T", чтобы выполнить два только что определенных теста. После этого в Visual Studio появится окно "Результаты теста", и в нем будут отображаться результаты тестового запуска:

Снимок экрана: окно

Примечание. В окне результатов теста VS столбец Имя класса по умолчанию не отображается. Это можно добавить, щелкнув правой кнопкой мыши в окне Результаты теста и выбрав команду меню Добавить и удалить столбцы.

Наши два теста заняли лишь часть секунды, и, как вы видите, они оба прошли. Теперь мы можем продолжать и дополнять их, создавая дополнительные тесты, которые проверяют определенные проверки правил, а также охватывают два вспомогательных метода — IsUserHost() и IsUserRegistered(), которые мы добавили в класс Dinner. Наличие всех этих тестов для класса Dinner сделает его гораздо проще и безопаснее добавлять в него новые бизнес-правила и проверки в будущем. Мы можем добавить новую логику правил в Dinner, а затем в течение нескольких секунд убедиться, что она не нарушила ни одну из наших предыдущих функций логики.

Обратите внимание, что использование описательного имени теста позволяет быстро понять, что проверяет каждый тест. Рекомендуется использовать команду меню Сервис-Параметры>, открыть экран конфигурации Инструменты тестирования-Test> Execution и установить флажок "Двойной щелчок неудачного или неубедительного результата модульного теста отображает точку сбоя в тесте". Это позволит дважды щелкнуть ошибку в окне результатов теста и сразу перейти к сбою утверждения.

Создание модульных тестов DinnersController

Теперь создадим несколько модульных тестов, которые проверяют функциональные возможности DinnersController. Сначала щелкните правой кнопкой мыши папку "Контроллеры" в проекте Test, а затем выберите команду меню Add-New Test (Добавить новый> тест ). Мы создадим модульный тест и назовем его DinnersControllerTest.cs.

Мы создадим два метода теста, которые проверяют метод действия Details() в DinnersController. Первый проверяет, возвращается ли представление при запросе существующего ужина. Второй проверяет, возвращается ли представление NotFound при запросе несуществующего ужина:

[TestClass]
public class DinnersControllerTest {

    [TestMethod]
    public void DetailsAction_Should_Return_View_For_ExistingDinner() {

        // Arrange
        var controller = new DinnersController();

        // Act
        var result = controller.Details(1) as ViewResult;

        // Assert
        Assert.IsNotNull(result, "Expected View");
    }

    [TestMethod]
    public void DetailsAction_Should_Return_NotFoundView_For_BogusDinner() {

        // Arrange
        var controller = new DinnersController();

        // Act
        var result = controller.Details(999) as ViewResult;

        // Assert
        Assert.AreEqual("NotFound", result.ViewName);
    } 
}

Приведенный выше код компилирует очистку. Однако при выполнении тестов они оба завершаются ошибкой:

Снимок экрана: код. Оба теста завершились сбоем.

Если посмотреть на сообщения об ошибках, мы увидим, что причина неудачных тестов заключается в том, что классу DinnersRepository не удалось подключиться к базе данных. Наше приложение NerdDinner использует строку подключения к локальному файлу SQL Server Express, который находится в каталоге \App_Data проекта приложения NerdDinner. Так как наш проект NerdDinner.Tests компилируется и выполняется в другом каталоге, отличном от каталога проекта приложения, относительный путь к нашей строке подключения является неправильным.

Это можно исправить, скопировав файл базы данных SQL Express в тестовый проект, а затем добавить в него соответствующую строку подключения теста в App.config тестового проекта. В этом случае приведенные выше тесты будут разблокированы и запущены.

Однако модульное тестирование кода с использованием реальной базы данных сопряжено с рядом проблем. В частности:

  • Это значительно замедляет время выполнения модульных тестов. Чем дольше выполняется тест, тем меньше вероятность их частого выполнения. В идеале вы хотите, чтобы модульные тесты могли выполняться за считанные секунды, и это было то, что вы делаете так же естественно, как компиляция проекта.
  • Это усложняет настройку и очистку логики в тестах. Вы хотите, чтобы каждый модульный тест был изолирован и не зависел от других (без побочных эффектов или зависимостей). При работе с реальной базой данных необходимо учитывать состояние и сбрасывать его между тестами.

Давайте рассмотрим шаблон проектирования под названием "внедрение зависимостей", который поможет нам обойти эти проблемы и избежать необходимости использования реальной базы данных с нашими тестами.

Внедрение зависимостей

Сейчас DinnersController тесно "связан" с классом DinnerRepository. "Связь" относится к ситуации, когда класс явно полагается на другой класс для работы:

public class DinnersController : Controller {

    DinnerRepository dinnerRepository = new DinnerRepository();

    //
    // GET: /Dinners/Details/5

    public ActionResult Details(int id) {

        Dinner dinner = dinnerRepository.FindDinner(id);

        if (dinner == null)
            return View("NotFound");

        return View(dinner);
    }

Так как для класса DinnerRepository требуется доступ к базе данных, тесно связанная зависимость класса DinnersController от DinnerRepository в конечном итоге требует наличия базы данных для тестирования методов действий DinnersController.

Мы можем обойти это, используя шаблон проектирования, называемый "внедрением зависимостей", который представляет собой подход, при котором зависимости (например, классы репозитория, предоставляющие доступ к данным) больше неявно создаются в классах, которые их используют. Вместо этого зависимости можно явно передать в класс, который использует их с помощью аргументов конструктора. Если зависимости определяются с помощью интерфейсов, у нас будет возможность передавать "поддельные" реализации зависимостей для сценариев модульного теста. Это позволяет нам создавать тестовые реализации зависимостей, которым на самом деле не требуется доступ к базе данных.

Чтобы увидеть это в действии, давайте реализуем внедрение зависимостей с помощью dinnersController.

Извлечение интерфейса IDinnerRepository

Первым шагом будет создание нового интерфейса IDinnerRepository, который инкапсулирует контракт репозитория, необходимый нашим контроллерам для получения и обновления Dinners.

Этот контракт интерфейса можно определить вручную, щелкнув правой кнопкой мыши папку \Models, а затем выбрав команду меню Добавить новый> элемент и создав интерфейс с именем IDinnerRepository.cs.

Кроме того, можно использовать средства рефакторинга, встроенные в Visual Studio Professional (и более поздних выпусков), чтобы автоматически извлечь и создать интерфейс из существующего класса DinnerRepository. Чтобы извлечь этот интерфейс с помощью VS, просто расположите курсор в текстовом редакторе класса DinnerRepository, а затем щелкните правой кнопкой мыши и выберите команду меню Рефакторинг-Извлечь> интерфейс :

Снимок экрана, на котором показано извлечение интерфейса, выбранного в подменю

Откроется диалоговое окно "Извлечение интерфейса" и появится запрос на ввод имени создаваемого интерфейса. По умолчанию используется IDinnerRepository и автоматически выбираются все открытые методы в существующем классе DinnerRepository для добавления в интерфейс:

Снимок экрана: окно

При нажатии кнопки "ОК" Visual Studio добавит в наше приложение новый интерфейс IDinnerRepository:

public interface IDinnerRepository {

    IQueryable<Dinner> FindAllDinners();
    IQueryable<Dinner> FindByLocation(float latitude, float longitude);
    IQueryable<Dinner> FindUpcomingDinners();
    Dinner             GetDinner(int id);

    void Add(Dinner dinner);
    void Delete(Dinner dinner);
    
    void Save();
}

А наш существующий класс DinnerRepository будет обновлен таким образом, чтобы он реализовывал интерфейс :

public class DinnerRepository : IDinnerRepository {
   ...
}

Обновление DinnersController для поддержки внедрения конструктора

Теперь мы обновим класс DinnersController для использования нового интерфейса.

В настоящее время DinnersController жестко закодирована таким образом, что его поле "dinnerRepository" всегда является классом DinnerRepository:

public class DinnersController : Controller {

    DinnerRepository dinnerRepository = new DinnerRepository();

    ...
}

Мы изменим его так, чтобы поле "dinnerRepository" было типа IDinnerRepository вместо DinnerRepository. Затем мы добавим два открытых конструктора DinnersController. Один из конструкторов позволяет передавать IDinnerRepository в качестве аргумента. Другой — конструктор по умолчанию, который использует существующую реализацию DinnerRepository:

public class DinnersController : Controller {

    IDinnerRepository dinnerRepository;

    public DinnersController()
        : this(new DinnerRepository()) {
    }

    public DinnersController(IDinnerRepository repository) {
        dinnerRepository = repository;
    }
    ...
}

Так как ASP.NET MVC по умолчанию создает классы контроллера с помощью конструкторов по умолчанию, наш DinnersController во время выполнения будет по-прежнему использовать класс DinnerRepository для доступа к данным.

Теперь мы можем обновить модульные тесты, чтобы передать реализацию "поддельного" репозитория ужина с помощью конструктора параметров. Этот "поддельный" репозиторий ужина не потребует доступа к реальной базе данных и вместо этого будет использовать образец данных в памяти.

Создание класса FakeDinnerRepository

Давайте создадим класс FakeDinnerRepository.

Начнем с создания каталога Fakes в проекте NerdDinner.Tests, а затем добавим в него новый класс FakeDinnerRepository (щелкните папку правой кнопкой мыши и выберите Добавить новый> класс):

Снимок экрана: пункт меню

Мы обновим код, чтобы класс FakeDinnerRepository реализовывал интерфейс IDinnerRepository. Затем можно щелкнуть его правой кнопкой мыши и выбрать команду контекстного меню "Реализовать интерфейс IDinnerRepository":

Снимок экрана: команда контекстного меню

Это приведет к тому, что Visual Studio автоматически добавит все члены интерфейса IDinnerRepository в класс FakeDinnerRepository с реализациями заглушки по умолчанию:

public class FakeDinnerRepository : IDinnerRepository {

    public IQueryable<Dinner> FindAllDinners() {
        throw new NotImplementedException();
    }

    public IQueryable<Dinner> FindByLocation(float lat, float long){
        throw new NotImplementedException();
    }

    public IQueryable<Dinner> FindUpcomingDinners() {
        throw new NotImplementedException();
    }

    public Dinner GetDinner(int id) {
        throw new NotImplementedException();
    }

    public void Add(Dinner dinner) {
        throw new NotImplementedException();
    }

    public void Delete(Dinner dinner) {
        throw new NotImplementedException();
    }

    public void Save() {
        throw new NotImplementedException();
    }
}

Затем можно обновить реализацию FakeDinnerRepository, чтобы использовать коллекцию List<Dinner> в памяти, переданную ей в качестве аргумента конструктора:

public class FakeDinnerRepository : IDinnerRepository {

    private List<Dinner> dinnerList;

    public FakeDinnerRepository(List<Dinner> dinners) {
        dinnerList = dinners;
    }

    public IQueryable<Dinner> FindAllDinners() {
        return dinnerList.AsQueryable();
    }

    public IQueryable<Dinner> FindUpcomingDinners() {
        return (from dinner in dinnerList
                where dinner.EventDate > DateTime.Now
                select dinner).AsQueryable();
    }

    public IQueryable<Dinner> FindByLocation(float lat, float lon) {
        return (from dinner in dinnerList
                where dinner.Latitude == lat && dinner.Longitude == lon
                select dinner).AsQueryable();
    }

    public Dinner GetDinner(int id) {
        return dinnerList.SingleOrDefault(d => d.DinnerID == id);
    }

    public void Add(Dinner dinner) {
        dinnerList.Add(dinner);
    }

    public void Delete(Dinner dinner) {
        dinnerList.Remove(dinner);
    }

    public void Save() {
        foreach (Dinner dinner in dinnerList) {
            if (!dinner.IsValid)
                throw new ApplicationException("Rule violations");
        }
    }
}

Теперь у нас есть фиктивная реализация IDinnerRepository, которая не требует базы данных и может вместо этого отработать список объектов Dinner в памяти.

Использование FakeDinnerRepository с модульными тестами

Давайте вернемся к модульным тестам DinnersController, которые ранее завершились сбоем из-за недоступности базы данных. Мы можем обновить методы теста, чтобы использовать FakeDinnerRepository, заполненный примером данных в памяти Dinner в DinnersController, используя следующий код:

[TestClass]
public class DinnersControllerTest {

    List<Dinner> CreateTestDinners() {

        List<Dinner> dinners = new List<Dinner>();

        for (int i = 0; i < 101; i++) {

            Dinner sampleDinner = new Dinner() {
                DinnerID = i,
                Title = "Sample Dinner",
                HostedBy = "SomeUser",
                Address = "Some Address",
                Country = "USA",
                ContactPhone = "425-555-1212",
                Description = "Some description",
                EventDate = DateTime.Now.AddDays(i),
                Latitude = 99,
                Longitude = -99
            };
            
            dinners.Add(sampleDinner);
        }
        
        return dinners;
    }

    DinnersController CreateDinnersController() {
        var repository = new FakeDinnerRepository(CreateTestDinners());
        return new DinnersController(repository);
    }

    [TestMethod]
    public void DetailsAction_Should_Return_View_For_Dinner() {

        // Arrange
        var controller = CreateDinnersController();

        // Act
        var result = controller.Details(1);

        // Assert
        Assert.IsInstanceOfType(result, typeof(ViewResult));
    }

    [TestMethod]
    public void DetailsAction_Should_Return_NotFoundView_For_BogusDinner() {

        // Arrange
        var controller = CreateDinnersController();

        // Act
        var result = controller.Details(999) as ViewResult;

        // Assert
        Assert.AreEqual("NotFound", result.ViewName);
    }
}

И теперь, когда мы запускаем эти тесты, они оба проходят:

Снимок экрана: модульные тесты, оба теста пройдены.

Самое лучшее, что они выполняются всего за долю секунды и не требуют какой-либо сложной логики установки и очистки. Теперь мы можем выполнить модульное тестирование всего кода метода действий DinnersController (включая перечисление, разбиение по страницам, сведения, создание, обновление и удаление) без необходимости подключения к реальной базе данных.

Побочный раздел: Платформы внедрения зависимостей
Выполнение внедрения зависимостей вручную (как описано выше) работает нормально, но становится сложнее поддерживать по мере увеличения количества зависимостей и компонентов в приложении. Для .NET существует несколько платформ внедрения зависимостей, которые могут обеспечить еще большую гибкость управления зависимостями. Эти платформы, также иногда называемые контейнерами "Инверсия управления" (IoC), предоставляют механизмы, обеспечивающие дополнительный уровень поддержки конфигурации для указания и передачи зависимостей объектам во время выполнения (чаще всего с помощью внедрения конструктора). Некоторые из наиболее популярных платформ внедрения зависимостей OSS и IOC в .NET включают: AutoFac, Ninject, Spring.NET, StructureMap и Windsor. ASP.NET MVC предоставляет API расширяемости, которые позволяют разработчикам участвовать в разрешении и создании экземпляров контроллеров, а также обеспечивают чистую интеграцию платформ внедрения зависимостей и IoC в этот процесс. Использование платформы DI/IOC также позволит нам удалить конструктор по умолчанию из нашего DinnersController, что полностью удалит связь между ним и DinnerRepository. Мы не будем использовать платформу внедрения зависимостей или IOC с нашим приложением NerdDinner. Но это то, что мы могли бы рассмотреть в будущем, если бы база кода и возможности NerdDinner росли.

Создание правки модульных тестов действий

Теперь создадим некоторые модульные тесты, которые проверяют функциональность Edit в DinnersController. Для начала мы протестируем версию HTTP-GET нашего действия Изменить:

//
// GET: /Dinners/Edit/5

[Authorize]
public ActionResult Edit(int id) {

    Dinner dinner = dinnerRepository.GetDinner(id);

    if (!dinner.IsHostedBy(User.Identity.Name))
        return View("InvalidOwner");

    return View(new DinnerFormViewModel(dinner));
}

Мы создадим тест, который проверяет, отображается ли представление, поддерживаемое объектом DinnerFormViewModel, при запросе допустимого ужина:

[TestMethod]
public void EditAction_Should_Return_View_For_ValidDinner() {

    // Arrange
    var controller = CreateDinnersController();

    // Act
    var result = controller.Edit(1) as ViewResult;

    // Assert
    Assert.IsInstanceOfType(result.ViewData.Model, typeof(DinnerFormViewModel));
}

Однако при выполнении теста мы увидим, что он завершается ошибкой, так как при обращении метода Edit к свойству User.Identity.Name для выполнения проверка Dinner.IsHostedBy() возникает исключение пустой ссылки.

Объект User в базовом классе Controller инкапсулирует сведения о вошедшего пользователя и заполняется ASP.NET MVC при создании контроллера во время выполнения. Так как мы тестируем DinnersController за пределами среды веб-сервера, объект User не задан (следовательно, исключение пустой ссылки).

Имитация свойства User.Identity.Name

Макетные платформы упрощают тестирование, позволяя нам динамически создавать поддельные версии зависимых объектов, которые поддерживают наши тесты. Например, мы можем использовать платформу макета в нашем тесте действия Изменить, чтобы динамически создать объект User, который наш DinnersController может использовать для поиска имитированного имени пользователя. Это позволит избежать создания пустой ссылки при выполнении теста.

Существует множество платформ макетов .NET, которые можно использовать с ASP.NET MVC (список из них можно просмотреть здесь: http://www.mockframeworks.com/).

После скачивания мы добавим ссылку в проект NerdDinner.Tests в сборку Moq.dll:

Снимок экрана: дерево навигации Nerd Dinner. Moq выделен.

Затем мы добавим вспомогательный метод CreateDinnersControllerAs(username)" в тестовый класс, который принимает имя пользователя в качестве параметра и затем "имитирует" свойство User.Identity.Name в экземпляре DinnersController:

DinnersController CreateDinnersControllerAs(string userName) {

    var mock = new Mock<ControllerContext>();
    mock.SetupGet(p => p.HttpContext.User.Identity.Name).Returns(userName);
    mock.SetupGet(p => p.HttpContext.Request.IsAuthenticated).Returns(true);

    var controller = CreateDinnersController();
    controller.ControllerContext = mock.Object;

    return controller;
}

Выше мы используем Moq для создания макета объекта, который имитирует объект ControllerContext (который ASP.NET MVC передает в классы Контроллера для предоставления объектов среды выполнения, таких как User, Request, Response и Session). Мы вызываем метод SetupGet в Mock, чтобы указать, что свойство HttpContext.User.Identity.Name в ControllerContext должно возвращать строку имени пользователя, переданную во вспомогательный метод.

Мы можем имитирование любого количества свойств и методов ControllerContext. Чтобы проиллюстрировать это, я также добавил вызов SetupGet() для свойства Request.IsAuthenticated (которое на самом деле не требуется для приведенных ниже тестов, но помогает проиллюстрировать, как можно имитировать свойства запроса). По завершении мы назначим экземпляр макета ControllerContext в DinnersController, который возвращается вспомогательным методом.

Теперь мы можем создавать модульные тесты, использующие этот вспомогательный метод для тестирования сценариев редактирования с участием разных пользователей:

[TestMethod]
public void EditAction_Should_Return_EditView_When_ValidOwner() {

    // Arrange
    var controller = CreateDinnersControllerAs("SomeUser");

    // Act
    var result = controller.Edit(1) as ViewResult;

    // Assert
    Assert.IsInstanceOfType(result.ViewData.Model, typeof(DinnerFormViewModel));
}

[TestMethod]
public void EditAction_Should_Return_InvalidOwnerView_When_InvalidOwner() {

    // Arrange
    var controller = CreateDinnersControllerAs("NotOwnerUser");

    // Act
    var result = controller.Edit(1) as ViewResult;

    // Assert
    Assert.AreEqual(result.ViewName, "InvalidOwner");
}

И теперь, когда мы запускаем тесты, они проходят:

Снимок экрана: модульные тесты, использующие вспомогательный метод. Тесты пройдены.

Тестирование сценариев UpdateModel()

Мы создали тесты, охватывающие версию HTTP-GET действия Изменить. Теперь создадим некоторые тесты, которые проверяют версию HTTP-POST действия Изменить:

//
// POST: /Dinners/Edit/5

[AcceptVerbs(HttpVerbs.Post), Authorize]
public ActionResult Edit (int id, FormCollection collection) {

    Dinner dinner = dinnerRepository.GetDinner(id);

    if (!dinner.IsHostedBy(User.Identity.Name))
        return View("InvalidOwner");

    try {
        UpdateModel(dinner);

        dinnerRepository.Save();

        return RedirectToAction("Details", new { id=dinner.DinnerID });
    }
    catch {
        ModelState.AddModelErrors(dinner.GetRuleViolations());
        
        return View(new DinnerFormViewModel(dinner));
    }
}

Интересным новым сценарием тестирования для поддержки этого метода действия является использование вспомогательного метода UpdateModel() в базовом классе Controller. Мы используем этот вспомогательный метод для привязки значений form-post к экземпляру объекта Dinner.

Ниже приведены два теста, демонстрирующие, как можно предоставить опубликованные в форме значения для вспомогательного метода UpdateModel(). Для этого мы создадим и заполним объект FormCollection, а затем назначим его свойству ValueProvider в контроллере.

Первый тест проверяет, что при успешном сохранении браузер перенаправляется на действие сведений. Второй тест проверяет, что при публикации недопустимых входных данных действие повторно отображает представление редактирования с сообщением об ошибке.

[TestMethod]
public void EditAction_Should_Redirect_When_Update_Successful() {

    // Arrange      
    var controller = CreateDinnersControllerAs("SomeUser");

    var formValues = new FormCollection() {
        { "Title", "Another value" },
        { "Description", "Another description" }
    };

    controller.ValueProvider = formValues.ToValueProvider();
    
    // Act
    var result = controller.Edit(1, formValues) as RedirectToRouteResult;

    // Assert
    Assert.AreEqual("Details", result.RouteValues["Action"]);
}

[TestMethod]
public void EditAction_Should_Redisplay_With_Errors_When_Update_Fails() {

    // Arrange
    var controller = CreateDinnersControllerAs("SomeUser");

    var formValues = new FormCollection() {
        { "EventDate", "Bogus date value!!!"}
    };

    controller.ValueProvider = formValues.ToValueProvider();

    // Act
    var result = controller.Edit(1, formValues) as ViewResult;

    // Assert
    Assert.IsNotNull(result, "Expected redisplay of view");
    Assert.IsTrue(result.ViewData.ModelState.Count > 0, "Expected errors");
}

Тестирование Wrap-Up

Мы рассмотрели основные понятия, связанные с классами контроллеров модульного тестирования. Эти методы позволяют легко создавать сотни простых тестов, которые проверяют поведение приложения.

Так как для тестов контроллера и модели не требуется реальная база данных, они очень быстры и просты в выполнении. Мы сможем выполнить сотни автоматических тестов за считанные секунды и сразу же получить отзыв о том, сломало ли что-то изменение. Это поможет нам обеспечить уверенность в постоянном совершенствовании, рефакторинге и совершенствовании приложения.

Мы рассмотрели тестирование в качестве последнего раздела в этой главе, но не потому, что тестирование необходимо выполнить в конце процесса разработки. Напротив, вы должны создавать автоматические тесты как можно раньше в процессе разработки. Это позволяет получать немедленные отзывы по мере разработки, помогает вдумчиво думать о сценариях использования приложения и помогает спроектировать приложение с учетом чистых слоев и взаимосвязей.

В более поздней главе книги рассматривается разработка на основе тестирования (TDD) и ее использование с ASP.NET MVC. TDD — это итеративный подход к написанию тестов, которым будет соответствовать полученный код. С помощью TDD вы начинаете каждую функцию с создания теста, который проверяет функциональность, которую вы хотите реализовать. Сначала написание модульного теста поможет вам четко понять функцию и ее работу. Только после того, как тест будет написан (и вы убедились, что он не пройден), вы реализуете фактические функциональные возможности, проверяемые тестом. Так как вы уже потратили время на размышления о варианте использования функции, вы получите лучшее представление о требованиях и способах их реализации. Когда вы закончите работу с реализацией, вы можете повторно запустить тест и немедленно оставить отзыв о том, правильно ли работает функция. Подробнее о TDD мы рассмотрим в главе 10.

Следующий шаг

Некоторые окончательные комментарии.