Разработка, управляемая тестами

OSzone.net » Microsoft » Разработка приложений » .NET Framework » Разработка, управляемая тестами
Автор: Кит Бернелл
Иcточник: MSDN Magazine
Опубликована: 21.11.2012

В основе шаблона Model-View-Controller (MVC) лежит разделение UI-функций на три компонента. Модель представляет данные и поведение (функциональность) в вашей предметной области. Представление управляет отображением модели и обрабатывает взаимодействие с пользователями. Контроллер координирует взаимодействие между представлением и моделью. Такое отделение изначально сложной в тестировании UI-логики от прикладной логики позволяет в максимальной степени охватывать тестами приложения, реализованные на основе шаблона MVC. В этой статье мы обсудим принципы и методики увеличения «тестируемости» ваших приложений ASP.NET MVC и, в том числе, рассмотрим, как структурировать решение, проектировать архитектуру кода для обработки встраивания зависимостей и реализовать встраивание зависимостей с помощью StructureMap.

Структуризация решения для максимального охвата тестами

Начать наше обсуждение лучше всего с того, с чего начинают новый проект все разработчики: с создания решения. Мы рассмотрим некоторые рекомендации по структурированию решения Visual Studio на основе моего опыта в создании крупномасштабных корпоративных приложений ASP.NET MVC с применением разработки, управляемой тестами (test-driven development, TDD). Первым делом при создании проекта ASP.NET MVC я предлагаю использовать шаблон пустого проекта. Остальные шаблоны хороши для экспериментов или проверки концепции, но, в целом, в них слишком много «шума», который в реальных корпоративных приложениях будет только отвлекать ваше внимание и совершенно не нужен.

Всякий раз, когда вы создаете какой-либо тип сложного приложения, вы должны использовать многоуровневую архитектуру. В разработке приложения ASP.NET MVC я рекомендую применять подход, который иллюстрируют рис. 1 и 2, где содержатся следующие проекты:

Именование тестовых проектов не менее важно, чем их размещение в общей структуре.

*
Рис. 1. Взаимодействие между уровнями

*
Рис. 2. Пример структуры решения

Я советую помещать контроллеры в отдельный проект Visual Studio. Насколько легко это делается, читайте в статье по ссылке bit.ly/K4mF2B. Размещение контроллеров в отдельном проекте позволяет добиться еще большего разделения логики, содержащейся в контроллерах, и UI-кода. В результате ваш веб-проект содержит только код, который действительно относится к UI.

Где размещать тестовые проекты Очень важно понимать, где размещать тестовые проекты и как их именовать. Когда вы разрабатываете сложные приложения корпоративного уровня, решения имеют тенденцию к значительному разбуханию, что затрудняет поиск конкретного класса или блока кода в Solution Explorer. Добавление множества тестовых проектов в существующую кодовую базу лишь усложняет навигацию в Solution Explorer. Я настоятельно советую физически отделять тестовые проекты от кода самого приложения. Поэтому я предлагаю помещать все тестовые проекты в папку Tests на уровне решения. Размещение всех тестовых проектов и тестов в одной папке решения значительно уменьшает «шум» в режиме просмотра по умолчанию в Solution Explorer и позволяет легко находить нужные тесты.

Затем вы захотите разделить типы тестов. Более чем вероятно, в вашем решении будет множество типов тестов (модульные, для проверки интеграции, производительности, UI и т. д.), и важно изолировать и сгруппировать тесты каждого типа. Это не только упрощает поиск тестов конкретных типов, но и позволяет легко запускать все тесты определенного типа. Если вы используете любой из популярных инструментальных наборов для Visual Studio — ReSharper (jetbrains.com/ReSharper) или CodeRush (devexpress.com/CodeRush), то получаете возможность щелчком правой кнопки мыши любой папки, проекта или класса в Solution Explorer вызвать контекстное меню и запустить все тесты, содержащиеся в данном элементе. Для группирования тестов по типам создайте папку для каждого типа тестов, которые вы планируете создать и поместить в папку Tests решения.

На рис. 3 приведен пример папки Tests решения, содержащей ряд папок для типов тестов.

*
Рис. 3. Пример папки Tests решения

