Наши инструменты и приемы для TDD

19 мая 2009 г.

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

Из этого инструмента убрали все лишнее и оставили только самое нужное. xUnit обладает удивительно понятным API. Он интегрирован с ReSharper, что делает работу с ним еще более простой. Чтобы начать использовать с xUnit, надо потратить совсем немного времени на чтение основ его работы.

TDD-программистам со временем надоедает мочить интерфейсы вручную и тогда они берут Moq. Как и в случае с xUnit, выбор именно Moq кроется в его простоте. Прочитав документацию использования, вы можете сразу приступать к использованию.

Мудрые программисты давно познали один из самых главных принципов - принцип инверсии зависимостей. Для более удобной реализации этого принципа на практике мы используем Ninject.

Теперь все вместе

Будем реализовывать следующую историю:

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

Начнем с теста. Только начиная с теста, можно встать на истинный путь TDD. Писать мы будем для ASP.NET MVC, поэтому начнем с теста для контроллера. Как можно понять на страницу детальной информации будет передаваться ID продукта.

   1:  public class ProductControllerTests
   2:  {
   3:      [Fact]
   4:      public void FindProductByIdAndAddToViewModel()
   5:      {
   6:          var controller = new ProductController();
   7:          var result = (ViewResult)controller.ProductDetails(1);
   8:          var model = (Product) result.ViewData.Model;
   9:          Assert.Equal(1, model.Id);
  10:      }
  11:  }

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

Тест не компилируется, потому что ничего еще не реализовано.

Добавим объекты контроллера и продукта в проект.

   1:  public class ProductController : Controller
   2:  {
   3:      /// <summary>
   4:      /// Отображение детальной информации о продукте с ID равной <paramref name="productId"/>
   5:      /// </summary>
   6:      /// <param name="productId"><see cref="Product.Id"/></param>
   7:      public ActionResult ProductDetails(int productId)
   8:      {
   9:          return View();
  10:      }
  11:  }
   1:  public class Product
   2:  {
   3:      public int Id { get; set; }
   4:  }

Теперь все компилируется, но тест не проходит, потому что функциональность не реализована.

Внутри контроллера мы будем брать объект продукта из хранилища. Назовем его интерфейс IProductRepository. Реализовывать для теста сам ProductRepository нам не надо. ProductController принимает следущий вид:

   1:  /// <summary>
   2:  /// Отображение детальной информации о продукте с ID равной <paramref name="productId"/>
   3:  /// </summary>
   4:  /// <param name="productId"><see cref="Product.Id"/></param>
   5:  public ActionResult ProductDetails(int productId)
   6:  {
   7:      Product product = Builder.Create<IProductRepository>().FindById(productId);
   8:      return View(product);
   9:  }

С контроллером мы закончили! Что за объект Builder? В нашем случае это будет просто обертка для IKernel - это объект Ninject.

   1:  public static class Builder
   2:  {
   3:      private static IKernel container;
   4:   
   5:      public static void Init(IKernel kernel)
   6:      {
   7:          container = kernel;
   8:      }
   9:   
  10:      public static T Create<T>()
  11:      {
  12:          return container.Get<T>();
  13:      }
  14:  }

Теперь, для работы теста надо инициализировать Builder и вернуть нужный нам объект из хранилища.

   1:  public class ProductControllerTests
   2:  {
   3:      private readonly Mock<IProductRepository> mockProductRepository = new Mock<IProductRepository>();
   4:   
   5:      public ProductControllerTests()
   6:      {
   7:          var module = new InlineModule(m => m.Bind<IProductRepository>().ToConstant(mockProductRepository.Object));
   8:          IKernel kernel = new StandardKernel(module);
   9:          Builder.Init(kernel);
  10:      }
  11:   
  12:      [Fact]
  13:      public void FindProductByIdAndAddToViewModel()
  14:      {
  15:          mockProductRepository.Setup(m => m.FindById(1)).Returns(new Product {Id = 1});
  16:   
  17:          var controller = new ProductController();
  18:          var result = (ViewResult)controller.ProductDetails(1);
  19:   
  20:          var model = (Product) result.ViewData.Model;
  21:          Assert.Equal(1, model.Id);
  22:      }
  23:  }

Для того, чтобы задать поведение IProductRepository мы использовали Moq.

