Защо считам TDD за чист код и как да го прилагам правилно

Вече написах малка статия за чист код тук. Обяснявам и показвам прост пример за това какво е чист код и как да го постигнете.

Но от моя гледна точка чистият код не може да съществува без тестване и за мен единственият начин за тестване е прилагането на TDD.

Какво е TDD (Test Driven Development)?

С прости думи, TDD означава, че ще напишете тест, преди да кодирате свързания производствен код. Когато този код е готов и премине теста ви, преработете, след това, когато тестът ви е преминат отново, напишете нов тест.

Защо е важно?

  • Наличието на тестово покритие ще ви помогне да преработите своя код. Тъй като имате тестове, за да разберете дали вашият рефакторинг не е счупил нищо, можете да го направите както искате. Можете да поемете риска да преработите, без риск, защото е тествано!
  • Писането на тест преди писане на производствен код ще ви накара да напишете само частта от кода, от която се нуждаете, без мъртъв код. Ако ограничите количеството код, вие очевидно го правите по-ясен, толкова по-чист.
  • В TDD вашите тестове са „базирани на намерение“. Това означава, че вашите тестове ще опишат какво представлява частта от кода ви, без да са наясно с детайлните реализации. Това има две предимства, първо, рефакторингът няма да прекъсва всичките ви тестове всеки път (защото тестовете не са наясно с имплементацията). Второ, вашите тестове ще служат като жива кодова документация.

Какво ще стане, ако тествам без TDD подход?

  • Писането на тест след код ще ви накара да напишете мъртъв код. Ще мислите по-мащабно, отколкото наистина имате нужда, когато кодирате. Очакване на някакво условие или абстракция, които никога няма да съществуват в производството, така че да добавите повече сложност към вашия код.
  • Когато пишете тест след това, може (и по-често, отколкото си мислите) да забравите намерението на вашия код. Така че вашият тест няма да обхваща намерение, а техническо мислене. Когато вашите тестове се превърнат в технически ориентирани, те очевидно стават по-зависими от изпълнението на детайлите.
  • Когато вашите тестове станат безсмислени, те се превръщат в дълг. Те губят аспекта на документацията, защото описват техническо изпълнение вместо намерение. Те също така губят отделянето от внедряването, това означава, че когато преработвате производствения си код, ще счупите твърде много тестове.
  • Вашите тестове може да получат добро покритие на кода, но няма да покрият намерението. Така че тестовете за мутации вероятно ще ви покажат колко слаби са вашите тестове. (Тестването на мутации е техника, която променя някои условия във вашия код, например инструкция „==“ ще стане „!=“, така че ако вашите тестове все още преминават след тези промени, вашите тестове са само покритие, те не тестват истинският код/намерение)

Как работи?

Направих бързо описание на TDD потока в първата част. И това е основният смисъл на TDD.

Като първо направите тест, разкрийте намерение, което още не е изпълнено. Създайте невалиден тест за него, след което накарайте производствения си код да работи с това ново намерение. Като първа стъпка, направете го бързо, не добре. Когато успеете, вашият тест преминава! След това вече можете да приложите рефакторинг, за да го направите добре (ориентиран към чист код).

Вашите тестове трябва да се основават на намерение, като „банкова сметка не може да бъде отрицателна“ повече от „Банковото хранилище не може да намалява повече от наличното“

Защо? В първия случай имате критерии за приемане, ориентирани към потребителя. Това ще ви помогне да разберете какво ще тествате и да го свържете с бизнес правило.

Във втория случай, въпреки че означава същото, вие не разбирате бизнес значението на теста. Така че, ако този тест се провали по-късно, поради рефакторинг, може да не разберете защо се провали, как да го коригирате... Дори по-лошо, тестът говори за хранилище и налична сума. Изглежда като подробности за изпълнението. Не е толкова лошо, но това естествено ще ви накара да използвате тези понятия във вашия тест, правейки го зависим от внедряване (свойство Repository и AvailableAmount).

Какво трябва да тествам?

Простият отговор е всичко. В TDD не можете да пишете код, ако нямате неуспешен тест. С изключение на рефакторинга, но рефакторингът не добавя функции, това е просто опростяване на съществуващ код.

Тестване на случаите на използване

Като цяло започвам от случая на употреба. Това има смисъл, тъй като е описание на характеристиките. „В чистата архитектура“ случаят на използване е команда, отговаряща на нуждите на потребителя. Основава се на намерение!

Изчакайте?! Точно това търсим в TDD. Тестовете също се основават на намерение! Така че тази част е по-лесна, просто пишете тест за всички критерии за приемане, които имате за тези функции.

Вземете пример с тази проста функция: „Клиент иска да изпрати банков превод“

Критериите са:

  • Клиентът трябва да е клиент на нашата банка
  • Банковата сметка на клиента трябва да има повече пари от това, което изпраща
  • Банковата сметка на клиента трябва да бъде дебитирана със сумата на превода

Така че нашият първи тест ще изглежда така:

Ние използваме създател на контекст, като правим разделение между детайлната реализация и тестовете. Също така е добра практика да направите теста си по-вербален. Когато чете този код, всеки може да разбере при какви условия ще се проведе този тест. Така че целта на теста е ясна!

След това пишете производствения код. Вероятно команда с хранилище на интерфейс, дадено на банковата сметка на клиент. Така че вашите тестове ще инжектират някаква подигравана реализация на това хранилище или ще използват някаква библиотека като Substitute, за да създадат средата, описана от теста.