Именование тестовых проектов Именование тестовых проектов не менее важно, чем их размещение в общей структуре. Вы наверняка захотите, чтобы можно было легко различать, какая часть вашего приложения находится под тестированием в каждом тестовом проекте и какой тип тестов содержит этот проект. Для этого следует именовать свои тестовые проекты по следующему соглашению: [Полное имя тестируемого проекта].Тест.[Тип теста]. Это позволит сходу точно определять уровень тестируемого проекта и тип выполняемого теста. Возможно, вы считаете, что размещение тестовых проектов в папках, специфичных для их типов, и включение типа тестов в имя тестового проекта избыточно, но помните, что папки решения используются только в Solution Explorer и не включаются в пространства имен файлов проектов. Поэтому, хотя проект модульного тестирования Controllers находится в папке Tests\Unit решения, пространство имен — TestDrivingMVC.Controllers.Test.Unit — не отражает эту структуру папок. Добавление типа тестов при именовании проекта необходимо для того, чтобы избежать конфликтов имен и определять, над каким типом теста вы работаете в редакторе. На рис. 4 показан Solution Explorer с тестовыми проектами.

*
Рис. 4. Тестовые проекты в Solution Explorer

Встраивание зависимостей в вашу архитектуру

Модульное тестирование многоуровневого приложения будет продолжаться до первой зависимости в тестируемом коде. Эти зависимости могут быть другими уровнями вашего приложения или полностью внешними для вашего кода (например, базой данных, файловой системой или веб-сервисами). При написании модульных тестов вам нужно корректно обрабатывать такие ситуации и использовать тестовые дублеры (test doubles) (фиктивные модули, имитации или заглушки), встречая внешнюю зависимость. Подробнее о тестовых дублерах см. статью «Exploring the Continuum of Test Doubles» (msdn.microsoft.com/magazine/cc163358) в сентябрьском номере «MSDN Magazine» за 2007 г. Однако, прежде чем вы сможете задействовать гибкость тестовых дублеров, ваш код должен отвечать определенной архитектуре, чтобы быть способным обрабатывать встраивание зависимостей.

Встраивание зависимости Это процесс встраивания конкретных реализаций, необходимых классу, а не класса, напрямую создающего экземпляры зависимости. Класс-потребитель (consuming class) ничего не знает о конкретной реализации любой из его зависимостей — ему известны только их интерфейсы; конкретные реализации предоставляются либо классом-потребителем, либо инфраструктурой встраивания зависимости (dependency injection, DI).

Цель встраивания зависимости — создание как можно более свободно связанного кода. Свободное связывание позволяет легко замещать реализации тестовых дублеров ваших зависимостей при написании модульных тестов.

Существует три основных способа встраивания зависимости:

При встраивании свойства вы предоставляете открытые свойства своего объекта, давая возможность установить его зависимости, как показано на рис. 5. Этот подход довольно прост и не требует никакого инструментария.

Рис. 5. Встраивание свойства

// Сервис Employee
public class EmployeeService : IEmployeeService {
  private ILoggingService _loggingService;
  public EmployeeService() {}
  public ILoggingService LoggingService { get; set; }
  public decimal CalculateSalary(long employeeId) {
    EnsureDependenciesSatisfied();
    _loggingService.LogDebug(string.Format(
      "Calculating Salary For Employee: {0}", employeeId));
    decimal output = 0;
    /*
    * Сложная логика, выполняемая для расчета
    * зарплаты сотрудника
    */
    return output;
  }
  private void EnsureDependenciesSatisfied() {
    if (_loggingService == null)
      throw new InvalidOperationException(
        "Logging Service dependency must be satisfied!");
    }
  }
}
// Контроллер Employee (потребитель EmployeeService)
public class EmployeeController : Controller {
  public ActionResult DisplaySalary(long id) {
    EmployeeService employeeService = new EmployeeService();
    employeeService.LoggingService = new LoggingService();
    decimal salary = employeeService.CalculateSalary(id);
    return View(salary);
  }
}

У этого подхода три недостатка. Во-первых, он перекладывает заботу о предоставлении зависимостей на потребителя. Во-вторых, он требует от вас реализации сторожевого кода (guarding code) в ваших объектах, чтобы зависимости устанавливались до того, как они начинают использоваться. В-третьих, с увеличением количества зависимостей в вашем объекте, растет и объем кода, необходимого для создания экземпляра этого объекта.

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

