Оптимизация процесса создания unit-тестов

    Всем привет! Хабраюзер shai_xylyd написал статью про аспекты тестирования, где им были рассмотрены некоторые понятия и ценности TDD. В частности, он упомянул очень интересный способ создания первичных юнит-тестов — когда функциональный код пишется совместно с кодом юнит-теста, чем меня очень заинтриговал.

    Дело в том, что я (как программист), нахожусь в состоянии переходного процесса между «классической» разработкой и разработкой test-driven, поэтому всякими способами ищу возможности упростить и сделать более естественной последнюю. После пары приседаний, сразу включиться в методику shai_xylyd не сумел. Начал переписку с автором статьи, где он натолкнул меня на мысль подойти к решению с математической точки зрения. Идея в том, чтобы воспользоваться функциональным пространством среды программирования и «разложить» написание юнит-теста на составляющие. После чего сделать выводы.

    Теория


    Для начала пара определений.

    Первичный юнит-тест — блок кода, покрывающий «основную» функцию тестируемой сущности.
    Вторичный юнит-тест — блок кода, покрывающий «основную» функцию тестируемой сущности в граничных условиях.

    Пространство Rp — конечное множество существенных данных среды программирования.
    (Другими словами, это множество существующих инстансов любых типов данных фиксированной платформы разработки).

    Функцией f(x) : Rp -> Rp назовем некоторую последовательность кода, выполненную над данными x из Rp.
    (Пример — в частном случае f(x) это простой метод класса, который принимает на вход x. Если сказать еще грубее, то f — это просто строчки кода).

    Мне нужно определить первичный юнит-тест (далее просто тест).

    Пусть z = h(x), где h — функция теста. Зафиксируем какое-то значение xo, тогда zo = h(xo). Теперь определим функцию a(zo), которая возвращает 0 (если zo некорректно) или 1 (если zo корректно). Иными словами, мы взяли какие-то тестовые данные xo, совершили с ними какие-то манипуляции в виде h(xo) и получили zo. Потом мы сделали assert для полученных данных zo и проверили правильность теста.
    Если перевести на псевдокод, то тестом будет являться следующая последовательность псевдокода:

    def xo
    zo = h(xo)
    a(zo)


    Пусть f(x) — функциональный код (тот, который будет работать в разрабатываемой сущности).
    Согласно описанному в самом начале методу, я должен писать функциональный код совместно с кодом теста. То есть:
    z = h(x) = f(m(x)), где m(x) — вспомогательный код: объекты-заглушки зависимостей функционального кода, моковые структуры фреймворка и т.п. (далее m(x) — моки)

    Теперь очень важная выкладка: природа моков такова, чтобы поставлять тестовые данные неизменными. Т.е. сущность мокового объекта в том, чтобы подменить поведение зависимости в тесте, выдав определенный программистом набор тестовых данных. Иными словами, m(x) = x. Отсюда следует разделение f(m(x)) = f(x). Последнее позволяет четко описать алгоритм создания теста, где функциональный код разрабатывается совместно с кодом теста.

    Алгоритм


    1. Определение тестовых данных и проверка результатов

      def xo
      def zo
      a(zo)

    2. Создание функционального кода

      def xo
      zo = f(xo)
      a(zo)

    3. Создание моков и вспомогательного кода теста

      def xo
      zo = f(m(xo))
      a(zo)

    4. Рефакторинг — вынесение f(x) в разрабатываемую сущность

      def xo
      zo = h(xo)
      a(zo)



    На каждом этапе тест должен выполнятся успешно. Что важно, сохраняется свойство TDD — сначала пишем тест для сущности, потом саму сущность.

    Практика и примеры


    Опробую этот метод на кошках себе. Платформа — .net, язык — C#, тестовая площадка — NUnit 2.x + Rhino Mocks 3.x

    Задача следующая. Есть топология заводов. Нужно определить микросервис, который по идентификатору завода возвращает инстанс класса «Завод»:

    /// <summary>
    /// Сервис определения завода по идентификатору
    /// </summary>
    /// <remarks>
    /// В случае, если в срезе данных нет таблиц с заводом
    /// </remarks>
    public interface INodeResolver
    {
      /// <summary>
      /// Найти завод по идентификатору
      /// </summary>
      /// <param name="id">Идентификатор завода</param>
      /// <returns>Завод</returns>
      Node FindById(int id);
    }


    * This source code was highlighted with Source Code Highlighter.


    За данные топологии отвечает сервис ITopologyService:

    /// <summary>
    /// Сервис данных топологии
    /// </summary>
    public interface ITopologyService
    {
      /// <summary>
      /// Возвращает "топологию" системы (БСУ, Контроллеры, БСО, Линии, etc)
      /// </summary>
      DataSets.TopologyData GetTopology(IDataFilter filter);
    }


    * This source code was highlighted with Source Code Highlighter.


    Т.е. мне надо создать сервис, который имеет зависимость от ITopologyService, получает от него данные и по переданному идентификатору создает новый инстанс класса Node.

    Создание теста



    Шаг 1. Нужно определить тестовые данные и результирующее значение

    [Test]
    public void FindNodeByIdTest()
    {      
      // x0
      TopologyData data = new TopologyData();
      data.Node.AddNodeRow(1, "Завод1", Guid.NewGuid());

      // z0
      Node node = new Node { Id = 1, Name = "Завод1" };

      Assert.AreEqual(1, node.Id);
      Assert.AreEqual("Завод1", node.Name);
    }


    * This source code was highlighted with Source Code Highlighter.


    Шаг 2. Определяем функциональность разрабатываемой сущности (по идентификатору завода получить объект «завод»)

    [Test]
    public void FindNodeByIdTest2()
    {
      // x0
      TopologyData data = new TopologyData();
      data.Node.AddNodeRow(1, "Завод1", Guid.NewGuid());

      // f(x0)
      TopologyData.NodeRow nodeRow = data.Node.FindByID(1);

      // z0
      Node node = new Node { Id = 1, Name = nodeRow.Description };
      Assert.AreEqual(1, node.Id);
      Assert.AreEqual("Завод1", node.Name);
    }

    * This source code was highlighted with Source Code Highlighter.


    Шаг 3. Создаем моки

    [Test]
    public void FindNodeByIdTest3()
    {
      MockRepository repo = new MockRepository();

      // x0
      TopologyData data = new TopologyData();
      data.Node.AddNodeRow(1, "Завод1", Guid.NewGuid());

      // m(x0)
      ITopologyService service = repo.StrictMock<ITopologyService>();
      service.Expect(x => x.GetTopology(EmptyFilter.Instance)).Return(data).Repeat.Once();
      
      repo.ReplayAll();

      // f(m(x0)) = f(x0)
      TopologyData dataSet = service.GetTopology(EmptyFilter.Instance);
      TopologyData.NodeRow nodeRow = dataSet.Node.FindByID(1);
      
      repo.VerifyAll();
      
      // z0
      Node node = new Node { Id = 1, Name = nodeRow.Description };
      Assert.AreEqual(1, node.Id);
      Assert.AreEqual("Завод1", node.Name);
    }


    * This source code was highlighted with Source Code Highlighter.


    Шаг 4: Рефакторинг и создание сущности:

    [Test]
    public void FindNodeByIdTest4()
    {
      MockRepository repo = new MockRepository();

      // x0
      TopologyData data = new TopologyData();
      data.Node.AddNodeRow(1, "Завод1", Guid.NewGuid());

      // m(x0)
      ITopologyService service = repo.StrictMock<ITopologyService>();
      service.Expect(x => x.GetTopology(EmptyFilter.Instance)).Return(data).Repeat.Once();

      repo.ReplayAll();

      NodeResolver resolver = new NodeResolver(service);
      // z0
      Node node = resolver.FindById(1);

      repo.VerifyAll();
      
      Assert.AreEqual(1, node.Id);
      Assert.AreEqual("Завод1", node.Name);
    }


    * This source code was highlighted with Source Code Highlighter.


    Разработанная сущность NodeResolver получилась такой:

    /// <summary>
    /// Сервис определения завода
    /// </summary>
    public class NodeResolver : INodeResolver
    {
      public NodeResolver(ITopologyService topologyService)
      {
        Guard.ArgumentNotNull(topologyService, "service");
        _data = topologyService.GetTopology(EmptyFilter.Instance);
      }

      #region INodeResolver Members

      public Node FindById(int id)
      {
        // f(x)
        return new Node { Id = id, Name = _data.Node.FindByID(id).Description };
      }

      #endregion

      private TopologyData _data;
    }


    * This source code was highlighted with Source Code Highlighter.


    Выводы


    Пожалуй, наиболее очевидным преимуществом предложенного метода перед обычным методом написания тестов (когда сначала выполняется 4, а потом реализуется f(x)), является экономия времени и «размазанность» разработки. Программисту теперь не приходится тратить время на код, который непосредственно не относится к функциональности программы. Он пишет код совместно с тестом, делая из двух зайцев одного (сам рефакторинг — это o-малое, которое можно отбросить).

    Спасибо за внимание.
    Поделиться публикацией

    Комментарии 17

      –5
      Давно хотел заняться юнит тестированием, но как-то руки не доходили. Ждем продолжение…
        +4
        :) Какого продолжения вы ждете?:) В статье предложена методика разработки юниттестов — если она вам понравилась, берите и используйте на здоровье.

        Если и будет какое-то продолжение, то исключительно по возможностям мок-фреймворка Rhino Mocks.
        +1
        Эх… только единицы разработчиков в полной мере пользуются TDD :( Возможно, если бы все руководствовались бы такой методикой, то дырявого ПО было бы гораздо меньше.
          0
          Я стараюсь сделать мир лучше:) Вот своих коллег-разработчиков постоянно идеологически обрабатываю. Вот еще и статью написал — может, поможет кому:)
          0
          Подготовить какую никакую матбазу и основываясь на ней делать дальнейшие выводы — это сильно и неожиданно.
            0
            А по-моему это должно быть стандартным подходом. Осознал, сформулировал, доказал (обосновал) — затем уже сделал. А не «показалось — сделал — переделал — переделал — переделал».
              +1
              тут скорей не матбаза, а запись обывательских рассуждений матсимволами
                0
                Хм. Это хорошо, плохо? Что плохого в том, что эти «матсимволы» помогли мне найти результат и определить методику?
                  0
                  это нормально
                  если насобачиться, лаконичнее получается
                  но благодарных слушателей станет меньше
                    0
                    Специально для благодарных слушателей приводится интересный пример:) Спасибо за отклик, btw
                    0
                    Это хорошо, но специфично. Все же люди как то больше ложками и поварешками мыслят.
                0
                Статью Вашу не понял, но посмотрите QuickCheck.

                Там небольшой DSL для
                — генерации входных данных
                — указания свойств функции
                — автоматического проведения тестов
                  0
                  Спасибо, погляжу:)
                  0
                  $a(z_0)$ или $a(h, x_0, z_0)$?
                  т.е. ассерт зависит только от конечного результата?
                    0
                    Я привел первый случай, потому как важно определить конечный результат. Почему важно? Чтобы не допустить ошибку в логике f(x). $a(h, x_0, z_0)$ — мне, если честно, не совсем понятен смысл этой конструкции. Дополнительные ассерты всего и вся? В том числе и функциональности теста? Чтож, можно и так — добавляется пятый шаг, на котором программист «усиляет» проверки (если они уместны).
                      0
                      правильней так было бы: $a_{h}(x_0, z_0)$

                      потому что функция проверки корректности зависит от проверяемого отображения, а результат её выполнения — ещё и от входных и выходных данных
                    0
                    Хм. Результат выполнения выдает z0. Т.е. опять же, h(x0) = z0 — это следует из определения функции, а сама конструкция определяет юнит-тест. Поэтому в конечном итоге все равно требуется только a(z0). Если честно, я не вижу смысла писать ассерты на код в h(x) — опять, из выведенного в статье h(x) = f(m(x)), а моки — не изменяют данные. Посмотрите в код, шаг 3, определяется мок, выдающий данные через заглушку ITopologyService. Он не изменяет данные, он их выдает в тестируемый класс как результат вызова метода ITopologyService.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое