Pull to refresh

Comments 86

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

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

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

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

```С#


    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);
}

class Person
{
    Car UpgradeCar(Enhancement enhancement)
    {
        if (_money < enhancement.Cost)
            throw ...;

        _money -= enhancement.Cost;
        enhancement.Upgrade(_car);

        return _car;
    }
}


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

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

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 менять если мы его используем. Много тестов завязанных на метод упадет? Так нужно более устойчивые абстракции проектировать. К тому же, конкретно я, не вижу много тестов проверяющих вызов одного и того же метода.

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

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

В вашем случае не описано, что произойдет, когда дернут метод 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 и что такое факт его применения — то есть если протокол применения изменится, надо это будет учесть только в одном месте
  • Лишние подробности убраны из теста в отдельный объект

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


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

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

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


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

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


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

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


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

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

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

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

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

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


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


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

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


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

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


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

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


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

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

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


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

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

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


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

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

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

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


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

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

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


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


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

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

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


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


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

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

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


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

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

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

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


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

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

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

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


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

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

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

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


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

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


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

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


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

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

Чаще всего это не позволяет писать тесты — в том смысле, что кто-то все равно декомпонует требования до уровня дизайна при написании 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, где это умолчание есть, оно полностью контролируемо).

Чаще всего это не позволяет писать тесты — в том смысле, что кто-то все равно декомпонует требования до уровня дизайна при написании 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 изменился, и добавление метода это ломающее изменение для любого кода который имплементирует интерфейс.

С моей точки зрения надо стремиться абстрагироваться от этого — детали реализации загонять на уровень ниже 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"). Никакой пользы мой продакшн-код от этого поведения тестов не получил.

Кто-то все равно должен это сделать — разбить 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"). Никакой пользы мой продакшн-код от этого поведения тестов не получил.

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

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

Кем? Где?


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

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


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


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

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


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

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


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

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

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

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

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, то в результате действия мы получаем уведомление.

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

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

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

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

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


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


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


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


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

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

"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)

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


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

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


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

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

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

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

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

Что??

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

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

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

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

Ни EuropeShopNotifier, ни InMemoryNotificationRecorder этими свойствами не обладают.
Потому, что отсутствие дефектов и неизменность System.String — аксиома.

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

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

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

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


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

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

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

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

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


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

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

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

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


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


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

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

… или нет.


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


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

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


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

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

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

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


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

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

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

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

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


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

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

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

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


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

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

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


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

Это да.

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

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

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

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

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

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


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

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

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

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


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


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

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

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

Что??

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

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

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

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


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.

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

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

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

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

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


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

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


см.


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

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


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

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

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


Не надо canned response.

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

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


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

Почему?

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

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


Почему?

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

Да.

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


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

Почему?


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

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


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

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


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

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

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

.Count() же

-_-
.Count() же

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


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

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

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

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


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

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

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

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


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

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

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

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

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


startSendNotification, addTextPath, endSendNoptification


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


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

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

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


Тесты не изменятся — он будет предоставлять 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.
Проверяет-проверяет. Просто не прямо, а косвенно.

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


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

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

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

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


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

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

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


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

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

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

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

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

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

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

Sign up to leave a comment.

Articles