Цель встраивания зависимости — создание как можно более свободно связанного кода.

Рис. 6. Встраивание конструктора

// Сервис Employee
public class EmployeeService : IEmployeeService {
  private ILoggingService _loggingService;
  public EmployeeService(ILoggingService loggingService) {
    _loggingService = loggingService;
  }
  public decimal CalculateSalary(long employeeId) {
    _loggingService.LogDebug(string.Format(
      "Calculating Salary For Employee: {0}", employeeId));
    decimal output = 0;
    /*
    * Сложная логика, выполняемая для расчета
    * зарплаты сотрудника
    */
    return output;
  }
}

// Потребитель EmployeeService
public class EmployeeController : Controller {
  public ActionResult DisplaySalary(long employeeId) {
    EmployeeService employeeService =
      new EmployeeService(new LoggingService());
    decimal salary = employeeService.CalculateSalary(employeeId);
    return View(salary);
  }
}

К сожалению, этот подход по-прежнему требует предоставления зависимостей от потребителя. Более того, этот подход на самом деле пригоден только для небольших приложений. В более крупных приложениях, как правило, слишком много зависимостей, чтобы предоставлять их через конструктор объекта.

Третий способ реализации встраивания зависимостей — использование инфраструктуры DI/IoC. Такая инфраструктура полностью снимает с потребителя ответственность за предоставление зависимостей и позволяет вам конфигурировать свои зависимости на этапе разработки, а разрешать их — в период выполнения. Для .NET существует много инфраструктур DI/IoC, в том числе Unity (предложение Microsoft), StructureMap, Castle Windsor, Ninject и др. Концепция всех инфраструктур DI/IoC одинакова, и выбор одной из них обычно сводится к персональным предпочтениям. Чтобы продемонстрировать инфраструктуры DI/IoC в этой статье, я буду использовать StructureMap.

Вывод встраивания зависимостей на новый уровень с помощью StructureMap

StructureMap (structuremap.net) — широко применяемая инфраструктура встраивания зависимостей. Ее можно установить через NuGet с помощью либо Package Manager Console (Install-Package StructureMap), либо NuGet Package Manager GUI (щелкните правой кнопкой мыши папку ссылок вашего проекта и выберите Manage NuGet Packages).

Конфигурирование зависимостей с помощью StructureMap Первый шаг в реализации StructureMap в ASP.NET MVC — конфигурирование ваших зависимостей так, чтобы StructureMap стало известно, как разрешать их. Это делается в методе Application_Start в Global.asax одним из двух способов.

Первый способ заключается в том, что вы самостоятельно сообщаете StructureMap, что для определенной абстрактной реализации следует использовать определенную конкретную реализацию:

ObjectFactory.Initialize(register => {
  register.For<ILoggingService>().Use<LoggingService>();
  register.For<IEmployeeService>().Use<EmployeeService>();
});

Недостаток этого подхода в том, что вы должны вручную регистрировать каждую зависимость в своем приложении, а в крупных приложениях это может оказаться делом весьма утомительным. Более того, поскольку вы регистрируете зависимости в Application_Start сайта ASP.NET MVC, ваш веб-уровень должен знать обо всех других уровнях вашего приложения, к которым подключены зависимости.

Вы также можете использовать средства автоматической регистрации и сканирования StructureMap для анализа ваших сборок и автоматического подключения зависимостей. При этом подходе StructureMap сканирует ваши сборки и, встречая какой-либо интерфейс, ищет сопоставленную с ним конкретную реализацию (на основе той концепции, что интерфейс именем IFoo по соглашению должен сопоставляться с конкретной реализацией Foo):

ObjectFactory.Initialize(registry => registry.Scan(x => {
  x.AssembliesFromApplicationBaseDirectory();
  x.WithDefaultConventions();
}));

Компонент разрешения зависимостей в StructureMap После конфигурирования зависимостей вам понадобится возможность обращения к ним из кодовой базы. Для этого создается компонент разрешения зависимостей (dependency resolver) и размещается в проекте Shared (так как ему потребуется доступ ко всем уровням приложения, имеющим зависимости):

