Відгук на книгу Growing Object-Oriented Software, Guided by Tests

Ця стаття — рев'ю на книгу «Growing Object-Oriented Software, Guided by Tests» (GOOS для стислості). В ній я покажу, як можна імплементувати проект-приклад з книги без використання моков (mocks).

Мета статті — показати, як використання моков може нашкодити кодом і наскільки простіше цей же код стає якщо від моков позбутися. Другорядна мета — виокремити поради з книги, які особисті мені здаються розумними і ті, які, навпаки, приносять більше шкоди, ніж користі. У книзі досить багато і тих і інших.

Версія англійською: ссылка.

Хороші частини
Почнемо з хороших речей. Велика їх частина знаходиться в перших двох секціях книги.

Автори визначають мету автоматичного тестування як створення сітки безпеки» (safety net), яка допомагає виявляти регрессиии в коді. На мій погляд, це дійсно найбільш важлива перевага, яку дає нам наявність тестів. Safety net допомагає досягти впевненості в тому, що код не працює як належить, що, в свою чергу, дозволяє швидко додавати новий функціонал та рефакторіть вже наявний. Команда стає набагато більш продуктивною, якщо вона впевнена, що зміни, внесені в код, не приводять до поломок.

Книга також описує важливість налаштування deployment environment на ранніх стадіях. Це повинно стати першочерговим завданням будь-якого нового проекту, оскільки дозволяє виявляти потенційні інтеграційні помилки на ранніх стадіях, до того як написано значну кількість коду.

Для цього автори пропонують починати з будівництва «ходячого скелета» (walking skeleton) — наипростейшей версії програми, яка в той же час у своїй реалізації зачіпає всі верстви програми. Приміром, якщо це веб-додаток, скелет може показати просту HTML-сторінку, яка запитує рядок з реальної бази даних. Цей скелет повинен бути покритий end-to-end тістом, з якого почнеться створення набору тестів (test suite).

Ця техніка також дозволяє сфокусуватися на розгортанні deployment pipeline без приділення підвищеної уваги архітектурі програми.

Книга пропонує дворівневий цикл TDD:

image

Іншими словами, починати кожну нову функціональність з end-to-end тіста і прокладати собі шлях до успішного проходження цього тесту через звичайний цикл red-green-refactor.

End-to-end тут виступають більше як вимірювання прогресу. Якісь з цих тестів можуть перебувати в «червоному» стані, т. к. фіча ще не реалізована, це нормально. Юніт тести в той же час виступають як сітка безпеки і повинні бути зеленими весь час.

Важливо щоб end-to-end тести зачіпали як можна більше зовнішніх систем, це допоможе виявляти інтеграційні помилки. У той же час, автори визнають, що якісь зовнішні системи доведеться замінювати на заглушки в будь-якому випадку. Питання, що включати у end-to-end тести, должет вирішуватися для кожного проекту окремо, тут немає універсальної відповіді.

Книга пропонує розширити класичний 3х-кроковий цикл TDD, додавши до нього четвертий крок: робити повідомлення про помилку більш зрозумілим.

image

Це допоможе переконатися, що якщо тест падає, ви зможете зрозуміти, що з ним не так просто глянувши на повідомлення про помилку, не запускаючи дебагер.

Автори рекомендують розробляти додаток «вертикальним» (end to end) з самого початку. Не витрачайте занадто багато часу на шліфування архітектури, почніть з якогось запиту, яка приходить ззовні (наприклад, UI) і обробіть цей запит повністю, включаючи всі верстви додатки (UI, логіка, БД) з мінімально можливою кількістю коду. Іншими словами, не вибудовуйте архітектуру заздалегідь.

Ще один відмінний рада — тестувати поведінку, а не методи. Дуже часто це не одне і те ж, т. к. одиниця поведінки може зачіпати кілька методів чи навіть класів.

Інший цікавий моммент — рекомендація робити тестову систему (system under test, SUT) контексто-незалежній:

«Жоден об'єкт не повинен мати уявлення про систему, в якій він запущений».

Це по суті концепція ізоляції моделі предметної області (domain model isolation). Доменні класи не повинні залежати від зовнішніх систем. В ідеалі ви повинні мати можливість повністю вирвати їх з поточного оточення і запустити без будь-яких додаткових зусиль. Крім очевидної переваги, пов'язаного з кращої тестируемостью коду, цей метод дозволяє спростити ваш код, т. к. ви здатні фокусуватися на предметній області не звертаючи увагу на аспекти, які не відносяться до вашого домену (БД, мережа і т. д.).

