Статья «Скользящая ответственность паттерна Репозиторий» подняла несколько вопросов, на которые очень сложно дать ответ. Нужен ли репозиторий, если абстрагироваться от технических деталей полностью невозможно? На сколько сложным репозиторий может быть, чтобы его написание оставалось целесообразным? Ответ на эти вопросы различается в зависимости от акцента, который делается при разработке систем. Наверно, самый сложный вопрос: нужен ли, вообще, репозиторий? Проблема «текучей абстракции» и рост сложности кодирования с увеличением уровня абстракции не позволяют найти решение, которое удовлетворяло бы оба лагеря. Например, в репортинге intention design приводит к созданию большого числа методов для каждого фильтра и сортировки, а generic решение создает большой оверхед по кодированию. Продолжать можно бесконечно…

Для более полного представления я взглянул на проблему абстракций со стороны применения их в уже готовом коде, в legacy code. Репозиторий, в таком случае, нас интересует только, как инструмент для достижения качественного и безбажного кода. Конечно, этот паттерн — не единственное, что необходимо для применения TDD практик. Наевшись «невкусной еды» в нескольких больших проектах и наблюдая за тем, что работает, а что нет, я вывел для себя несколько правил, которые мне помогают следовать TDD практикам. С удовольствием выслушаю конструтктивную критику и иные приёмы внедрения TDD.

Предисловие


Некоторые могут заметить, что в старом проекте применить TDD невозможно. Существует мнение, что для них больше подходят разные виды интеграционных тестов (UI-тесты, end-to-end), т.к. разобраться в старом коде слишком сложно. Так же, можно услышать, что написание тестов перед самим кодированием приводит только к потере времени, т.к. мы можем не знать, как будет работать код. Мне приходилось работать в нескольких проектах, где ограничивались только интеграционными тестами, считая, что юнит-тесты не показательны. При этом писалось очень много тестов, они запускали кучу сервисов и пр. и пр. В итоге разобраться в этом мог только один человек, который их, собственно, и написал.

За свою практику я успел поработать в нескольких очень крупных проектах, где было очень много legacy code. В одних были тесты, в других только собирались это внедрять. Мне самому удалось поднять 2 больших проекта. И везде я так или иначе пытался применять TDD подход. На начальных этапах понимания TDD воспринимался, как Test First development. Но чем дальше, тем отчетливее были видны отличия между этим упрощенным пониманием и нынешнем представлением, называемым коротко BDD. Какой бы язык не использовался, основные моменты, которые я назвал правилами, остаются похожими. Кто-то может найти параллели между правилами и другими принципами написания хорошего кода.

Правило 1: Используем Bottom-Up (Inside-Out)


Это правило больше относится к способу анализа и дизайна ПО при встраивании новых кусков кода в уже работающий проект.

Когда вы проектируете новый проект, абсолютно естественно представлять систему целиком. На этом этапе вы контролируете и набор компонентов, и будущую гибкость архитектуры. Поэтому можете писать модули, которые удобным и лучшим образом интегрируются друг с другом. Такой Top-Down подход позволяет выполнить хороший upfront дизайн будущей архитектуры, описать необходимые гуайдлайны и иметь целостное представление того, что, в итоге, хочется. Через некоторое время проект превращается в то, что называют legacy code. И тут начинается самое интересное.

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

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

Правило 2: Тестируйте только изменённый код


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

Пример


Существует модуль online-магазина, который создает корзину из выбранных элементов и сохраняет её в базу. Конкретная реализация нас не волнует. Как сделано, так сделано — это legacy code. Теперь нам необходимо внедрить сюда новое поведение: отправлять уведомление в бухгалтерию в случае, когда стоимость корзины превышает 1000$. Вот такой код мы видим. Как внедрить изменение?

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        var items = LoadSelectedItemsFromDb();
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);
        SaveToDb(cart);
    }
}

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

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        var items = LoadSelectedItemsFromDb();
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);

        // NEW FEATURE
        new EuropeShopNotifier().Send(cart);

        SaveToDb(cart);
    }
}

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

Правило 3: Тестируем только требования


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

Так же представьте, как другие классы, являющиеся клиентами вашего модуля, будут с ним взаимодействовать. Нужно ли им писать 10 строк кода, чтобы настроить ваш модуль? Чем проще будут коммуникации между частями системы, тем лучше. Поэтому из старого кода лучше выделять модули, ответственные за что-то конкретное. Тут вам на помощь придёт SOLID.

Пример


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

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        // 1) load from DB
        var items = LoadSelectedItemsFromDb();

        // 2) Tax-object creates SaleItem and
        // 4) goes through items and apply taxes
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();

        // 3) creates a cart and 4) applies taxes
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);

        new EuropeShopNotifier().Send(cart);

        // 4) store to DB
        SaveToDb(cart);
    }
}

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

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        // 1) extracted to a repository
        var itemsRepository = new ItemsRepository();
        var items = itemsRepository.LoadSelectedItems();
			
        // 2) extracted to a mapper
        var saleItems = items.ConvertToSaleItems();
			
        // 3) still creates a cart
        var cart = new Cart();
        cart.Add(saleItems);
			
        // 4) all routines to apply taxes are extracted to the Tax-object
        new EuropeTaxes().ApplyTaxes(cart);
			
        new EuropeShopNotifier().Send(cart);
			
        // 5) extracted to a repository
        itemsRepository.Save(cart);
    }
}

Что касается тестов, то можно обойтись данными сценариями. Пока их реализация нас не интересует.

public class EuropeTaxesTests
{
    public void Should_not_fail_for_null() { }

    public void Should_apply_taxes_to_items() { }

    public void Should_apply_taxes_to_whole_cart() { }

    public void Should_apply_taxes_to_whole_cart_and_change_items() { }
}

public class EuropeShopNotifierTests
{
    public void Should_not_send_when_less_or_equals_to_1000() { }

    public void Should_send_when_greater_than_1000() { }

    public void Should_raise_exception_when_cannot_send() { }
}

Правило 4: Добавляем только протестированный код


Как я уже писал выше, следует минимизировать изменения в старый код. Чтобы это сделать, старый и новый/модифицированный код можно разделить. Новый код можно выделить в методы, работу которых можно проверить юнит тестами. Такой подход поможет уменьшить связанные риски. Существует две техники, которые были описаны в книге «Working Effectively with Legacy Code» (ссылка на книгу ниже).

Sprout method/class — эта техника позволяет встроить очень безопасно новый код в старый. То, как я добавил нотификатор и является примером данного подхода.

Wrap method — несколько посложнее, но суть такая же. Подходит не всегда, а только в случаях когда новый код вызывается до/после старого. При выделении ответственностей два вызова метода ApplyTaxes заменились одним вызовом. Для этого надо было поменять второй метод так, чтобы логика работы не сломалась сильно и её можно было проверить. Вот так выглядел класс до изменений.

public class EuropeTaxes : Taxes
{
    internal override SaleItem ApplyTaxes(Item item)
    {
        var saleItem = new SaleItem(item)
        {
            SalePrice = item.Price*1.2m
        };
        return saleItem;
    }