public static class Resolver {
  public static T GetConcreteInstanceOf<T>() {
    return ObjectFactory.GetInstance<T>();
  }
}

Класс Resolver (я предпочитаю называть его так, потому что Microsoft ввела класс DependencyResolver с появлением ASP.NET MVC 3, и чуть позже я немного расскажу о нем) является простым статическим классом с одной функцией. Эта функция принимает обобщенный параметр T, представляющий интерфейс, для которого вы ищете конкретную реализацию, и возвращает T, который представляет саму реализацию переданного интерфейса.

Прежде чем обсуждать, как использовать новый класс Resolver в вашем коде, я хочу пояснить, почему я написал собственный компонент разрешения зависимостей вместо создания класса, который реализует интерфейс IDependency­Resolver, введенный в ASP.NET MVC 3. Включение функциональности IDependency­Resolver — отличное дополнение ASP.NET MVC и большой шаг вперед в продвижении правильных приемов программирования. К сожалению, он размещен в System.Web.MVC DLL, а я не хочу, чтобы в уровнях моего приложения, не имеющих отношения к Web, были ссылки на библиотеку, специфичную для веб-технологии.

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

Рис. 7. Разрешение зависимостей в коде

public class EmployeeService : IEmployeeService {
  private ILoggingService _loggingService;
  public EmployeeService() {
    _loggingService =
      Resolver.GetConcreteInstanceOf<ILoggingService>();
  }
  public decimal CalculateSalary(long employeeId) {
    _loggingService.LogDebug(string.Format(
      "Calculating Salary For Employee: {0}", employeeId));
    decimal output = 0;
    /*
    * Сложная логика, выполняемая для расчета
    * зарплаты сотрудника
    */
    return output;
  }
}

Использование StructureMap для встраивания тестовых дублеров в модульные тесты После того как архитектура кода сделана такой, что позволяет вводить зависимости безо всякого участия потребителя, давайте вернемся к исходной задаче корректной обработки зависимостей в модульных тестах. Сценарий будет следующим.

Весьма вероятно, что с такого рода сценарием вы уже сталкивались. Но теперь у вас есть подходящая архитектура, способная разорвать привязку к зависимости подменой на тестового дублера. Я предпочитаю создавать тестовые дублеры в проекте, который можно использовать во всех моих тестовых проектах. Как видно на рис. 8, я создал проект Shared в папке Tests решения. В этот проект я добавил папку Fakes, так как для выполнения тестов мне нужна имитация реализации ILoggingService.

*
Рис. 8. Проект для общего тестового кода и имитаций

Создать имитацию (fake) для сервиса протоколирования легко. Сначала я создаю класс LoggingServiceFake в папке Fakes. LoggingService­Fake должен удовлетворять контракту, ожидаемому EmployeeService, т. е. он должен реализовать ILoggingService и его методы. По определению, имитация — это дублер, содержащий ровно столько кода, чтобы имитировать интерфейс. Как правило, это подразумевает, что у него есть пустые реализации void-методов, а реализации функций содержат выражение return, которое возвращает «зашитое» в код значение, например:

public class LoggingServiceFake : ILoggingService {
  public void LogError(string message, Exception ex) {}
  public void LogDebug(string message) {}
  public bool IsOnline() {
    return true;
  }
}

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

Реализовав имитацию, я могу написать тест. Для начала я создам тестовый класс в проекте модульных тестов TestDrivingMVC.Service.Test.Unit и в соответствии с уже рассмотренными соглашениями назову его Employee¬ServiceTest, как показано на рис. 9.

Рис. 9. Тестовый класс EmployeeServiceTest

[TestClass]
public class EmployeeServiceTest {
  private ILoggingService _loggingServiceFake;
  private IEmployeeService _employeeService;
  [TestInitialize]
  public void TestSetup() {
    _loggingServiceFake = new LoggingServiceFake();
    ObjectFactory.Initialize(x =>
     x.For<ILoggingService>().Use(_loggingServiceFake));
    _employeeService = new EmployeeService();
  }
  [TestMethod]
  public void CalculateSalary_ShouldReturn_Decimal() {
   // Arrange
    long employeeId = 12345;
    // Act
    var result =
      _employeeService.CalculateSalary(employeeId);
    // Assert
    result.ShouldBeType<decimal>();
  }
}

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