Книга є першоджерел досить відомого правила «Замещайте тільки типи, якими ви володієте» («Only mock types that you own»). Іншими словами, використовуйте моки тільки для типів, які ви написали самі. Інакше ви не зможете гарантувати, що ваші моки коректно моделюють поведінку цих типів.

Цікаво, що протягом книги автори самі пару раз порушують це правило і використовують моки для типів з зовнішніх бібліотек. Ті типи досить прості, так що там дійсно немає особливого сенсу створювати власні обгортки над ними.

Погані частини
Незважаючи на безліч цінних порад, книга також дає потенційно шкідливі рекомендації, і таких рекомендацій досить багато.

Автори є прихильниками mockist підходу до юніт-тестування (більш докладно про відмінності тут: mockist vs classicist) навіть коли йдеться про комунікацію між індивідуальними об'єктами всередині доменної моделі. На мій погляд, це найбільший недолік книги, все решта — наслідок з нього.

Щоб обгрунтувати свій підхід, автори наводять визначення ООП, дане Алан Кей (Alan Kay):

«Головна ідея — обмін повідомленнями. Ключ до створення доброго і розширюваного програми полягає у дизайні того, як різні його модулі комунікують один з одним, а не в тому, як вони влаштовані всередині.»

Вони потім укладають, що взаємодії між об'єктами — це те, на чому ви повинні фокусуватися в першу чергу при юніт тестуванні. За цією логікою, комунікація між класами — це те, що в результаті робить систему такою, яка вона є.

У цій точці зору є дві проблеми. По-перше, визначення ООП, дане Аланом Кеєм, тут недоречно. Воно досить расплывчиво щоб робити на його основі такі далекосяжні висновки і має мало спільного з сучасними ООП мовами.

Ось ще одна відома цитата від нього:

«Я придумав фразу „об'єктно-орієнтований“, і я не мав на увазі С++».

І звичайно, ви можете спокійно замінити тут С++ на C# або Java.

Друга проблема з таким підходом полягає в тому, що окремі класи занадто малі (fine-grained) щоб розглядати їх як незалежні комунікатори. Те, як вони спілкуються між собою, змінюється часто і має мало спільного з кінцевим результатом, який ми в підсумку повинні перевіряти в тестах. Патерн комунікації між об'єктами — це деталь реалізації (implementation detail) і стає частиною API тільки коли комунікація перетинає кордони системи: коли ваша доменна модель починає спілкуватися із зовнішніми сервісами. На жаль, книга не робить цих відмінностей.

Недоліки підходу, запропонованого книгою, стають очевидними, якщо ви подивитеся на код проекту з 3й голови. Фокус на комунікаціями між об'єктами не тільки призводить до крихким тестів з-за їх зав'язаними на деталі імплементації, але і також призводить до переусложненному дизайну з циклічними залежностями, header інтерфейсами і надмірною кількістю шарів абстракцій.

В іншій частині статті, я збираюся показати як проект з книги може бути модифікований і який ефект це має на юніт тести.

Оригінальна кодова база написана на Java, модифікована версія — на C#. Я переписав проект повністю, включаючи юніт тести, end-to-end тести, UI і емулятор для XMPP сервера.

Проект
Перш ніж зануритися в код, давайте подивимося на предметну область. Проект з книги — Auction Sniper. Бот, який бере участь в аукціонах від імені користувача. Ось його інтерфейс:

image

Item Id — ідентифікатор предмета, який в даний момент продається. Stop Price — максимальна ціна, яку ви, як користувач готові заплатити за нього. Last Price — остання ціна, яку ви або інші учасники аукціону запропонували за цей предмет. Last Bid — остання ціна, яку зробили ви. State — стан аукціону. На скріншоті вище ви можете бачити, що додаток виграло обидва предмета, ось чому обидві ціни однакові в обох випадках: вони прийшли від вашого застосування.

Кожен рядок у списку представляє окремого агента, який слухає повідомлення приходять від сервера і реагує на них посилаючи команди у відповідь. Бізнес-правила можна підсумувати наступним картинкою:

image