Добре, тестът премина! Поздравления ! Тъй като това е първата стъпка, която написахме, няма какво да преработваме, но ако смятаме, че го правим твърде бързо и твърде грозно, време е да преработим! Когато тестът премине отново, вие сте готови!

Сега напишете нов тест!

Както виждате, добавихме нов метод в моя контекст за указване на текущата налична сума на клиента. Разбира се, в сравнение с първия тест, този път клиентът е съществуващ клиент. Разбира се отново, тестът е неуспешен за сега! Така че нека напишем малко производствен код!

Този път написахме много малко код. Тъй като командата и някои от тези параметри/зависимости вече са създадени или абстрахирани, ние просто добавихме реда код, за да повдигнем доброто изключение. Нашият тест премина, но този път ще направя малко рефакторинг, за да го направя по-чист:

Добавихме нов метод към обекта „Акаунт“, за да знаем дали сумата може да бъде дебитирана. И нашият тест отново е зелен!

Последният тест:

Този път контекстът описва съществуващ клиент с достатъчно пари. Моите очаквания са клиентската сметка да бъде дебитирана. Това е свързаният производствен код:

Всички наши тестове са зелени! Но мисля, че можем да направим малко по-добре, можем да капсулираме „дебитната операция“ върху обекта на сметката.

Нашите тестове са все още зелени! Поздравления! Но не забравяйте да направите теста си толкова чист, колкото производствения ви код! И така, има една последна стъпка!

Това отново е малка промяна, но чрез създаването на нова променлива за име на очакваната стойност, целта на теста е по-разкрита.

Няколко думи за „конструктора на тестов контекст“:

Както виждате на екраните по-горе, тестовете се пишат с помощта на нещо, наречено „създател на контекст“. Това позволява на кода да бъде по-смислен и да бъде отделен от детайлната реализация. Създателят на контекст разбира се ще знае някои подробности, той ще подготви контекста за изпълнение за теста. Подготовка на връщане на стойност на някакъв интерфейсен договор и др.

Ако внедряването ни се промени по-късно, например, ние не използваме хранилище за съхраняване на нашия банков превод, но излъчваме събитие на домейн. Просто ще трябва да променим този детайл в инструмента за създаване на контекст, а не във всички наши тестове!

За да обобщим, основните две предимства са:

  • По-чист тестов код, тестовият код разкрива намерението като името на теста.
  • Тестовете са отделени от детайлите на производствения код. Ако някои реализации се променят, не е нужно да променяте всичките си тестове! Просто създателят на контекст.

Тестване на инфраструктурата

За да тествате инфраструктурата си, просто трябва да тествате интерфейсното хранилище, дефинирано във вашия домейн! Тъй като договорът за домейн ще внедри вашата инфраструктура и тъй като не искате да бъдете свързвани с подробностите за внедряването, просто трябва да тествате тези договори!

Няма да показвам „ритъма“, както правя за частта за случая на употреба. Предполагам, че схващате идеята за това „как работи“. Така че ще говоря за техниките за тестване на инфраструктурния слой.

  • Подигравка на инфраструктурния отговор: като външен API отговор или резултат от заявка към база данни. От моя гледна точка най-добрият вариант в TDD. Тъй като всъщност не искаме интеграционни тестове, искаме БЪРЗИ тестове. Тестове, които се изпълняват много бързо, да се изпълняват често. Тестове, които разкриват намерението на договора за интерфейс, а не да проверяват дали някои бази данни наистина са достъпни.
  • Изпълнение на тестове с контейнер: използвайки някаква библиотека като „тестов контейнер“, можете да създадете среда за тестване и да приложите вашите тестове. Това е доста добър вариант, тестът е сравнително бърз, може да се изпълни локално, ако разработчикът има докер екземпляр. И има някои добри аспекти на истински интеграционен тест.
  • Изпълнение на тестове в тестова среда: това е интеграционен тест! Мисля, че провеждането на някои интеграционни тестове, за да проверите дали нямате конфигурационна грешка например, е добра практика, но това не трябва да е вашият начин да тествате договора си за интерфейс! Това е бавно, може да не можете да ги изпълните, когато разработвате, и не е базирано на намерение, а на техническа основа.

За да възобновите, подигравайте се с инфраструктурния отговор, за да поддържате тестовете си бързи и смислени. Можете да добавите някои „тестове за контейнери“, за да проверите конкретен инфраструктурен код. Като сложна заявка. След това добавете малко интеграционни тестове, само за да проверите дали цялата ви среда изглежда добре (казвам „погледнете“, защото в случай че най-добрите тестове за среда не могат да ви уверят, че сте добри за внедряване).

Тестване на API/изпълним файл:

Това е последната част. Вашият бизнес/случаи на употреба са добре тествани, така че приложението ви е почти напълно тествано. Но все пак е добра практика да добавите някои тестове, като интеграционни тестове (в паметта или с помощта на някаква среда), за да сте сигурни, че вашият API или вашият изпълним файл са добре конфигурирани, приложете добрите опции за сигурност и т.н.

Пример за повторно разпределение на тестове

Домейнът не е „добре тестван“, защото трябва да бъде обхванат от случая на използване (които използват обект на домейн). Така че тестовете на домейна са просто тестове за конкретен случай, когато вашият обект на домейн е твърде сложен, за да бъде тестван/обяснен от вашите тестове за случаи на използване.

Разбира се, това е примерно преразпределение. Но вие разбирате глобалната идея зад това.