ObjectFactory.Initialize(x =>
  x.For<ILoggingService>().Use(
  _loggingService));

Это код, указывающий StructureMap использовать LoggingServiceFake, когда ранее созданный нами класс Resolver пытается разрешить ILoggingService. Я поместил этот код в метод, помеченный атрибутом TestInitialize, который сообщает инфраструктуре модульного тестирования выполнить этот метод до выполнения всех тестов в тестовом классе.

Благодаря возможностям DI/IoC и StructureMap я могу полностью отвязать код от сервиса протоколирования. Это позволяет мне кодировать и выполнять модульные тесты независимо от состояния сервиса протоколирования, а также писать модульные тесты, которые не полагаются на какие-либо зависимости.

Использование StructureMap в качестве фабрики контроллеров по умолчанию ASP.NET MVC предоставляет точку расширения, через которую вы можете добавить собственную реализацию того, как в вашем приложении создаются экземпляры контроллеров. Написав класс, производный от DefaultControllerFactory (рис. 10), можно управлять тем, как создаются контроллеры.

Рис. 10. Собственная фабрика контроллеров

public class ControllerFactory : DefaultControllerFactory {
  private const string ControllerNotFound =
  "The controller for path '{0}' could not be found or it does not implement IController.";
  private const string NotAController = "Type requested is not a controller: {0}";
  private const string UnableToResolveController =
    "Unable to resolve controller: {0}";
  public ControllerFactory() {
    Container = ObjectFactory.Container;
  }
  public IContainer Container { get; set; }
  protected override IController GetControllerInstance(
    RequestContext context, Type controllerType) {
    IController controller;
    if (controllerType == null)
      throw new HttpException(404, String.Format(ControllerNotFound,
      context.HttpContext.Request.Path));
    if (!typeof (IController).IsAssignableFrom(controllerType))
      throw new ArgumentException(string.Format(NotAController,
      controllerType.Name), "controllerType");
    try {
      controller = Container.GetInstance(controllerType)
        as IController;
    }
    catch (Exception ex) {
      throw new InvalidOperationException(
      String.Format(UnableToResolveController,
        controllerType.Name), ex);
    }
    return controller;
  }
}

В новой фабрике контроллеров имеется открытое StructureMap-свойство Container, которое задается на основе StructureMap ObjectFactory (оно конфигурируется на рис. 10 в Global.asax).

У меня также есть переопределенная версия метода GetControllerInstance, который выполняет проверку типов, а затем использует контейнер StructureMap для разрешения текущего контроллера на основе переданного параметра — типа контроллера. Поскольку я изначально задействовал средства автоматической регистрации и сканирования при конфигурировании StructureMap, больше ничего делать не требуется.

Преимущество создания собственной фабрики контроллеров заключается в том, что вы больше не ограничены непараметризованными конструкторами в своих контроллерах. В этот момент вы, возможно, задаетесь вопросом: «Как же я буду передавать параметры в конструктор контроллера?». Благодаря расширяемости DefaultControllerFactory и StructureMap вам и не нужно этого делать. Когда вы объявляете параметризованный конструктор для своих контроллеров, зависимости разрешаются автоматически при разрешении контроллера в новой фабрике контроллеров.

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

Как видно на рис. 11, я добавил параметр IEmployeeService к конструктору HomeController. Когда контроллер разрешается в новой фабрике контроллеров, любые параметры, требуемые конструктором контроллера, разрешаются автоматически. То есть вам не нужно добавлять код для ручного разрешения зависимостей контроллера, но вы по-прежнему можете использовать имитации, как обсуждалось ранее.

Рис. 11. Разрешение контроллера

public class HomeController : Controller {
  private readonly IEmployeeService _employeeService;
  public HomeController(IEmployeeService employeeService) {
    _employeeService = employeeService;
  }
  public ActionResult Index() {
    return View();
  }
  public ActionResult DisplaySalary(long id) {
    decimal salary = _employeeService.CalculateSalary(id);
    return View(salary);
  }
}

Используя эти рекомендации и приемы в своих приложениях ASP.NET MVC, вы упростите процесс TDD и сделаете его более четким.


Ссылка: http://www.oszone.net/19166/