Кожен агент (вони також називаються Auction Sniper) починає з верху картинки, в стані Joining. Потім він чекає поки сервер пришле подія з поточним станом аукціону — остання ціна, ім'я користувача зробив ставку і мінімальне збільшення ціни необхідне для того, щоб перебити останню ставку. Цей тип події називається Price.

Якщо необхідна ставка менше ніж стоп ціна, яку користувач встановив для предмета, додаток відправляє свою ставку (bid) і переходить у стан Bidding. Якщо нове Price подія показує, що наша ставка лідирує, Sniper нічого не робить і переходить в стан Winning. Нарешті, друга подія послана сервером — це Close подія. Коли воно приходить, додаток дивиться в якому статусі вона зараз для цього предмета. Якщо в Winning, то переходить в Won, всі інші статуси переходять у Lost.

Тобто по суті ми маємо бота, який посилає команди сервера і підтримує внутрішню state machine.

Давайте подивимося на архітектуру додаток, запропонованого книгою. Ось її діаграма (клікніть для збільшення):

image

Якщо ви вважаєте, що вона переусложнена понад заходи для такої простої задачі, це тому що так і є. Отже, які проблеми ми бачимо тут?

Саме перше зауваження, що кидається в очі, — велика кількість header інтерфейсів. Цей термін позначає інтерфейс, який повністю копіює єдиний клас имплементирующий цей інтерфейс. Приміром, XMPPAuction один до одного співвідноситься з Auction інтерфейсом, AcutionSniper — з AuctionEventListener і так далі. Інтерфейси з єдиною імплементацією не є абстракцією і вважаються дизайном «із запашком» (design smell).

Нижче та ж діаграма без інтерфейсів. Я прибрав їх, щоб структура діаграми стала більш зрозумілою.

image

Друга проблема тут — циклічні залежності. Найбільш очевидна з них — між XMPPAuction і AuctionSniper, але вона не єдина. Приміром, AuctionSniper посилається на SnipersTableModel, який у свою чергу посилається на SniperLauncher і так далі поки зв'язок не приходить назад до AuctionSniper.

Циклічні залежності в коді навантажують наш мозок, коли ми намагаємося прочитати і зрозуміти цей код. Причина в тому, що з подібними залежностями ви не знаєте з чого почати. Щоб зрозуміти призначення одного з класів, вам необхідно помістити в голові цілий граф класів, циклічно пов'язаних один з одним.

Навіть після того, як я повністю переписав код проекту, мені доводилося досить часто звертатися до діаграм щоб зрозуміти як різні класи та інтерфейси ставляться один до одного. Ми, люди, добре розуміємо ієрархії, з циклічними графами у нас часто виникають складності. Scott Wlaschin написав чудову статтю на цю тему: Cyclic dependencies are evil.

Третя проблема — відсутність ізоляції доменної моделі. Ось як архітектура виглядає з точки зору DDD:

image

Класи посередині складають доменну модель. У той же час, вони комунікують з сервером аукціону (ліворуч) і UI (праворуч). Приміром, SniperLauncher спілкується з XMPPAuctionHouse, AuctionSniper — з XMPPAcution і SnipersTableModel.

Звичайно, вони роблять це використовуючи інтерфейси, а не реальні класи, але, знову ж, додавання в модель header інтерфейсів не означає, що ви автоматично починаєте слідувати Dependency Inversion принципам.

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

Всі ці недоліки — це звичайний наслідок з ситуації, коли розробники фокусуються на тестуванні взаємодій між класами всередині доменної моделі, а не їх публічного API. Подібний підхід призводить до створення header інтерфейсів, оскільки інакше стає неможливим «замочити» сусідні класи, до великої кількості циклічних залежностей і доменним класами безпосередньо спілкуються з зовнішнім світом.

Тепер Давайте поглянемо на самі юніт тести. Ось приклад одного з них:

@Test public void reportsLostIfAuctionClosesWhenBidding() {
allowingSniperBidding();
ignoringAuction();

context.checking(new Expectations() {{
atLeast(1).of(sniperListener).sniperStateChanged(
new SniperSnapshot(ITEM_ID, 123, 168, LOST));

when(sniperState.is("bidding"));
}});

sniper.currentPrice(123, 45, PriceSource.FromOtherBidder);
sniper.auctionClosed();
}

