Для более полного представления я взглянул на проблему абстракций со стороны применения их в уже готовом коде, в 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%. (см. ссылку ниже).
Дополнительно
- Book “Working Effectively with Legacy Code” by Michael Feathers
- TDD when up to your neck in Legacy Code
- Breaking Hidden Dependencies
- The Legacy Code Lifecycle
- Should you unit test private methods on a class?
- Unit testing internals
- 5 Common Misconceptions About TDD & Unit Tests
- Intro to Unit Testing 5: Invading Legacy Code in the Name of Testability
- Law of Demeter
Комментарии (86)
indestructable
30.08.2016 23:38Спасибо, статья понравилась, понятные и практически применимые правила для написания тестов.
lair
31.08.2016 00:49Среди «мокистов» (это те, кто мокает в тестах) есть те, кто проверяет количество вызовов определенных методов, верифицирует сам вызов и пр. Другими словами, занимается проверкой внутренней работы методов. Это так же плохо, как и тестирование приватных.
Эээ, но если весь тестируемый метод и сводится к тому, чтобы что-то внутри себя сделать, а потом сделанное передать в следующий объект, то как проверить, что сделано нужное?
Artem_zin
31.08.2016 02:55Проверить, что вызов не упал и ничего не вернул, очевидно же!
// Переписывать большинство кода на чистые функции, в остальных случаях проверять вызовы внутренних объектов
ApeCoder
31.08.2016 08:36Надо передавать готовую хорошо сделанную абстракцию (у Фаулера — fake)типа InMemoryNotifier.
Она должна быть не Ad Hoc для теста с записанными количествами вызовов, а реализовывать некий интерфейс
см.
lair
31.08.2016 11:00Надо передавать готовую хорошо сделанную абстракцию (у Фаулера — fake)типа InMemoryNotifier.
Это не абстракция, а реализация. И что же эта реализация будет делать?
Она должна быть не Ad Hoc для теста с записанными количествами вызовов, а реализовывать некий интерфейс
А как реализация интерфейса противоречит "ad hoc для теста"? Нельзя сделать ad-hoc-реализацию интерфейса?
ApeCoder
31.08.2016 11:081) Записывать нотификейшены в список в памяти
2) Она не должна знать про конкретный тест и про конкретное использование абстракции в конкретном. То есть если интерфейс позволяет вызывать методы в любом порядке, то и реализация интерфейса должна позволять так.
Не надо canned response.
lair
31.08.2016 11:09Записывать нотификейшены в список в памяти
Ну записала. Это проверило, что сделаны нужные вызовы?
Она не должна знать про конкретный тест и про конкретное использование абстракции в конкретном.
Почему?
ApeCoder
31.08.2016 11:21Ну записала. Это проверило, что сделаны нужные вызовы?
Да. Только эта проверка не должна валится во всех комбинациях вызовов которые допускает интерфейс. Т.е. не должно быть canned response.
Почему?
1) Иначе тесты будут хрупкими. Падение теста будет просто показывать, что изменилось что-то незначительное.
2) В разных тестах будет разная реализация — это значит что реюз кода фейка будет затруднен. А фейк должен поддерживать абстракцию полностью. Иначе тест будет проверять не "Если мы тебе передали нотификатор, то ты долджен отослать сообщения" а "Если мы тебе передали нечто ограниченное не имеющее название и не специфицированное в твоем интерфейсе то ты должен передать сообщение". То есть как только в Notificator появится еще один метод мы должны реализовывать его во всех специфических для тестов нотификаторахlair
31.08.2016 11:26Да.
Нет же. Как факт записи чего-то в список памяти проверяет, что вызов был совершен и был совершен правильно?
Падение теста будет просто показывать, что изменилось что-то незначительное.
Почему?
В разных тестах будет разная реализация — это значит что реюз кода фейка будет затруднен
А нужен ли этот реюз? Как бы, reusable objects — это всегда дополнительный оверхед (на поддержку этой самой reusability)?
Иначе тест будет проверять не "Если мы тебе передали нотификатор, то ты долджен отослать сообщения" а "Если мы тебе передали нечто ограниченное не имеющее название и не специфицированное в твоем интерфейсе то ты должен передать сообщение".
Вообще-то, тест должен проверять, что "если тебе передали такой-то заказ, то ты выполнил такую-то операцию над заданной тебе зависимостью". Что там в этой зависимости, проверять не надо (и SUT от этого зависеть не должен).
А еще бывают милые ситуации, когда надо проверять именно количество вызовов метода (первый же пример — кэширование).
areht
31.08.2016 11:34> А еще бывают милые ситуации, когда надо проверять именно количество вызовов метода (первый же пример — кэширование).
> Записывать нотификейшены в список в памяти
.Count() же
-_-lair
31.08.2016 11:36.Count() же
Это если у вас операции записи. А если операции чтения?
(понятно, что все можно написать, но только это будет ровно то, против чего в посте возмущаются)
ApeCoder
31.08.2016 11:46Я бы сделал считающий декоратор для абстракции (интересно, можно автоматом сгенерить). Это весьма специфичные тесты и я бы постарался делать такие тесты как можно меньше
lair
31.08.2016 11:54Я бы сделал считающий декоратор для абстракции (интересно, можно автоматом сгенерить)
… а дальше бы проверили число вызовов. Ну и чем это отличается от того, что написано в посте как нежелательное?
Это весьма специфичные тесты и я бы постарался делать такие тесты как можно меньше
Количество этих тестов обусловлено только и исключительно тем, какой модуль вы тестируете. Если вам надо протестировать кэширующий декоратор, то у вас большая часть тестов будет именно такой.
ApeCoder
31.08.2016 12:49Ну и чем это отличается от того, что написано в посте как нежелательное?
Тем что остальные аспекты абстракции будут реализованы полностью. То есть подсчет вызовов будет работать только и исключительно в тестировании кешей и чего-то такого и даже в нем сама корректность вызовов будет проверяться при помощи того, что я называю фейком
Количество этих тестов обусловлено только и исключительно тем, какой модуль вы тестируете. Если вам надо
протестировать кэширующий декоратор, то у вас большая часть тестов будет именно такой.Да именно так.
lair
31.08.2016 12:53Тем что остальные аспекты абстракции будут реализованы полностью.
А где в посте написано что-то про остальные аспекты абстракции? Там написано только "есть те, кто проверяет количество вызовов определенных методов, верифицирует сам вызов и пр." А предложенное вами решение именно это и проверяет: количество вызовов определенных методов.
ApeCoder
31.08.2016 13:01Оно не проверяет. Допустим у нас поменяется интерфейс. Вместо sendNotification будет
startSendNotification, addTextPath, endSendNoptification
Тесты не изменятся — он будет предоставлять notifier и ожидать что результатом будет отправленное собщение. Изменятся только реализация и симулятор.
То есть у SUT контракт "если вы мне предоставите notifier я туда пошлю сообщение" а тест "вот тебе notifier а я проверяю сообщения" и таким образом тест проверяет контракт, а все подробности релизации скрыты в симуляторе и в SUT
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.ApeCoder
31.08.2016 13:23Проверяет-проверяет. Просто не прямо, а косвенно.
Ок можете истолковывать мое "не проверять вызовы" как "проверять вызовы, но только так, чтобы все корректные с точки зрения абстракции вызовы считались тестом корректными. Или в максимальной степени соответствовать этому условию"
Симулятор — часть тестов. Поэтому тесты изменятся.
Ок, не придется менять каждый конкретный тест, придется менять только общую для всех часть в симуляторе
lair
31.08.2016 13:38Ок можете истолковывать мое "не проверять вызовы" как "проверять вызовы, но только так, чтобы все корректные с точки зрения абстракции вызовы считались тестом корректными. Или в максимальной степени соответствовать этому условию"
То есть все-таки проверять вызовы. Про что и речь.
Ок, не придется менять каждый конкретный тест, придется менять только общую для всех часть в симуляторе
… и все свелось к тому, какой код (тестов) проще поддерживать. И внезапно, "общая для всех часть симулятора" — это shared fixture, которая может как удешевлять, так и удорожать поддержку тестов.
ApeCoder
31.08.2016 13:42http://xunitpatterns.com/Shared%20Fixture.html
We reuse the same instance of the test fixture across many tests.
То есть если новый экземпляр симулятора создавать отдельно для каждого теста, он не будет shared fixture
lair
31.08.2016 13:46Методы симулятора (их код) — это тоже экземпляр чего-то (здравствуй, Smalltalk), который вы и шарите. Это может показаться демагогией поначалу, но когда вы обнаружите, что для разных тестов вам нужно разное поведение, то может оказаться, что его настройка сопоставима с настройкой данных в традиционном понимании shared fixture.
paragraff
31.08.2016 12:42Согласен, лучше проверять действие абстрагируясь от факта вызова конкретных методов с конкретными параметрами.
Но не могу согласиться с тем, что фейк должен поддерживать абстракцию полностью. Тогда фейк всегда будет по сложности равен или сравним с реальным объектом. Вот здесь и будет оверхед.
Да, тесты возможно будут хрупкими. Однако полная реализация абстракции, особенно если абстракция высокого уровня, едва ли выйдет дешевле, чем поддержка unit-тестов с одноразовыми mock'ами.ApeCoder
31.08.2016 13:09Фейк должен поддерживать только то, что нужно от "Реального объекта" и это "Только то, что нужно" должно иметь имя интерфейса
paragraff
31.08.2016 16:34Тогда знание о том, что нужно сделает тесты в равной степени хрупкими, как и проверка вызовов методов — интерфейса взаимодействия объектов.
ApeCoder
31.08.2016 16:37"Только то, что нужно" должно иметь имя интерфейса
То есть если какое-то подмножество интерфейса используется чаще, то надо его выделять и использовать и фейкать.
areht
> Среди «мокистов» (это те, кто мокает в тестах) есть те, кто проверяет количество вызовов определенных методов, верифицирует сам вызов и пр. Другими словами, занимается проверкой внутренней работы методов. Это так же плохо, как и тестирование приватных.
> Очень хорошо помогает поставить мозги на нужные рельсы подход BDD.
Так как вы тестируете, что CreateSale вызывает notifier?
ApeCoder
Я бы использовал peel and slice — notifier должен передаваться либо как аргумент конструктора с дефолтным значением либо как свойство
ApeCoder
```С#
Serg046
Допустим класс `Car` имеет достаточно св-в, чтобы определить что именно поменялось и допустим у нас конкретный `enhancement` (что вряд ли), как тестировать? Проверять что поменялось у Car? Но ведь у нас уже есть тесты для того конкретного `enhancement`, которые делают это же самое.
Или оставить только проверку «на деньги» и не проверять апгрейд вообще? Но тогда как быть уверенным, что у нас в результате будет обновленная машина?
ApeCoder
Сделать recording enhancement который будет записывать факт применения enhancement внутри себя. Вне зависимости от того, сколькими и какими вызовами это было сделано.
Serg046
Какая тогда принципиальная разница? Ведь при рефакторинге все равно придется recording enhancement менять если мы его используем. Много тестов завязанных на метод упадет? Так нужно более устойчивые абстракции проектировать. К тому же, конкретно я, не вижу много тестов проверяющих вызов одного и того же метода.
Только вот в случае с моком нам не нужно создавать тип вручную и не будет лишних дынных. А в случае с рекордером, во-первых, нужно его создать и поддерживать. Во-вторых, у нас либо один рекордер для типа и тогда у нас много лишней инфы при тесте, либо много самих рекордеров для разных методов типа.
Ну и самое главное, оба подхода — это проверка внутренней работы методов. Т.е. вы с автором не согласны и просто показали еще один подход, который вам кажется более предпочтительным?
ApeCoder
В вашем случае не описано, что произойдет, когда дернут метод Cost — что будет в тесте?
Я вижу следующие достоинства:
И это не проверка внутренней работы метода. Контракт метода, "если мне дали улучшение, я его применю" — мы ему даем улучшение и проверяем, что оно применено.
Что именно значит "применить улучшение" и "проверить что улучшение применено" является внутренним делом улучшения, ведь так?
Serg046
Не совсем понял. Для денег будут отдельные тесты.
Для меня это не достоинство, лишние знания. Но даже если и считать это минусом это решается элементарно. Например в том же
moq
такое поведение по умолчанию, а чтобы заставить мок упасть нужно уже передаватьMockBehavior.Strict
.Да, это мне нравится, но на мой вкус минусы перевешивают.
И, как уже писал, конкретно я, не вижу много тестов проверяющих вызов одного и того же метода (из разных тестов).
Мок вариант делает тоже самое, в конечном счете оба подхода прибиты к
Enhancement.Upgrade(...)
, т.е. к конкретному механизму внутри метода.ApeCoder
Они могут косвенно использовать метод но не проверять его. Например, если добавится запись названия улучшения в какой-то журнал И название будет обязательным, придется обходить все такие тесты и впиливать туда название. Или moq как-то считает что свойство ненулевое и его присвоит?
Лишние подробности убраны из теста в отдельный объект
Тут вопрос, что такое наглядность. Мне кажется, текст моего теста более похож на формулировку исходного требования.
В принципе, количество инфы в теле теста можно выносить в какие-то билдеры/фектори методы.
В конечном счете все сводится к смене ориентации магнитных доменов на жестком диске :)
Спасибо за обсуждение. Мне надо еще подумать и почитать. Некоторые части концепции #NoMocks я додумал, возможно неправильно — надо это подробнее изучить.
Serg046
Да он много что умеет. К тому же никто не запрещает его расширять или использовать другой инструмент.
P.S. согласен, наверное здесь уже нечего обсуждать.
lair
Вернется дефолтное значение.
Нет. "Применить улучшение" — это операция, описанная в контракте улучшения, и с точки зрения любого потребителя "применить улучшение" — это вызвать эту операцию. Соответственно, при тестировании такого потребителя, проверка, что улучшение применено — это проверка, что эта операция проведена.
ApeCoder
Может ли такое быть, что в будущем контракт изменится? Например добавитья "асинхронное применение улучшения" и т.д.
Я бы сказал, что мой подход это как использование Path.Combine вместо X + "\" + Y — и то и другое сводится к конкатенации но сама по себе формулировка требований более устойчива.
lair
Может. Но изменение контракта — это (потенциальное) изменение всех потребителей (и, по большому счету, требований к ним).
Вопрос, традиционно, в том, что вы тестируете — потребителя, его взаимодействие, или результаты его взаимодействий. А это, в свою очередь, вытекает из требований.
Неа, нет никакой "большей устойчивости". Вот добавилось в контракт "асинхронное применение", и что случилось? У вас (если язык статический) перестали компилироваться тесты, хотя никаких ошибок в реальности нет. Уже проблема, надо пойти и добавить реализацию. Какую? Еще одна проблема. А ведь мы еще ничего не поменяли в SUT.
ApeCoder
С моей точки зрения тесты стали некорректны — они стали перестали описывать требования корректно.
С моей точки зрения хорошо, что компилятор нашел их некорректность.
Потому, что это случилось сразу же после изменения интерфейса, а не через некоторое время, когда кто-то стал оптимизировать одно из мест использования, а затем попробовал прогнать тесты и они свалились.
lair
Это зависит от того, что именно было в требованиях.
То есть вы считаете, что хорошо, что программист добавил новый метод, и тесты (даже те, которые вообще не связаны с этим методом) посыпались? По мне, так это просто яркий пример хрупких тестов.
ApeCoder
Обычно требования фурмулируются высокоуровнево типа "Если человеку предоставили способ обновить машину и у него хватает денег, то он применет этот способ" по идее тесты не должны сыпаться от того, каким именно образом человек применяет его
Мне кажется это скорее строгость а не хрупкость — они не падют непонятно от чего, а просто напоминают мне, что я не подумал о чем-то.
lair
Это требования к фиче. Из них юнит-тесты писать не надо (обычно). Требования, из которых вырастают тесты, обычно возникают на уровне дизайна.
Излишняя строгость — это и есть хрупкость. Особенно это хорошо заметно, если интерфейс в одном компоненте, а тесты — в другом, и их авторы вообще никак не связаны.
ApeCoder
С моей точки зрения требования к этому уровню абстракции должны быть близки к требованию к фиче
Допустим я ввожу новй метод в интерфейс, компилирую и вижу, что в тестовом дубле он не реализован. У меня есть выбор:
Если тестовый объект не мой я могу сделать что-то из этого, и послать хозяину письмо — он будет в курсе новых возможностей и посоветует как поступить лучше.
То есть, по сравнению с вариантами с моками больше выбора и решение более осознанное. Те же самые гарантии как и для production кода (кстати, интересно подумать на тему, почему в prod не используются умолчания типа "Если в объекте нету метода верни пустое значение" — даже в Смолтоке, насколько я помню).
lair
Чаще всего это не позволяет писать тесты — в том смысле, что кто-то все равно декомпонует требования до уровня дизайна при написании assertions.
Или не видите, потому что тестовый модуль вам недоступен (он в другом проекте, который просто использует ваш интерфейс).
Ваш объект и есть мок, поэтому противопоставление некорректно. Если вы имеете в виду "по сравнению с ad-hoc-моками", то все описанные вами варианты там тоже доступны — просто вы не узнаете о том, что кто-то добавил в интерфейс новый метод. А должны ли?
Мезарос:
Узнаете? То, за что вы ратуете — это очень специфический случай Overspecified software (которая проблема, кстати, регулярно ассоциируется именно с моками), когда тест зависит не только от того, что ему реально нужно знать, но и от каких-то вещей, которые для него избыточны.
Потому что для production они могут быть опасны.
(заметим, что в конкретном Moq, где это умолчание есть, оно полностью контролируемо).
ApeCoder
С моей точки зрения надо стремиться абстрагироваться от этого — детали реализации загонять на уровень ниже custom assertions и т.д. чтобы был виден intention.
В любом случае контракт метода не "я буду работать если мне передадут способ улучшения автомобиля" а не "я буду работать, если мне передадут способ улучшения автомобиля, но только если работают методы X и Y и я не обязуюсь дергать никакой метод кроме них" в последнем случае получается, что на самом деле он требует какой-то другой интерфейс являщийся подмножеством исходного.
Если мы его реализуем моком, то, получается, что мы ему передаем не тото интерфейс что он требует, а нечто, сгенерированное автоматически удовлетворяющее только типам но не всему в контракту. Вероятно, в большинстве случаев это срабатывает.
Production code изменился, и добавление метода это ломающее изменение для любого кода который имплементирует интерфейс.
lair
Кто-то все равно должен это сделать — разбить intent на конкретные операции и проверки.
А откуда вы взяли этот второй "контракт"? Вообще-то, разумно ожидать, что "мне передадут способ улучшения автомобиля, который работает в рамках своего контракта" — а современные автогенераторы моков становятся все лучше и лучше в генерации под контракт.
Это, будем честными, часто так и есть — и зачастую это следствие недостаточно мощной системы типов и ограничений в языке.
Еще раз: то, что у вас — это и есть мок.
Мой (как автора SUT) продакшн-код не изменился. Более того, весь мой продакшн-код корректно работает. Сломались только и исключительно тесты, дав мне false positive — что и является запахом ("When we don't think the change should have affected the tests that are failing"). Никакой пользы мой продакшн-код от этого поведения тестов не получил.
ApeCoder
Да. В моем случае тест должен содержать intent а детальные операции и проверки должны быть уровнем ниже. То есть, если абстрактная формулировка требует, чтобы при получении корректного способа апгрейда и достаточности средств он должен быть применен, то "корректный сопособ апгрейда" и "проверка, что способ применен" должно быть сформулировано отдельно.
Если объект x содержит метод m требующий реализации подмножества y интерфейса z то логично назвать это подмножество каким-то именем и написать именно его в требованиях m — нет?
В нашем примере можно не добавлять методы в существующий интерфейс, а сделать новый расширяющий его с добавленными методами.
В нашем случае системы типов достаточно. См. Выше, если мы этого не делаем, значит мы считаем что высока вероятность, что методы нужны всем.
Мне надо перечитать, но, насколько я понял по Месзаросу мок должен содержать assertions. У меня он их не содержит — он просто готов сообщить внешнему миру о каких-то обобщенных свойствах. Я постараюсь перечитать его и то, что читал по #NoMocks в ближайшие дни, возможно я что-то не так понял или додумал, спасибо вам за внимательность.
Мне кажется, я на это уже отвечал. Я понимаю вашу точку зрения.
lair
Кем? Где?
Не всегда. В частности, в любом декораторе каждый отдельный метод использует только один метод от декорируемого интерфейса, но это не означает, что мы выделяем по интерфейсу на метод.
(и это мы еще не затрагиваем проблемы именования этих подмножеств)
Тогда ранее написанный тестовый код для этого интерфейса останется рабочим — вне зависимости от того, как он сделан. Добавление нового метода в существующий интерфейс — это был ваш аргумент.
Да, но стоит, скажем, вспомнить классический пример с потоком, от которого нужно только чтение и произвольный переход — как "обычная" система типов перестает работать.
Да, я не прав, в этом конкретном примере у вас test spy. Но, что важно: то, что сгенерил Moq (в этом примере) — тоже test spy. Поэтому разницы все равно нет.
areht
Я не про DI. Я про тест, который работает без «верифицирует сам вызов». По вашей ссылке ниже warehouseControl.verify() верифицирует сам вызов.
notifier.Should().Contain(notification => notification.Price == expectedPrice), видимо, тоже.
ApeCoder
InMemoryNotificationRecorder это InMemory реализайия INotifier которая кладет в себя нотификейшены.
Should.COntain — это из Fluent assertions
Его можно со всеми коллекциями употреблять
Таким образом мы не проверяем каким количеством и каких вызовов получено уведомление. Мы просто проверяем что если передать что-то поддерживающее интерфейс INotifier, то в результате действия мы получаем уведомление.
areht
Что-то я не вижу где счастье… Грабли там же, где и с верификацией.
я вижу 2 варианта:
1) INotifier примитивный, состоит из одного метода. Оверхед, мало толку.
2) INotifier состоит из 52-х методов, которые, в зависимости от метода и параметров, формируют сообщения разных типов и раскладывают их по разным коллекциям разных типов (InMemoryNotificationRecorder: ArrayList — это же интереснее).
2.1) InMemoryNotificationRecorder реализует все 52 метода, в основном копипастой.
2.2) Декомпозируем, тогда внутри есть INotificationSender, тогда нам нужен InMemoryNotificationSenderRecorder, который мы, с десятком других каких-то стабов, создадим в тесте и все передадим в EuropeShopNotifier и получим интеграционный тест.
Ну и переписывание стабов при переходе из 1 в 2 — удовольствие так себе.
ApeCoder
1) Размер оверхеда?
2.1) red — green — refactor
2.2) Resharper
areht
1) InMemoryNotificationRecorder
2.1) red — green —? Копипасту боевого кода с тестовым обобщать? Будет 2.2.
2.2) но в тестах то сплошной бойлерплейт, да и тесты интеграционные.
ApeCoder
1) Это не размер, это имя. Размер должен быть какой-то метрикой. Причем оверхед посравнению с моком.
2.1) Да, все правильно будет 2.2 сделанный постепенно
2.2) И что — мы рефакторим Notifier а не тесты.
Если делать нотифаер моком, то тест будет хрупкий — зависеть от последовательности вызова методов и прочее.
Если делать его фейком, то будет стабильная абстракция и тесты не будут валиться при эквивалетных преобразованиях.
Теперь давайте задумаемся, почему мы не мокаем System.String?
Мой ответ такой, что для теста нам нужна полная но самая простая реализация зависимости. Если самая простая реализация зависимости уже есть в production, то можно использовать ее.
Арло Белши, например, где-то писал что фейки могут понадобитьяс в продакшене (например можно собрать какой-нибудь буфер нотификаций используя InMemoryNotificationSender)
Таким образом, с моей точки зрения, если Notificator будет достаточно простым внутри его вполне можно использовать в тесте
lair
"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)
ApeCoder
Спасибо, я смотрел терминологию у Фаулера и не видел там этого ограничения
Надо перечитать в http://xunitpatterns.com/Using%20Test%20Doubles.html я увидел реализацию In Memory DB, но не увидел примера теста.
Мне надо посмотреть, что именно Мезарос имеет ввиду под Directly Controlled. Вы не можете сделать ссылку yна пример использования Fake у него?
lair
Мезарос имеет в виду, что как только вы начинаете проверять (не важно, каким способом), какие операции были совершены над test double — это не может быть fake object. Это либо test spy, либо mock.
Фейки используются тогда, когда нам надо заменить функциональность зависимости из-за того, что она медленная, недоступна в тестовой среде, имеет слишком много состояния, не позволяет совместное тестирование и так далее.
areht
1) 12 строчек кода против одной на verify
> 2.2) И что — мы рефакторим Notifier а не тесты.
Как будто ломаются не тесты, и не их надо поддерживать
> Если делать нотифаер моком, то тест будет хрупкий — зависеть от последовательности вызова методов и прочее.
Что??
> Таким образом, с моей точки зрения, если Notificator будет достаточно простым внутри его вполне можно использовать в тесте
Любой сложный код можно разбить на достаточно простые части и тестировать эти макароны ломким сложным и медленным интеграционным тестом.
> Теперь давайте задумаемся, почему мы не мокаем System.String?
Потому, что отсутствие дефектов и неизменность System.String — аксиома.
Ни EuropeShopNotifier, ни InMemoryNotificationRecorder этими свойствами не обладают.
lair
На самом деле, не поэтому. Мы не мокаем
string
, потому что у нас (обычно) нет требований на то, что SUT выполняет именно конкретные операции над строкой, так что нам не надо верифицировать, какие операции над ней были произведены. А поскольку нам это поведение и заменять не нужно, нам вообще не нужны test doubles.areht
> потому что у нас (обычно) нет требований на то, что SUT выполняет именно конкретные операции над строкой
Выполнять операции над самой System.String — это вообще, прямо скажем, экзотика. Подозреваю, что он имел в виду в принципе простые типы, а там такие требования могли бы быть.
ApeCoder
А почему нам не интересны конкретные операции со строкой (а интересен результат) но такое нельзя применить с другими абстракциями.
Не станут ли тесты более стабильными, если такой же принцип применять к другим абстракциям?
lair
Потому у операций над строкой есть внешний наблюдаемый результат, а у других абстракций его может и не быть. Более того, строки (сравнительно) быстрые, не шарятся между тестами, всегда доступны и так далее. Не для всех абстракций это верно.
ApeCoder
Сделать фейк (можно вслед за Арло назвать это симулятором пока я не разберусь с определением :)) который позволяет работать с результатом действий и создавать и работать быстро и, таким образом, не шарить между тестами
lair
Вот вы и применили к "другим абстракциям" не тот же принцип, что к строкам.
Следующий вопрос, собственно, сведется к стоимости разработки и поддержки такого test double.
ApeCoder
Чтобы снизить стоимость реализации надо применять hexagonal architecture и required interface на границах. То есть если у вас есть файловая система, а вам надо использовать только одну папку с файлами, и от нее интересно только поиск по имени и чтение содержимого, надо делать абстракцию BlobStorage с реализацией FolderBlobStorage и InMemoryBlobStorage и не симулировать всякие там атрибуты и прочее
lair
И все равно вам придется сравнивать стоимость "полной" реализации и "ad-hoc" реализации для такого интерфейса.
ApeCoder
Если это не полная реализация, значит это реализация не этого интерфейса, а какого-то его подмножества.
И как только какой-то метод где-нибудь в глубине начнет использовать что-то выходящее за это подмножество куча тестов могут посыпаться.
То есть тесты будут показывать не то, что требование не выполняется, а то, что просто что-то изменилось.
lair
… или нет.
Вот смотрите, есть у вас неуклюжий, но вполне жизненный сервис некоего стороннего отправлятеля:
С точки зрения бизнеса вас интересует только то, что запихивается в
Send
— и для всех этих тестов вам достаточно "пустой" реализацииLogin/Logout
; в ваших тестах никогда и ничего не сломается из-за того, что она пустая.(Да, возможно, вам где-то надо протестировать, что
Login
/Logout
используются корректно, то есть протестировать корректность встраивания компонента. В этот момент вам будет нужно, чтобы вы могли эти методы наблюдать, или даже подменять, если вы хотите проверить, что система сделает, если логин упал. Но вот хотите ли вы каждый тест усложнять теми деталями, которые нужны для корректности логина/логаута?)ApeCoder
Если нам нужен очень редко Login и Logout надо просто сделать интерфейс, который не содержит их и в большинстве случаев используется именно он. Возможно даже стоит выделить LoggedInSender чтобы система типов контролировала, что либо классу передали залогиненый сендер, либо он сам залогинен.
lair
Э нет. С точки зрения технологии они нужны. Просто для тестируемых бизнес-сценариев они неинтересны.
Который тоже придется тестировать, ага.
ApeCoder
lair
Тогда для пользователей этого интерфейса мое замечание (о ненужности реализации
Login
/Logout
) продолжает верно.Тестировать надо его реализацию, очевидно.
ApeCoder
Для тех, кто зависит от логина реализация нужна, значит мы ее делаем.
Для тех кто не зависит от логина, предоставляем factory method InMemorySender.CreateLoggedInSender() и не тащим подробности логина и логаута в их тесты.
Это да.
lair
Зачем им нужна реализация (отличная от no-op)?
ApeCoder
Напишите требования к SUT использующие данные методы, а я скажу, зачем
lair
Send
в зависимости от данных, переданных в SUT.ApeCoder
Затем, чтобы протестировать вот эти требования:
•SUT должен логиниться с credentials, полученными из конфигурации
•на каждый успешный логин должен быть логаут
•если логин неуспешен, отправка (и вообще никакие действия с нотификатором) не осуществляется
lair
Ну для начала, отличная от no-op реализация этих методов вам нужна только в третьем пункте (и только логина), во всех остальных случаях вам нужно только наблюдение.
Во-вторых, а для оставшихся ~50 требований (которые, собственно, и представляют бизнес-ценность)?
ApeCoder
Для оставшихся мы используем тот же симулятор, только создаем его уже в залогиненом виде
ИЛИ используем другой ограниченный интерфейс со своим симулятором
lair
… то есть реализация логина для них не нужна. О чем и речь: из ~53 требований на (один и то же!) SUT ~50 не требуют "полной реализации" интерфейса — им достаточно, чтобы она была non-breaking.
Более того, пока вы не начнете тестировать эти три оставшихся требования, вы и не начнете писать эту реализацию в вашей заглушке. А когда напишете — у вас внезапно могут упасть остальные тесты.
Этого мы не можем, потому что SUT во всех случаях принимает один и тот же интерфейс (это один и тот же SUT).
ApeCoder
areht
Это как настроите.
lair
Нет. Мок — это просто объект, который (а) выступает как test stub (т.е., реагирует неким ожидаемым образом на воздействия от SUT) (б) при этом выступает как test spy (т.е., отслеживает воздействия с SUT для последующего анализа) и (ц) сам проверяет некие assertions во время вызовов от SUT (в этом его отличие от test spy). Никакие canned responses здесь не обязательны.
ApeCoder
Вы правы, я перепутал. У Фаулера
lair
… там выше не зря сказано "Meszaros [...] defined four particular kinds of double".