    internal override void ApplyTaxes(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return;
        var exclusion = 30m/cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

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

public class EuropeTaxes : Taxes
{
    internal override void ApplyTaxes(Cart cart)
    {
        ApplyToItems(cart);
        ApplyToCart(cart);
    }

    private void ApplyToItems(Cart cart)
    {
        foreach (var item in cart.SaleItems)
            item.SalePrice = item.Price*1.2m;
    }

    private void ApplyToCart(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return;
        var exclusion = 30m / cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

Правило 5: «Ломаем» скрытые зависимости


Это правило о самом большом зле в старом коде: об использовании оператора new внутри метода одного BO для создания других BO, репозиториев или других непростых объектов. Почему это плохо? Самое простое объяснение: это делает части системы сильно связанными и способствует уменьшению их согласованности. Еще короче: приводит к нарушению принципа «low coupling, high cohesion». Если взглянуть с другой стороны, то такой код слишком сложно будет выделить в отдельный, независимый инструмент. Избавиться от таких скрытых зависимостей за раз очень трудоёмко. Но это можно делать постепенно.

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

Во-вторых, переменные, хранящие инстанс внешнего BO/репозитория, должны иметь абстрактный тип, а лучше интерфейс. Интерфейс лучше, т.к. сильнее развязывает руки разработчику. В конечном итоге, это позволит сделать из модуля атомарный инструмент.

В-третьих, не оставляйте большие методы-простыни. Это явный признак того, что метод делает больше, чем указано в его названии. И следом это свидетельствует о возможном нарушении SOLID, Закона Деметры. А так же логики и Земного порядка.

Пример


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

public class EuropeShop : Shop
{
    private readonly IItemsRepository _itemsRepository;
    private readonly Taxes.Taxes _europeTaxes;
    private readonly INotifier _europeShopNotifier;

    public EuropeShop()
    {
        _itemsRepository = new ItemsRepository();
        _europeTaxes = new EuropeTaxes();
        _europeShopNotifier = new EuropeShopNotifier();
    }

    public override void CreateSale()
    {
        var items = _itemsRepository.LoadSelectedItems();
        var saleItems = items.ConvertToSaleItems();

        var cart = new Cart();
        cart.Add(saleItems);

        _europeTaxes.ApplyTaxes(cart);
        _europeShopNotifier.Send(cart);
        _itemsRepository.Save(cart);
    }
}

Правило 6: Чем меньше больших тестов, тем лучше


Большие тесты — это различные интеграционные тесты, которыми пытаются тестировать пользовательские сценарии. Несомненно, они важные, но проверять ими логику некоторого IF в глубине кода очень затратно. В итоге только один девелопер, только в специальном костюме, обложившись оберегами, сможет там что-то менять. Написание такого теста требует столько же времени, если не больше, как и написание самого функционала. Поддержка их — это как еще один legacy code, который страшно менять. При этом это всего лишь тесты!

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

Правило 7: Не тестируем private методы


Если вдруг вам захотелось протестировать приватный метод, то, видимо, вы истосковались по костылям. Некоторые не видят в этом ничего плохого. Но давайте посмотрим на причины вашей «хотелки». Приватный метод может быть слишком сложным или содержать код, который не вызывается из публичных методов. Уверен, что любая другая причина, которую можно придумать, окажется характеристикой «плохого» кода или дизайна. Скорее всего, часть кода из приватного метода должна выделиться в отдельный метод/класс. Проверьте не нарушается ли первый принцип SOLID? Эта первая причина почему так не стоит делать. Вторая — это то, что таким образом вы проверяете не поведение всего модуля, а то, как он это делает. Внутренняя реализация может меняться независимо от поведения модуля. Поэтому в таком случае вы получаете хрупкие тесты, на поддержку которые тратится больше времени, чем необходимо.

Чтобы избежать необходимости тестировать приватные методы, представте свои классы, как набор атомарных инструментов, об устройстве которых вы не знаете ничего. Вы ожидаете некоторое поведение, которое и тестируете. Этот взгляд справедлив и для классов в рамках ассембли. Классы, доступные клиентам (из других ассембли) будут паблик, а те, что выполняют внутреннюю работу private. Хотя, отличие от методов есть. Классы внутреннего назначения могут быть сложными, поэтому их можно сделать internal и тоже тестировать.

Пример


Например, чтобы протестировать одно условия в приватном методе класса EuropeTaxes я не буду писать тест на этот метод. Я буду ожидать, что налоги будут применяться определенным образом, поэтому тест будет отражать именно это. В самом тесте я посчитал ручками что должно получиться, взял это за эталон и ожидаю того же результата от класса.

public class EuropeTaxes : Taxes
{
    // code skipped

    private void ApplyToCart(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return; // <<< I WANT TO TEST THIS CONDIFTION
        var exclusion = 30m / cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

// test suite
public class EuropeTaxesTests
{
    // code skipped

    [Fact]
    public void Should_apply_taxes_to_cart_greater_300()
    {
        #region arrange
        // list of items which will create a cart greater 300
        var saleItems = new List<Item>(new[]{new Item {Price = 83.34m},
            new Item {Price = 83.34m},new Item {Price = 83.34m}})
            .ConvertToSaleItems();
        var cart = new Cart();
        cart.Add(saleItems);

        const decimal expected = 83.34m*3*1.2m;
        #endregion

        // act
        new EuropeTaxes().ApplyTaxes(cart);

        // assert
        Assert.Equal(expected, cart.TotalSalePrice);
    }
}

Правило 8: Не тестируем алгоритм методов


Тут неудачно подобрано название правила, но лучшего пока не придумал. Среди «мокистов» (это те, кто мокает в тестах) есть те, кто проверяет количество вызовов определенных методов, верифицирует сам вызов и пр. Другими словами, занимается проверкой внутренней работы методов. Это так же плохо, как и тестирование приватных. Разница только в уровне применения такой проверки. Такой подход опять дает множество хрупких тестов, из-за чего TDD некоторыми не воспринимается нормально.

Правило 9: Не модифицируем legacy code без тестов


Это самое главное правило, т.к. отражает желание команды следовать такому пути. Без желания двигаться в этом направлении всё, о чем было сказано выше, смысла особого не имеет. Т.к. если девелопер не хочет применять TDD (не понимает его смысл, не видит пользы и пр.), то реальная его польза будет размываться постоянным обсуждением как это тяжело и неэффективно.

Если вы собрались применять TDD, обсудите в команде, добавьте в Definition of Done и применяйте. Сначала будет тяжело, как со всем новым. Как и любое искусство, TDD требует постоянной практики, а удовольствие приходит по мере обучения. Постепенно, написанных юнит тестов станет много, вы начнете чувствовать здоровье вашей системы и начнете ценить простоту написания кода, описывая на первом этапе требования. Есть исследования TDD, проведенные на реальных больших проектах в Microsoft и IBM, показывающие уменьшение багов в продакшн системах от 40% до 80%. (см. ссылку ниже).

Дополнительно


  1. Book “Working Effectively with Legacy Code” by Michael Feathers
  2. TDD when up to your neck in Legacy Code
  3. Breaking Hidden Dependencies
  4. The Legacy Code Lifecycle
  5. Should you unit test private methods on a class?
  6. Unit testing internals
  7. 5 Common Misconceptions About TDD & Unit Tests
  8. Intro to Unit Testing 5: Invading Legacy Code in the Name of Testability
  9. Law of Demeter
Поделиться с друзьями
-->

Комментарии (86)


  1. areht
    30.08.2016 22:01
    +2

    > Среди «мокистов» (это те, кто мокает в тестах) есть те, кто проверяет количество вызовов определенных методов, верифицирует сам вызов и пр. Другими словами, занимается проверкой внутренней работы методов. Это так же плохо, как и тестирование приватных.

    > Очень хорошо помогает поставить мозги на нужные рельсы подход BDD.

    Так как вы тестируете, что CreateSale вызывает notifier?


    1. ApeCoder
      31.08.2016 08:33

      Я бы использовал peel and slice — notifier должен передаваться либо как аргумент конструктора с дефолтным значением либо как свойство


      1. ApeCoder
        31.08.2016 08:48

        ```С#


            public class EuropeShop : Shop
        {
            private readonly IItemsRepository _itemsRepository;
            private readonly Taxes.Taxes _europeTaxes;
            private readonly INotifier _europeShopNotifier;
        
            public EuropeShop(INotifier notifier = new EuropeShopNotifier())
            {
                _itemsRepository = new ItemsRepository();
                _europeTaxes = new EuropeTaxes();
                _europeShopNotifier = notifier;
            }
           ...
        }
            ...
            [Fact]
        void createSaleShouldSendNotification()
        {
            var notifier = new InMemoryNotificationRecorder();
            var subject = new EuropeShop(notifier);
            subject.CreateSale();
            notifier.Should().Contain(notification => notification.Price == expectedPrice);
        }


        1. Serg046
          31.08.2016 18:07

          class Person
          {
              Car UpgradeCar(Enhancement enhancement)
              {
                  if (_money < enhancement.Cost)
                      throw ...;
          
                  _money -= enhancement.Cost;
                  enhancement.Upgrade(_car);
          
                  return _car;
              }
          }
          


          Допустим класс `Car` имеет достаточно св-в, чтобы определить что именно поменялось и допустим у нас конкретный `enhancement` (что вряд ли), как тестировать? Проверять что поменялось у Car? Но ведь у нас уже есть тесты для того конкретного `enhancement`, которые делают это же самое.
          Или оставить только проверку «на деньги» и не проверять апгрейд вообще? Но тогда как быть уверенным, что у нас в результате будет обновленная машина?


          1. ApeCoder
            01.09.2016 09:34

            Сделать recording enhancement который будет записывать факт применения enhancement внутри себя. Вне зависимости от того, сколькими и какими вызовами это было сделано.


            1. Serg046
              01.09.2016 14:21
              +1

              var enhancementMock = new Mock<Enhancement>();
              var person = new Person();
              person.UpgradeCar(enhancementMock.Object);
              enhancementMock.Verify(e => e.Upgrade(It.IsAny<Car>()), Times.AtLeastOnce);
              


              Какая тогда принципиальная разница? Ведь при рефакторинге все равно придется recording enhancement менять если мы его используем. Много тестов завязанных на метод упадет? Так нужно более устойчивые абстракции проектировать. К тому же, конкретно я, не вижу много тестов проверяющих вызов одного и того же метода.

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

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


              1. ApeCoder
                01.09.2016 15:06

                В вашем случае не описано, что произойдет, когда дернут метод Cost — что будет в тесте?


                var enhancement = new RecordingEnhancement();
                var subject  = new Person();
                subject.UpgradeCar(ehnahncement);
                AssertTrue(enhachement.WasAppliedTo(Person.Car));
                
                class RecordingEnhancement : Enhancement {
                     Car subject;
                     Money cost;
                     RecordingEnhancement(Money cost = 0)
                     {
                          this.cost = cost;
                     }
                
                     Maoney  cost{get{ return cost; }}
                
                     void Upgrade(Car car)
                     {
                            subject = car;
                     }
                
                     Boolean WasAppliedTo(Car car)
                     {
                            return subject = car;
                     }
                }

                Я вижу следующие достоинства:


                • У нас целостная абстракция — компилятор нас заставит определить Cost и этот тест не перестанет валиться потому, что вдруг кому-то понадобилось дернуть Cost
                • Есть отдельное описание того, что такое Ehancement и что такое факт его применения — то есть если протокол применения изменится, надо это будет учесть только в одном месте
                • Лишние подробности убраны из теста в отдельный объект

                И это не проверка внутренней работы метода. Контракт метода, "если мне дали улучшение, я его применю" — мы ему даем улучшение и проверяем, что оно применено.


                Что именно значит "применить улучшение" и "проверить что улучшение применено" является внутренним делом улучшения, ведь так?


                1. Serg046
                  01.09.2016 15:41

                  В вашем случае не описано, что произойдет, когда дернут метод Cost — что будет в тесте?

                  Не совсем понял. Для денег будут отдельные тесты.


                  У нас целостная абстракция — компилятор нас заставит определить Cost и этот тест не перестанет валиться потому, что вдруг кому-то понадобилось дернуть Cost

                  Для меня это не достоинство, лишние знания. Но даже если и считать это минусом это решается элементарно. Например в том же moq такое поведение по умолчанию, а чтобы заставить мок упасть нужно уже передавать MockBehavior.Strict.


                  Есть отдельное описание того, что такое Ehancement и что такое факт его применения — то есть если протокол применения изменится, надо это будет учесть только в одном месте

                  Да, это мне нравится, но на мой вкус минусы перевешивают.
                  И, как уже писал, конкретно я, не вижу много тестов проверяющих вызов одного и того же метода (из разных тестов).


                  Лишние подробности убраны из теста в отдельный объект

                  1. Обычно нет необходимости дублировать много зависимостей (даже если дизайн не очень хорош), так что это скорее минус. Уходит наглядность, которая очень удобна.
                  2. Как уже писал, в случае с рекордером, во-первых, нужно его создать и поддерживать. Во-вторых, у нас либо один рекордер для типа и тогда у нас много лишней инфы при тесте, либо много самих рекордеров для разных методов типа.

                  И это не проверка внутренней работы метода. Контракт метода, «если мне дали улучшение, я его применю» — мы ему даем улучшение и проверяем, что оно применено.

                  Мок вариант делает тоже самое, в конечном счете оба подхода прибиты к Enhancement.Upgrade(...), т.е. к конкретному механизму внутри метода.


                  1. ApeCoder
                    01.09.2016 16:43

                    Да, это мне нравится, но на мой вкус минусы перевешивают.
                    И, как уже писал, конкретно я, не вижу много тестов проверяющих вызов одного и того же метода (из разных тестов).

                    Они могут косвенно использовать метод но не проверять его. Например, если добавится запись названия улучшения в какой-то журнал И название будет обязательным, придется обходить все такие тесты и впиливать туда название. Или moq как-то считает что свойство ненулевое и его присвоит?


                    Лишние подробности убраны из теста в отдельный объект


                    Обычно нет необходимости дублировать много зависимостей (даже если дизайн не очень хорош), так что это скорее минус. Уходит наглядность, которая очень удобна.

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


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

                    В принципе, количество инфы в теле теста можно выносить в какие-то билдеры/фектори методы.


                    Мок вариант делает тоже самое, в конечном счете оба подхода прибиты к Enhancement.Upgrade(...), т.е. к конкретному механизму внутри метода.

                    В конечном счете все сводится к смене ориентации магнитных доменов на жестком диске :)


                    Спасибо за обсуждение. Мне надо еще подумать и почитать. Некоторые части концепции #NoMocks я додумал, возможно неправильно — надо это подробнее изучить.


                    1. Serg046
                      01.09.2016 16:53
                      +1

                      Или moq как-то считает что свойство ненулевое и его присвоит?

                      Да он много что умеет. К тому же никто не запрещает его расширять или использовать другой инструмент.


                      P.S. согласен, наверное здесь уже нечего обсуждать.


                1. lair
                  01.09.2016 15:43

                  В вашем случае не описано, что произойдет, когда дернут метод Cost — что будет в тесте?

                  Вернется дефолтное значение.


                  Что именно значит "применить улучшение" и "проверить что улучшение применено" является внутренним делом улучшения, ведь так?

                  Нет. "Применить улучшение" — это операция, описанная в контракте улучшения, и с точки зрения любого потребителя "применить улучшение" — это вызвать эту операцию. Соответственно, при тестировании такого потребителя, проверка, что улучшение применено — это проверка, что эта операция проведена.


                  1. ApeCoder
                    01.09.2016 16:18

                    Нет. "Применить улучшение" — это операция, описанная в контракте улучшения, и с точки зрения любого потребителя "применить улучшение" — это вызвать эту операцию

                    Может ли такое быть, что в будущем контракт изменится? Например добавитья "асинхронное применение улучшения" и т.д.


                    Я бы сказал, что мой подход это как использование Path.Combine вместо X + "\" + Y — и то и другое сводится к конкатенации но сама по себе формулировка требований более устойчива.


                    1. lair
                      01.09.2016 16:23

                      Может ли такое быть, что в будущем контракт изменится? Например добавитья "асинхронное применение улучшения" и т.д.

                      Может. Но изменение контракта — это (потенциальное) изменение всех потребителей (и, по большому счету, требований к ним).


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


                      Я бы сказал, что мой подход это как использование Path.Combine вместо X + "\" + Y — и то и другое сводится к конкатенации но сама по себе формулировка требований более устойчива.

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


                      1. ApeCoder
                        01.09.2016 16:50

                        С моей точки зрения тесты стали некорректны — они стали перестали описывать требования корректно.


                        С моей точки зрения хорошо, что компилятор нашел их некорректность.


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


                        1. lair
                          01.09.2016 17:10

                          они стали перестали описывать требования корректно.

                          Это зависит от того, что именно было в требованиях.


                          С моей точки зрения хорошо, что компилятор нашел их некорректность.

                          То есть вы считаете, что хорошо, что программист добавил новый метод, и тесты (даже те, которые вообще не связаны с этим методом) посыпались? По мне, так это просто яркий пример хрупких тестов.


                          1. ApeCoder
                            01.09.2016 17:24

                            Это зависит от того, что именно было в требованиях

                            Обычно требования фурмулируются высокоуровнево типа "Если человеку предоставили способ обновить машину и у него хватает денег, то он применет этот способ" по идее тесты не должны сыпаться от того, каким именно образом человек применяет его


                            То есть вы считаете, что хорошо, что программист добавил новый метод, и тесты (даже те, которые вообще не связаны с этим методом) посыпались? По мне, так это просто яркий пример хрупких тестов.

                            Мне кажется это скорее строгость а не хрупкость — они не падют непонятно от чего, а просто напоминают мне, что я не подумал о чем-то.


                            1. lair
                              01.09.2016 17:47

                              Обычно требования фурмулируются высокоуровнево типа "Если человеку предоставили способ обновить машину и у него хватает денег, то он применет этот способ" по идее тесты не должны сыпаться от того, каким именно образом человек применяет его

                              Это требования к фиче. Из них юнит-тесты писать не надо (обычно). Требования, из которых вырастают тесты, обычно возникают на уровне дизайна.


                              Мне кажется это скорее строгость а не хрупкость — они не падют непонятно от чего, а просто напоминают мне, что я не подумал о чем-то.

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


                              1. ApeCoder
                                01.09.2016 19:02

                                Это требования к фиче. Из них юнит-тесты писать не надо (обычно). Требования, из которых вырастают тесты, обычно возникают на уровне дизайна.

                                С моей точки зрения требования к этому уровню абстракции должны быть близки к требованию к фиче


                                Особенно это хорошо заметно, если интерфейс в одном компоненте, а тесты — в другом, и их авторы вообще никак не связаны.

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


                                1. Передумать его вводить (например завести другой интерфейс и не заставлять людей его имплементировать)
                                2. Разобраться в коде и реализовать
                                3. Написать NotImplementedException
                                4. Заглушки, возвращающие пустые значения
                                5. Прочее

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


                                То есть, по сравнению с вариантами с моками больше выбора и решение более осознанное. Те же самые гарантии как и для production кода (кстати, интересно подумать на тему, почему в prod не используются умолчания типа "Если в объекте нету метода верни пустое значение" — даже в Смолтоке, насколько я помню).


                                1. lair
                                  01.09.2016 19:15

                                  С моей точки зрения требования к этому уровню абстракции должны быть близки к требованию к фиче

                                  Чаще всего это не позволяет писать тесты — в том смысле, что кто-то все равно декомпонует требования до уровня дизайна при написании assertions.


                                  Допустим я ввожу новй метод в интерфейс, компилирую и вижу, что в тестовом дубле он не реализован.

                                  Или не видите, потому что тестовый модуль вам недоступен (он в другом проекте, который просто использует ваш интерфейс).


                                  То есть, по сравнению с вариантами с моками больше выбора

                                  Ваш объект и есть мок, поэтому противопоставление некорректно. Если вы имеете в виду "по сравнению с ad-hoc-моками", то все описанные вами варианты там тоже доступны — просто вы не узнаете о том, что кто-то добавил в интерфейс новый метод. А должны ли?


                                  Мезарос:


                                  Symptoms

                                  We have one or more tests that used to run and pass but now either fail to compile and run or fail when they are run [...] When we don't think the change should have affected the tests that are failing or we haven't changed any production code or tests, we have a case of Fragile Tests.

                                  Impact

                                  Fragile Tests increase the cost of test maintenance by forcing us to visit many more tests each time we modify the functionality of the system or the fixture.

                                  Узнаете? То, за что вы ратуете — это очень специфический случай Overspecified software (которая проблема, кстати, регулярно ассоциируется именно с моками), когда тест зависит не только от того, что ему реально нужно знать, но и от каких-то вещей, которые для него избыточны.


                                  кстати, интересно подумать на тему, почему в prod не используются умолчания типа "Если в объекте нету метода верни пустое значение"

                                  Потому что для production они могут быть опасны.


                                  (заметим, что в конкретном Moq, где это умолчание есть, оно полностью контролируемо).


                                  1. ApeCoder
                                    01.09.2016 19:52

                                    Чаще всего это не позволяет писать тесты — в том смысле, что кто-то все равно декомпонует требования до уровня дизайна при написании assertions.

                                    С моей точки зрения надо стремиться абстрагироваться от этого — детали реализации загонять на уровень ниже custom assertions и т.д. чтобы был виден intention.


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


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


                                    When we don't think the change should have affected the tests that are failing or we haven't changed any production code or tests

                                    Production code изменился, и добавление метода это ломающее изменение для любого кода который имплементирует интерфейс.


                                    1. lair
                                      01.09.2016 21:19

                                      С моей точки зрения надо стремиться абстрагироваться от этого — детали реализации загонять на уровень ниже custom assertions и т.д. чтобы был виден intention.

                                      Кто-то все равно должен это сделать — разбить intent на конкретные операции и проверки.


                                      В любом случае контракт метода не "я буду работать если мне передадут способ улучшения автомобиля" а не "я буду работать, если мне передадут способ улучшения автомобиля, но только если работают методы X и Y и я не обязуюсь дергать никакой метод кроме них"

                                      А откуда вы взяли этот второй "контракт"? Вообще-то, разумно ожидать, что "мне передадут способ улучшения автомобиля, который работает в рамках своего контракта" — а современные автогенераторы моков становятся все лучше и лучше в генерации под контракт.


                                      он требует какой-то другой интерфейс являщийся подмножеством исходного.

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


                                      Если мы его реализуем моком,

                                      Еще раз: то, что у вас — это и есть мок.


                                      Production code изменился, и добавление метода это ломающее изменение для любого кода который имплементирует интерфейс.

                                      Мой (как автора SUT) продакшн-код не изменился. Более того, весь мой продакшн-код корректно работает. Сломались только и исключительно тесты, дав мне false positive — что и является запахом ("When we don't think the change should have affected the tests that are failing"). Никакой пользы мой продакшн-код от этого поведения тестов не получил.


                                      1. ApeCoder
                                        02.09.2016 09:48

                                        Кто-то все равно должен это сделать — разбить intent на конкретные операции и проверки.

                                        Да. В моем случае тест должен содержать intent а детальные операции и проверки должны быть уровнем ниже. То есть, если абстрактная формулировка требует, чтобы при получении корректного способа апгрейда и достаточности средств он должен быть применен, то "корректный сопособ апгрейда" и "проверка, что способ применен" должно быть сформулировано отдельно.


                                        А откуда вы взяли этот второй "контракт"?

                                        Если объект x содержит метод m требующий реализации подмножества y интерфейса z то логично назвать это подмножество каким-то именем и написать именно его в требованиях m — нет?


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


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

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


                                        Еще раз: то, что у вас — это и есть мок.

                                        Мне надо перечитать, но, насколько я понял по Месзаросу мок должен содержать assertions. У меня он их не содержит — он просто готов сообщить внешнему миру о каких-то обобщенных свойствах. Я постараюсь перечитать его и то, что читал по #NoMocks в ближайшие дни, возможно я что-то не так понял или додумал, спасибо вам за внимательность.


                                        Мой (как автора SUT) продакшн-код не изменился. Более того, весь мой продакшн-код корректно работает. Сломались только и исключительно тесты, дав мне false positive — что и является запахом ("When we don't think the change should have affected the tests that are failing"). Никакой пользы мой продакшн-код от этого поведения тестов не получил.

                                        Мне кажется, я на это уже отвечал. Я понимаю вашу точку зрения.


                                        1. lair
                                          02.09.2016 11:36

                                          "корректный сопособ апгрейда" и "проверка, что способ применен" должно быть сформулировано отдельно.

                                          Кем? Где?


                                          Если объект x содержит метод m требующий реализации подмножества y интерфейса z то логично назвать это подмножество каким-то именем и написать именно его в требованиях m — нет?

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


                                          (и это мы еще не затрагиваем проблемы именования этих подмножеств)


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

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


                                          В нашем случае системы типов достаточно.

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


                                          Мне надо перечитать, но, насколько я понял по Месзаросу мок должен содержать assertions. У меня он их не содержит — он просто готов сообщить внешнему миру о каких-то обобщенных свойствах

                                          Да, я не прав, в этом конкретном примере у вас test spy. Но, что важно: то, что сгенерил Moq (в этом примере) — тоже test spy. Поэтому разницы все равно нет.


      1. areht
        31.08.2016 08:49

        Я не про DI. Я про тест, который работает без «верифицирует сам вызов». По вашей ссылке ниже warehouseControl.verify() верифицирует сам вызов.

        notifier.Should().Contain(notification => notification.Price == expectedPrice), видимо, тоже.


        1. ApeCoder
          31.08.2016 08:59

          InMemoryNotificationRecorder это InMemory реализайия INotifier которая кладет в себя нотификейшены.


          Should.COntain — это из Fluent assertions


          Его можно со всеми коллекциями употреблять


          new[] { 1, 2, 3 }.Should().Contain(item => item > 3, "at least {0} item should be larger than 3", 1);

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


          1. areht
            31.08.2016 10:03
            +1

            Что-то я не вижу где счастье… Грабли там же, где и с верификацией.

            я вижу 2 варианта:
            1) INotifier примитивный, состоит из одного метода. Оверхед, мало толку.
            2) INotifier состоит из 52-х методов, которые, в зависимости от метода и параметров, формируют сообщения разных типов и раскладывают их по разным коллекциям разных типов (InMemoryNotificationRecorder: ArrayList — это же интереснее).
            2.1) InMemoryNotificationRecorder реализует все 52 метода, в основном копипастой.
            2.2) Декомпозируем, тогда внутри есть INotificationSender, тогда нам нужен InMemoryNotificationSenderRecorder, который мы, с десятком других каких-то стабов, создадим в тесте и все передадим в EuropeShopNotifier и получим интеграционный тест.
            Ну и переписывание стабов при переходе из 1 в 2 — удовольствие так себе.


            1. ApeCoder
              31.08.2016 10:05
              +1

              1) Размер оверхеда?
              2.1) red — green — refactor
              2.2) Resharper


              1. areht
                31.08.2016 10:28

                1) InMemoryNotificationRecorder
                2.1) red — green —? Копипасту боевого кода с тестовым обобщать? Будет 2.2.
                2.2) но в тестах то сплошной бойлерплейт, да и тесты интеграционные.


                1. ApeCoder
                  31.08.2016 10:48

                  1) Это не размер, это имя. Размер должен быть какой-то метрикой. Причем оверхед посравнению с моком.
                  2.1) Да, все правильно будет 2.2 сделанный постепенно
                  2.2) И что — мы рефакторим Notifier а не тесты.
                  Если делать нотифаер моком, то тест будет хрупкий — зависеть от последовательности вызова методов и прочее.
                  Если делать его фейком, то будет стабильная абстракция и тесты не будут валиться при эквивалетных преобразованиях.


                  Теперь давайте задумаемся, почему мы не мокаем System.String?


                  Мой ответ такой, что для теста нам нужна полная но самая простая реализация зависимости. Если самая простая реализация зависимости уже есть в production, то можно использовать ее.


                  Арло Белши, например, где-то писал что фейки могут понадобитьяс в продакшене (например можно собрать какой-нибудь буфер нотификаций используя InMemoryNotificationSender)


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


                  1. lair
                    31.08.2016 11:08

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

                    "A Fake Object is quite different from a Test Stub or a Mock Object in that it is neither directly controlled nor observed by the test. The Fake Object is used to replace the functionality of the real DOC in a test for reasons other than verification of indirect inputs and outputs."


                    (Gerard Meszaros, xUnit Test Patterns: Refactoring Test Code, Chapter 11: Using Test Doubles)


                    1. ApeCoder
                      31.08.2016 11:38

                      Спасибо, я смотрел терминологию у Фаулера и не видел там этого ограничения
                      Надо перечитать в http://xunitpatterns.com/Using%20Test%20Doubles.html я увидел реализацию In Memory DB, но не увидел примера теста.


                      Мне надо посмотреть, что именно Мезарос имеет ввиду под Directly Controlled. Вы не можете сделать ссылку yна пример использования Fake у него?


                      1. lair
                        31.08.2016 11:45

                        Мезарос имеет в виду, что как только вы начинаете проверять (не важно, каким способом), какие операции были совершены над test double — это не может быть fake object. Это либо test spy, либо mock.


                        Фейки используются тогда, когда нам надо заменить функциональность зависимости из-за того, что она медленная, недоступна в тестовой среде, имеет слишком много состояния, не позволяет совместное тестирование и так далее.


                  1. areht
                    31.08.2016 11:24

                    1) 12 строчек кода против одной на verify

                    > 2.2) И что — мы рефакторим Notifier а не тесты.

                    Как будто ломаются не тесты, и не их надо поддерживать

                    > Если делать нотифаер моком, то тест будет хрупкий — зависеть от последовательности вызова методов и прочее.

                    Что??

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

                    Любой сложный код можно разбить на достаточно простые части и тестировать эти макароны ломким сложным и медленным интеграционным тестом.

                    > Теперь давайте задумаемся, почему мы не мокаем System.String?

                    Потому, что отсутствие дефектов и неизменность System.String — аксиома.

                    Ни EuropeShopNotifier, ни InMemoryNotificationRecorder этими свойствами не обладают.


                    1. lair
                      31.08.2016 11:31

                      Потому, что отсутствие дефектов и неизменность System.String — аксиома.

                      На самом деле, не поэтому. Мы не мокаем string, потому что у нас (обычно) нет требований на то, что SUT выполняет именно конкретные операции над строкой, так что нам не надо верифицировать, какие операции над ней были произведены. А поскольку нам это поведение и заменять не нужно, нам вообще не нужны test doubles.


                      1. areht
                        31.08.2016 11:42

                        > потому что у нас (обычно) нет требований на то, что SUT выполняет именно конкретные операции над строкой

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


                      1. ApeCoder
                        31.08.2016 11:42

                        А почему нам не интересны конкретные операции со строкой (а интересен результат) но такое нельзя применить с другими абстракциями.


                        Не станут ли тесты более стабильными, если такой же принцип применять к другим абстракциям?


                        1. lair
                          31.08.2016 11:49

                          А почему нам не интересны конкретные операции со строкой (а интересен результат)

                          Потому у операций над строкой есть внешний наблюдаемый результат, а у других абстракций его может и не быть. Более того, строки (сравнительно) быстрые, не шарятся между тестами, всегда доступны и так далее. Не для всех абстракций это верно.


                          1. ApeCoder
                            31.08.2016 12:57

                            Сделать фейк (можно вслед за Арло назвать это симулятором пока я не разберусь с определением :)) который позволяет работать с результатом действий и создавать и работать быстро и, таким образом, не шарить между тестами


                            1. lair
                              31.08.2016 12:59

                              Вот вы и применили к "другим абстракциям" не тот же принцип, что к строкам.


                              Следующий вопрос, собственно, сведется к стоимости разработки и поддержки такого test double.


                              1. ApeCoder
                                31.08.2016 13:06

                                Чтобы снизить стоимость реализации надо применять hexagonal architecture и required interface на границах. То есть если у вас есть файловая система, а вам надо использовать только одну папку с файлами, и от нее интересно только поиск по имени и чтение содержимого, надо делать абстракцию BlobStorage с реализацией FolderBlobStorage и InMemoryBlobStorage и не симулировать всякие там атрибуты и прочее


                                1. lair
                                  31.08.2016 13:13

                                  И все равно вам придется сравнивать стоимость "полной" реализации и "ad-hoc" реализации для такого интерфейса.


                                  1. ApeCoder
                                    31.08.2016 13:18

                                    Если это не полная реализация, значит это реализация не этого интерфейса, а какого-то его подмножества.


                                    И как только какой-то метод где-нибудь в глубине начнет использовать что-то выходящее за это подмножество куча тестов могут посыпаться.


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


                                    1. lair
                                      31.08.2016 13:35

                                      И как только какой-то метод где-нибудь в глубине начнет использовать что-то выходящее за это подмножество куча тестов могут посыпаться.

                                      … или нет.


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


                                      void Login(username, password)
                                      void Send(to, message)
                                      void Logout()

                                      С точки зрения бизнеса вас интересует только то, что запихивается в Send — и для всех этих тестов вам достаточно "пустой" реализации Login/Logout; в ваших тестах никогда и ничего не сломается из-за того, что она пустая.


                                      (Да, возможно, вам где-то надо протестировать, что Login/Logout используются корректно, то есть протестировать корректность встраивания компонента. В этот момент вам будет нужно, чтобы вы могли эти методы наблюдать, или даже подменять, если вы хотите проверить, что система сделает, если логин упал. Но вот хотите ли вы каждый тест усложнять теми деталями, которые нужны для корректности логина/логаута?)


                                      1. ApeCoder
                                        31.08.2016 13:40

                                        Если нам нужен очень редко Login и Logout надо просто сделать интерфейс, который не содержит их и в большинстве случаев используется именно он. Возможно даже стоит выделить LoggedInSender чтобы система типов контролировала, что либо классу передали залогиненый сендер, либо он сам залогинен.


                                        1. lair
                                          31.08.2016 13:42

                                          Если нам нужен очень редко Login и Logout надо просто сделать интерфейс, который не содержит их

                                          Э нет. С точки зрения технологии они нужны. Просто для тестируемых бизнес-сценариев они неинтересны.


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

                                          Который тоже придется тестировать, ага.


                                          1. ApeCoder
                                            31.08.2016 13:45

                                            Если нам нужен очень редко Login и Logout надо просто сделать интерфейс, который не содержит их

                                            Э нет. С точки зрения технологии они нужны. Просто для тестируемых бизнес-сценариев они неинтересны.

                                            Я ж не говорю убрать существующий интерфейс, где они есть


                                            Который тоже придется тестировать, ага.

                                            Это интерфейс, что его тестировать?


                                            1. lair
                                              31.08.2016 13:48

                                              Я ж не говорю убрать существующий интерфейс, где они есть

                                              Тогда для пользователей этого интерфейса мое замечание (о ненужности реализации Login/Logout) продолжает верно.


                                              Это интерфейс, что его тестировать?

                                              Тестировать надо его реализацию, очевидно.


                                              1. ApeCoder
                                                31.08.2016 13:55

                                                Для тех, кто зависит от логина реализация нужна, значит мы ее делаем.
                                                Для тех кто не зависит от логина, предоставляем factory method InMemorySender.CreateLoggedInSender() и не тащим подробности логина и логаута в их тесты.


                                                Тестировать надо его реализацию, очевидно.

                                                Это да.


                                                1. lair
                                                  31.08.2016 13:56

                                                  Для тех, кто зависит от логина реализация нужна, значит мы ее делаем.

                                                  Зачем им нужна реализация (отличная от no-op)?


                                                  1. ApeCoder
                                                    31.08.2016 14:03

                                                    Напишите требования к SUT использующие данные методы, а я скажу, зачем


                                                    1. lair
                                                      31.08.2016 14:06

                                                      • SUT должен логиниться с credentials, полученными из конфигурации
                                                      • на каждый успешный логин должен быть логаут
                                                      • если логин неуспешен, отправка (и вообще никакие действия с нотификатором) не осуществляется
                                                      • ~50 требований на то, что именно должно быть в Send в зависимости от данных, переданных в SUT.


                                                      1. ApeCoder
                                                        31.08.2016 14:09

                                                        Затем, чтобы протестировать вот эти требования:
                                                        •SUT должен логиниться с credentials, полученными из конфигурации
                                                        •на каждый успешный логин должен быть логаут
                                                        •если логин неуспешен, отправка (и вообще никакие действия с нотификатором) не осуществляется


                                                        1. lair
                                                          31.08.2016 14:12

                                                          Ну для начала, отличная от no-op реализация этих методов вам нужна только в третьем пункте (и только логина), во всех остальных случаях вам нужно только наблюдение.


                                                          Во-вторых, а для оставшихся ~50 требований (которые, собственно, и представляют бизнес-ценность)?


                                                          1. ApeCoder
                                                            31.08.2016 14:16

                                                            Для оставшихся мы используем тот же симулятор, только создаем его уже в залогиненом виде
                                                            ИЛИ используем другой ограниченный интерфейс со своим симулятором


                                                            1. lair
                                                              31.08.2016 14:21

                                                              Для оставшихся мы используем тот же симулятор, только создаем его уже в залогиненом виде

                                                              … то есть реализация логина для них не нужна. О чем и речь: из ~53 требований на (один и то же!) SUT ~50 не требуют "полной реализации" интерфейса — им достаточно, чтобы она была non-breaking.


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


                                                              ИЛИ используем другой ограниченный интерфейс со своим симулятором

                                                              Этого мы не можем, потому что SUT во всех случаях принимает один и тот же интерфейс (это один и тот же SUT).


                    1. ApeCoder
                      31.08.2016 11:41

                      Если делать нотифаер моком, то тест будет хрупкий — зависеть от последовательности вызова методов и прочее.

                      Что??

                      Mock подразумевает canned response — то есть если меня вызвали два раза на первый я возвращаю это а на второй то и еще проверяю что в первый раз аргументы такие, в другой другие, нет?


                      1. areht
                        31.08.2016 11:47
                        +1

                        Это как настроите.


                      1. lair
                        31.08.2016 11:52

                        Нет. Мок — это просто объект, который (а) выступает как test stub (т.е., реагирует неким ожидаемым образом на воздействия от SUT) (б) при этом выступает как test spy (т.е., отслеживает воздействия с SUT для последующего анализа) и (ц) сам проверяет некие assertions во время вызовов от SUT (в этом его отличие от test spy). Никакие canned responses здесь не обязательны.


                        1. ApeCoder
                          31.08.2016 12:45

                          Вы правы, я перепутал. У Фаулера


                          Dummy objects are passed around but never actually used. Usually they are just used to fill parameter lists.
                          Fake objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an in memory database is a good example).
                          Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in for the test. Stubs may also record information about calls, such as an email gateway stub that remembers the messages it 'sent', or maybe only how many messages it 'sent'.
                          Mocks are what we are talking about here: objects pre-programmed with expectations which form a specification of the calls they are expected to receive.


                          1. lair
                            31.08.2016 12:47
                            +1

                            … там выше не зря сказано "Meszaros [...] defined four particular kinds of double".


  1. indestructable
    30.08.2016 23:38

    Спасибо, статья понравилась, понятные и практически применимые правила для написания тестов.


  1. lair
    31.08.2016 00:49

    Среди «мокистов» (это те, кто мокает в тестах) есть те, кто проверяет количество вызовов определенных методов, верифицирует сам вызов и пр. Другими словами, занимается проверкой внутренней работы методов. Это так же плохо, как и тестирование приватных.

    Эээ, но если весь тестируемый метод и сводится к тому, чтобы что-то внутри себя сделать, а потом сделанное передать в следующий объект, то как проверить, что сделано нужное?


    1. Artem_zin
      31.08.2016 02:55

      Проверить, что вызов не упал и ничего не вернул, очевидно же!


      // Переписывать большинство кода на чистые функции, в остальных случаях проверять вызовы внутренних объектов


    1. ApeCoder
      31.08.2016 08:36

      Надо передавать готовую хорошо сделанную абстракцию (у Фаулера — fake)типа InMemoryNotifier.
      Она должна быть не Ad Hoc для теста с записанными количествами вызовов, а реализовывать некий интерфейс


      см.



      1. lair
        31.08.2016 11:00

        Надо передавать готовую хорошо сделанную абстракцию (у Фаулера — fake)типа InMemoryNotifier.

        Это не абстракция, а реализация. И что же эта реализация будет делать?


        Она должна быть не Ad Hoc для теста с записанными количествами вызовов, а реализовывать некий интерфейс

        А как реализация интерфейса противоречит "ad hoc для теста"? Нельзя сделать ad-hoc-реализацию интерфейса?


        1. ApeCoder
          31.08.2016 11:08

          1) Записывать нотификейшены в список в памяти
          2) Она не должна знать про конкретный тест и про конкретное использование абстракции в конкретном. То есть если интерфейс позволяет вызывать методы в любом порядке, то и реализация интерфейса должна позволять так.


          Не надо canned response.


          1. lair
            31.08.2016 11:09

            Записывать нотификейшены в список в памяти

            Ну записала. Это проверило, что сделаны нужные вызовы?


            Она не должна знать про конкретный тест и про конкретное использование абстракции в конкретном.

            Почему?


            1. ApeCoder
              31.08.2016 11:21

              Ну записала. Это проверило, что сделаны нужные вызовы?

              Да. Только эта проверка не должна валится во всех комбинациях вызовов которые допускает интерфейс. Т.е. не должно быть canned response.


              Почему?

              1) Иначе тесты будут хрупкими. Падение теста будет просто показывать, что изменилось что-то незначительное.
              2) В разных тестах будет разная реализация — это значит что реюз кода фейка будет затруднен. А фейк должен поддерживать абстракцию полностью. Иначе тест будет проверять не "Если мы тебе передали нотификатор, то ты долджен отослать сообщения" а "Если мы тебе передали нечто ограниченное не имеющее название и не специфицированное в твоем интерфейсе то ты должен передать сообщение". То есть как только в Notificator появится еще один метод мы должны реализовывать его во всех специфических для тестов нотификаторах


              1. lair
                31.08.2016 11:26

                Да.

                Нет же. Как факт записи чего-то в список памяти проверяет, что вызов был совершен и был совершен правильно?


                Падение теста будет просто показывать, что изменилось что-то незначительное.

                Почему?


                В разных тестах будет разная реализация — это значит что реюз кода фейка будет затруднен

                А нужен ли этот реюз? Как бы, reusable objects — это всегда дополнительный оверхед (на поддержку этой самой reusability)?


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

                Вообще-то, тест должен проверять, что "если тебе передали такой-то заказ, то ты выполнил такую-то операцию над заданной тебе зависимостью". Что там в этой зависимости, проверять не надо (и SUT от этого зависеть не должен).


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


                1. areht
                  31.08.2016 11:34

                  > А еще бывают милые ситуации, когда надо проверять именно количество вызовов метода (первый же пример — кэширование).

                  > Записывать нотификейшены в список в памяти

                  .Count() же

                  -_-


                  1. lair
                    31.08.2016 11:36

                    .Count() же

                    Это если у вас операции записи. А если операции чтения?


                    (понятно, что все можно написать, но только это будет ровно то, против чего в посте возмущаются)


                    1. ApeCoder
                      31.08.2016 11:46

                      Я бы сделал считающий декоратор для абстракции (интересно, можно автоматом сгенерить). Это весьма специфичные тесты и я бы постарался делать такие тесты как можно меньше


                      1. lair
                        31.08.2016 11:54

                        Я бы сделал считающий декоратор для абстракции (интересно, можно автоматом сгенерить)

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


                        Это весьма специфичные тесты и я бы постарался делать такие тесты как можно меньше

                        Количество этих тестов обусловлено только и исключительно тем, какой модуль вы тестируете. Если вам надо протестировать кэширующий декоратор, то у вас большая часть тестов будет именно такой.


                        1. ApeCoder
                          31.08.2016 12:49

                          Ну и чем это отличается от того, что написано в посте как нежелательное?

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


                          Количество этих тестов обусловлено только и исключительно тем, какой модуль вы тестируете. Если вам надо
                          протестировать кэширующий декоратор, то у вас большая часть тестов будет именно такой.

                          Да именно так.


                          1. lair
                            31.08.2016 12:53

                            Тем что остальные аспекты абстракции будут реализованы полностью.

                            А где в посте написано что-то про остальные аспекты абстракции? Там написано только "есть те, кто проверяет количество вызовов определенных методов, верифицирует сам вызов и пр." А предложенное вами решение именно это и проверяет: количество вызовов определенных методов.


                            1. ApeCoder
                              31.08.2016 13:01

                              Оно не проверяет. Допустим у нас поменяется интерфейс. Вместо sendNotification будет


                              startSendNotification, addTextPath, endSendNoptification


                              Тесты не изменятся — он будет предоставлять notifier и ожидать что результатом будет отправленное собщение. Изменятся только реализация и симулятор.


                              То есть у SUT контракт "если вы мне предоставите notifier я туда пошлю сообщение" а тест "вот тебе notifier а я проверяю сообщения" и таким образом тест проверяет контракт, а все подробности релизации скрыты в симуляторе и в SUT


                              1. lair
                                31.08.2016 13:11

                                Оно не проверяет.

                                Проверяет-проверяет. Просто не прямо, а косвенно.


                                Тесты не изменятся — он будет предоставлять notifier и ожидать что результатом будет отправленное собщение. Изменятся только реализация и симулятор.

                                Симулятор — часть тестов. Поэтому тесты изменятся.


                                (кстати, а что делать, если production-реализация notifier требует, чтобы вызовы шли строго в последовательности startSendNotification, addTextPath, endSendNoptification, и строго по одному?)


                                Собственно (у Фаулера это описано, кстати), есть два вида тестирования с помощью подмены — основанное на состоянии (это то, что вы продвигаете) и основанное на поведении (это то, что делают некоторые другие люди). Оба они, в итоге, приводят к одному и тому же: мы тестируем косвенные выходы SUT. Выбор между ними, по большому счету, исключительно вкусовой — в обоих случаях вы пишете код, в обоих случаях этот код надо менять с изменением тестируемой системы (заметим, в скобках, снова вслед за Фаулером, что есть вещи, которые нельзя адекватно протестировать на состоянии — то же кэширование).


                                So should I be a classicist or a mockist?

                                I find this a difficult question to answer with confidence.


                                1. ApeCoder
                                  31.08.2016 13:23

                                  Проверяет-проверяет. Просто не прямо, а косвенно.

                                  Ок можете истолковывать мое "не проверять вызовы" как "проверять вызовы, но только так, чтобы все корректные с точки зрения абстракции вызовы считались тестом корректными. Или в максимальной степени соответствовать этому условию"


                                  Симулятор — часть тестов. Поэтому тесты изменятся.

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


                                  1. lair
                                    31.08.2016 13:38

                                    Ок можете истолковывать мое "не проверять вызовы" как "проверять вызовы, но только так, чтобы все корректные с точки зрения абстракции вызовы считались тестом корректными. Или в максимальной степени соответствовать этому условию"

                                    То есть все-таки проверять вызовы. Про что и речь.


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

                                    … и все свелось к тому, какой код (тестов) проще поддерживать. И внезапно, "общая для всех часть симулятора" — это shared fixture, которая может как удешевлять, так и удорожать поддержку тестов.


                                    1. ApeCoder
                                      31.08.2016 13:42

                                      http://xunitpatterns.com/Shared%20Fixture.html


                                      We reuse the same instance of the test fixture across many tests.

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


                                      1. lair
                                        31.08.2016 13:46

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


              1. paragraff
                31.08.2016 12:42

                Согласен, лучше проверять действие абстрагируясь от факта вызова конкретных методов с конкретными параметрами.
                Но не могу согласиться с тем, что фейк должен поддерживать абстракцию полностью. Тогда фейк всегда будет по сложности равен или сравним с реальным объектом. Вот здесь и будет оверхед.
                Да, тесты возможно будут хрупкими. Однако полная реализация абстракции, особенно если абстракция высокого уровня, едва ли выйдет дешевле, чем поддержка unit-тестов с одноразовыми mock'ами.


                1. ApeCoder
                  31.08.2016 13:09

                  Фейк должен поддерживать только то, что нужно от "Реального объекта" и это "Только то, что нужно" должно иметь имя интерфейса


                  1. paragraff
                    31.08.2016 16:34

                    Тогда знание о том, что нужно сделает тесты в равной степени хрупкими, как и проверка вызовов методов — интерфейса взаимодействия объектов.


                    1. ApeCoder
                      31.08.2016 16:37

                      "Только то, что нужно" должно иметь имя интерфейса

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


  1. Ohar
    31.08.2016 18:59

    гуайдлайны
    O_o