По-перше, цей тест фокусується на комунікаціях між класами, що призводить до необхідності створювати і підтримувати значна кількість коду, пов'язаного з створенням моков, але це тут не найголовніше. Головний недолік тут в тому, що цей тест містить інформацію про деталі імплементації досліджуваного об'єкта. Оператор when тут означає, що тест знає про внутрішній стан системи і імітує цей стан для того, щоб протестувати його.

Ось ще один приклад:

private final Mockery context = new Mockery();
private final SniperLauncher launcher =
new SniperLauncher(auctionHouse, sniperCollector);
private final States auctionState =
context.states("auction state").startsAs("not joined");

@Test public void
addsNewSniperToCollectorAndThenJoinsauction() {
final Item item = new Item("item 123", 456);

context.checking(new Expectations() {{
allowing(auctionHouse).auctionFor(item); will(returnValue(auction));

oneOf(auction).addAuctionEventListener(with(sniperForItem(item)));
when(auctionState.is("not joined"));

oneOf(sniperCollector).addSniper(with(sniperForItem(item)));
when(auctionState.is("not joined"));

one(auction).join(); then(auctionState.is("joined"));
}});

launcher.joinAuction(item);
}

Цей код — чіткий приклад витоку знань про деталі імплементації системи. Тест у цьому прикладі реалізує повноцінну state машину для перевірки того, що досліджуваний клас викликає методи своїх сусідів в конкретно цьому порядку (останні три рядки):

public class SniperLauncher implements UserRequestListener {
public void joinAuction(Item item) {
Auction auction = auctionHouse.auctionFor(item);
AuctionSniper sniper = new AuctionSniper(item, auction);
auction.addAuctionEventListener(sniper); // These
collector.addSniper(sniper); // three
auction.join(); // lines
}
}

З-за високої пов'язаності з нутрощами досліджуваної системи, тести зразок цього дуже крихкі. Будь нетривіальний рефакторинг призведе до їх падіння незавимисо від того, зламав цей рефакторинг що-небудь чи ні. Це в свою чергу суттєво знижує їх цінність, т. к. тести часто видають помилкові спрацьовування і з-за цього перестають сприйматися як частина надійної safety net.

Повний вихідний код проекту з книги можна знайти тут: посилання.

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

Для того, щоб зрозуміти, як проект може бути імплементований з належною ізоляцією доменної області, без циклічних залежностей і без надмірної кількості непотрібних абстракцій, давайте подивимося на функції додатка. Воно приймає події від сервера і реагує на них деякими командами, підтримуючи внутрішню state машину:

image

І це по суті все. У реальності, це практично ідеальна функціональна (functional programming) архітектура, і нам нічого не заважає імплементувати її як таку.

Ось як виглядає діаграма альтернативного рішення:

image

Давайте розглянемо декілька важливих відмінностей. По-перше, доменна модель повністю ізольована від зовнішнього світу. Класи в ній не кажуть прямо з view моделлю або з XMPP Server, всі посилання спрямовані доменним класами, а не навпаки.

Все спілкування з зовнішнім світом, будь то сервер або UI, віддано шару Application Services, роль якого в нашому випадку виконує AuctionSniperViewModel. Вона виступає як щит, що захищає доменну модель від небажаного впливу зовнішнього світу: фільтрує вхідні події та інтерпретує виходять команди.

По-друге, доменна модель не містить циклічних залежностей. Структура класів тут деревоподібна, а це означає, що потенційний новий розробник має чітке місце з якого він може почати читати цей код. Він може почати з литьев дерева і переходити вище по дереву крок за кроком, без необхідності розміщувати всю діаграму класів в його голові одноразово. Код конкретно цього проекту досить простий, звичайно, так що я впевнений ви не мали б проблем з читанням його навіть у разі наявності циклічних залежностей. Тим не менше, в більш складних випадках чітка деревоподібна структура — великий плюс в плані простоти і доступності.

До речі, відомий DDD патерн — Aggregate — націлений на розв'язання конкретно цієї проблеми. Групуючи кілька сутностей в єдиний агрегат, ми зменшуємо кількість зв'язків у доменній моделі і таким чином робимо код простіше.

Третій важливий момент тут — це те, що альтернативна версія не містить інтерфейсів. Це одна з переваг наявності повністю ізольованою доменної моделі: вам просто не треба привносити в код інтерфейси якщо вони не становлять реальну абстракцію. В даному прикладі, у нас немає таких абстракцій.