Чтобы реализовать работу системы в объчном режиме, надо инициализировать все зависимости при запуске нашего сайта:

   1:  public class MvcApplication : HttpApplication
   2:  {
   3:          protected void Application_Start()
   4:          {
   5:                  var module = new InlineModule(m => m.Bind<IProductRepository>()
   6:                                                          .To<ProductRepository>()
   7:                                                          .Using<OnePerRequestBehavior>());
   8:                  IKernel kernel = new StandardKernel(module);
   9:                  Builder.Init(kernel);
  10:   
  11:                  RegisterRoutes(RouteTable.Routes);
  12:          }
  13:          ...

Теперь у нас есть тест, который срабатывает и контроллер, который протестирован. Чтобы реализовать историю использования остается реализовать ProductRepository.

И так далее в таком же духе!

P.S. По вкусу можно использовать NUnit, MbUnit, MSTest, StuctureMap, Unity, Rhino.Mocks и так далее.

8 комментариев:

  1. Я может чего-то не так понял, но вы правда предлагаете напрямую обращаться к статическим методам IoC контейнера?!
    Это же ужасный вариант для тестирования, особенно когда для самих тестов тоже нужен IoC контейнер.
    Тем более, что уж в MVC-то есть прекрасные фабрики, с помощью которых все, что вам нужно (репозитории, например) будет инжектиться прямо в конструктор.

    ОтветитьУдалить
  2. @Игорь
    Я примерно понял твои опасения и ты очасти прав.

    Дело в том, что класс Builder используется в качестве обертки над любым IoC контейнером. В данном случае используется Ninject. С таким же успехом можно использовать, например, StuctureMap. Для этого надо будет только заменить метод Init. Конечно, в Init можно было бы передавать какой-то объект типа IDependencyContainer и сделать разные его реализации для разных случаев.

    "Это же ужасный вариант для тестирования, особенно когда для самих тестов тоже нужен IoC контейнер."
    Для тестирования этот вариант отлично подходит и это показала практика. Сейчас на проекте мы используем именно такой подход. Может я не вижу каких-то недостатков, с которыми мы можем столкнуться в будущем?

    "Тем более, что уж в MVC-то есть прекрасные фабрики..."
    Builder используется не только для создания объектов в проекте Web, а также для настройки зависимостей между другими проектами.

    В любом случае будет интересно увидеть вариант решения этой истории использования на http://igar.ru ;)

    P.S. Приведенная мной реализация отлично работает на практике и пока к с ней проблем не возникает

    ОтветитьУдалить
  3. Подход с билдером нулячий, посмотри на примеры MvcContrib, такие же точно подходы используються везде. Никто и никогда не делает билдеров, все включая рутовый обьект собираеться при помощи ИоК контейнера. Соотвевенно IProductRepository это просто зависимость кторая инжектиться в консрутктор...


    П.С. Читаю твой блог, но впечатление такое что реального проэета у тебя нема... Сорри, мож просто показалось.

    ОтветитьУдалить
  4. @Mike Chaliy
    "такие же точно подходы используються везде"
    Это не аргумент, потому что мы используем подход, который решает наши конкретные задачи, а то что это используется "везде" нам не сильно интересно =)

    ОтветитьУдалить
  5. @Mike Chaliy, существуют множество разных методов инверсии зависимости:
    1. Пассивная инверсия зависимостей:
    a) constructor injection;
    b) setter injection;
    c) interface injection;
    d) field injection.
    2. Активная инверсия зависимостей (Dependency Lookup):
    a) pull approach;
    b) push approach.

    О том, какой принцип выбрать для конкретного примера можно спорить годами, от этого проект в срок выполнен не будет.

    PS: В том, что мы в нашем текущем проекте выбрали принцип 2a нет ничего плохого.

    ОтветитьУдалить
  6. Строка типа

    Product product = Builder.Create().FindById(productId);

    это все-таки зло. Понятно, что есть подход pull approach для DI, но не стоит забывать что этот подход ставит принцип IoC с ног на голову (ИНЪЕКЦИЯ зависимостей а не ПОИСК зависимостей) и превращает паттерн DI в Service Locator. Есть случаи, когда просто нет альтернативы pull approach (например когда зависимости нужны атрибутам или статичным методам), но в контроллер все-таки лучше проводить именно инъекцию. Ведь и вся инфраструктура для этого есть (ControllerFactory)!!!

    ОтветитьУдалить
  7. @admax
    На сегодняшний день я с тобой полностью согласен. использование пассивной инъекции чревато проблемами. В ближайшее время обязательно об этом напишу и посыплю свою голову пеплом.

    Спасибо за комментарий!

    ОтветитьУдалить
  8. А касательно тестов, не стоит забывать что IOC контейнер может производить тяжеловесные опрерации при инициализации и повторять эти операции во время модульных тестов порочно. Вот при интергационном тестировании -- пожалуйста.

    ОтветитьУдалить

Моя книга «Антихрупкость в IT»

Как достигать результатов в IT-проектах в условиях неопределённости. Подробнее...