Класи в новій імплементації чітко розділені за їх призначенням. Вони або містять бізнес знання (business knowledge) — це класи всередині доменної моделі або спілкуються з зовнішнім світом — класи поза доменної моделі, — але ніколи і те й інше разом. Такий розподіл обов'язків дозволяє нам фокусуватися на одній проблемі в кожен момент часу: ми думаємо про доменної логікою, або вирішуємо як реагувати на стимули від UI і сервера аукціонів.

Знову ж таки, це спрощує код, а отже, робить його більш підтримуваним. Ось як виглядає найбільш важлива частина шару Application Services:

_chat.MessageReceived += ChatMessageRecieved;

private void ChatMessageRecieved(string message)
{
AuctionEvent ev = AuctionEvent.From(message);
AuctionCommand command = _auctionSniper.Process(ev);
if (command != AuctionCommand.None())
{
_chat.SendMessage(command.ToString());
}
}

Тут ми отримуємо рядок від сервера аукціонів, трансформуємо її в event (валідація включена в цей крок), передаємо його снайперу і якщо результирущая команда не None, посилаємо її назад сервера. Як можете бачити, відсутність бізнес-логіки робить шар Application Services тривіальним.

Тести без моков
Інша перевага ізольованою доменної моделі — це можливість протестувати її використовуючи функціональний підхід. Ми можемо розглядати кожну частину поведінки в ізоляції один від одного і перевіряти кінцевий результат, який вона генерує, без приділення уваги тому, як саме цей результат був досягнутий.

Наприклад, наступний тест перевіряє як Sniper, тільки що приєднався до аукціону, реагує на отримання події Close:

[Fact]
public void Joining_sniper_loses_when_auction_closes()
{
var sniper = new AuctionSniper("", 200);

AuctionCommand command = sniper.Process(AuctionEvent.Close());

command.ShouldEqual(AuctionCommand.None());
sniper.StateShouldBe(SniperState.Lost, 0, 0);
}

Він перевіряє, що результуюча команда порожня, що означає sniper не робить ніяких дій, і що стан стає Lost після цього.

Ось ще приклад:

[Fact]
public void Sniper_bids_when_price_event_with_a_different_bidder_arrives()
{
var sniper = new AuctionSniper("", 200);

AuctionCommand command = sniper.Process(AuctionEvent.Price(1, 2, "some bidder"));

command.ShouldEqual(AuctionCommand.Bid(3));
sniper.StateShouldBe(SniperState.Bidding, 1, 3);
}

Цей тест перевіряє, що снайпер відправляє заявку коли поточна ціна і мінімальний інкремент менше ніж встановлений цінову межу.

Єдине місце, де моки можуть потенційно бути виправдані — при тестуванні шару Application Services, який комунікує з зовнішніми системами. Але ця частина покрита end-to-end тестами, так що в даному конкретному випадку в цьому немає необхідності. До речі, end-to-end тести в книзі чудові, я не знайшов нічого, що можна було б у них змінити або поліпшити.

Вихідний код альтернативної імплементації можна знайти на тут.

Висновок
Фокус на комунікаціях між окремими класами призводить до крихким тестів, а також до збитку самій архітектурі проекту.

Щоб уникнути цих недоліків:

  • Не створюйте header інтерфейсів для доменних класів.
  • Мінімізуйте кількість циклічних залежностей в коді.
  • Ізолюйте доменну модель: не дозволяйте доменним класів комунікувати із зовнішнім світом.
  • Зменшуйте кількість непотрібних абстракцій.
  • Робіть упор на перевірці стану і кінцевого результату при тестуванні доменної моделі, не комунікаціях між класами.
Pluralsight курс
У мене тільки що вийшов новий курс на Pluralsight на тему прагматичного юніт-тестування. В ньому я спробував розповісти про практики побудови юніт тестів, що призводять до найкращого результату з найменшими зусиллями. Гайдлайны зі статті вище стали частиною цього курсу розглянуто в ньому детально, з приведенням безлічі прикладів.

У мене також є кілька десятків тріальних кодів, які дають необмежений доступ до Pluralsight терміном на 30 днів (до всієї бібліотеки, не тільки моєму курсом). Якщо комусь потрібен — пишіть в лічку, із задоволенням поділюся.

Посилання на курс: Building a Pragmatic Unit Test Suite.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.