Тестовете като предпазна мрежа
Накратко: Характеризиращите тестове (characterization tests) заковават какво прави старият код днес, затова хващат всяка неволна промяна на поведението, докато го подреждаш.
Проблем
Безобидната промяна
Магазинът смята доставката с функцията delivery_fee. Писана е преди три години, тестове няма. Доставката е 4,50 €, извън София е с 2 € повече, до Видин с още 3 €, а над 10 кг се плаща по 0,50 € за всеки килограм отгоре. От 50 € нагоре е безплатна.
def delivery_fee(total, kg, city): fee = 4.50 if city != "София": fee = fee + 2.00 if city == "Видин": fee = fee + 3.00 if kg > 10: fee = fee + (kg - 10) * 0.50 if total >= 50: fee = 0 return feeМиналия месец колега я „подреди“: щом поръчката е безплатна, защо да смятаме, и премести проверката най-отгоре. Написа total > 50 вместо total >= 50. Цяла седмица поръчките за точно 50 € плащаха доставка. Никой не забеляза, докато не дойдоха оплакванията.
Сега на теб ти трябва да добавиш нов град. Пипаш ли тази функция? Ако не я пипаш, всяко ново правило се лепи отгоре и кодът става все по-страшен. Ако я пипаш, може да счупиш нещо, което работи, и да не разбереш.
Идеята
Мрежа под въжето
Въжеиграчът върви по въжето спокойно, защото отдолу има мрежа. Ако падне, мрежата го хваща, преди да стане беля.
За кода мрежата са тестовете. Преди да смениш как е написан кодът, пишеш тестове, които казват какво прави той днес. После сменяш и пускаш тестовете. Зелено: поведението е същото. Червено: нещо се е сменило, и знаеш точно кое.
Такива тестове се казват характеризиращи (characterization tests): те не проверяват дали кодът е верен, а описват как се държи. Ако днес кодът прави нещо странно, тестът записва и странното. Това е предпазната мрежа: никакъв код не се пипа, докато под него няма тестове.
Виж го
Запиши какво прави днес
Не гадаеш какво би трябвало да връща функцията. Питаш нея. Една кратка програма я вика с няколко поръчки и печата отговорите:
print("20 €, 2 кг, София → ", delivery_fee(20, 2, "София"), sep="")print("20 €, 2 кг, Пловдив → ", delivery_fee(20, 2, "Пловдив"), sep="")print("20 €, 16 кг, София → ", delivery_fee(20, 16, "София"), sep="")print("50 €, 2 кг, Пловдив → ", delivery_fee(50, 2, "Пловдив"), sep="")print("60 €, 25 кг, Пловдив → ", delivery_fee(60, 25, "Пловдив"), sep="")20 €, 2 кг, София → 4.520 €, 2 кг, Пловдив → 6.520 €, 16 кг, София → 7.550 €, 2 кг, Пловдив → 060 €, 25 кг, Пловдив → 0Последният ред е изненада. Пакетът е 25 кг, а доставката е безплатна: безплатната доставка маха и надценката за тегло. Грешка ли е? Може би. Но днес е така, клиентите го ползват, и решението дали да се смени не е твое, докато подреждаш кода. Записваш го такова, каквото е.
Всеки ред от пробата става тест:
import unittest
from delivery import delivery_fee
class DeliveryTest(unittest.TestCase): def test_sofia(self): self.assertEqual(delivery_fee(20, 2, "София"), 4.5)
def test_outside_sofia(self): self.assertEqual(delivery_fee(20, 2, "Пловдив"), 6.5)
def test_heavy(self): self.assertEqual(delivery_fee(20, 16, "София"), 7.5)
def test_free_from_50(self): self.assertEqual(delivery_fee(50, 2, "Пловдив"), 0)
def test_free_even_when_heavy(self): # Така е днес: безплатната доставка маха и надценката за тегло. self.assertEqual(delivery_fee(60, 25, "Пловдив"), 0)ОК test_free_even_when_heavyОК test_free_from_50ОК test_heavyОК test_outside_sofiaОК test_sofiaТестове: 5, минали: 5, паднали: 0Пет теста, пет зелени. Мрежата е опъната.
По-точно, за любопитните
Тестовете тук се пускат както в урока за тестовете от модул „Програми в истинския свят“. Проверената програма слага функцията и теста в един файл (без реда from delivery import ...) и ги пуска с unittest, като подрежда резултатите по име. Така изходът е един и същ при всяко пускане.
Стойностите в теста са каквото Python отпечата. Последният 0 е цяло число, защото старият код пише fee = 0. За assertEqual 0 == 0.0 е вярно, но пробата показва точно какво връща функцията.
Виж го
Безобидната промяна под мрежата
Сега колегата прави същата промяна, но под мрежа. Проверката отива най-отгоре:
def delivery_fee(total, kg, city): if total > 50: return 0 fee = 4.50 if city != "София": fee = fee + 2.00 if city == "Видин": fee = fee + 3.00 if kg > 10: fee = fee + (kg - 10) * 0.50 return feeОК test_free_even_when_heavyОК test_heavyОК test_outside_sofiaОК test_sofiaПАДА test_free_from_50: 6.5 != 0Тестове: 5, минали: 4, паднали: 1Един тест е червен и казва точно кой случай: поръчка за 50 € извън София. Очаквано 0, а е 6,50 €. Грешката е хваната на бюрото, не от клиент.
class DeliveryTest(unittest.TestCase): def test_sofia(self): self.assertEqual(delivery_fee(20, 2, "София"), 4.5)
def test_outside_sofia(self): self.assertEqual(delivery_fee(20, 2, "Пловдив"), 6.5)
def test_heavy(self): self.assertEqual(delivery_fee(20, 16, "София"), 7.5)
def test_free_from_50(self): self.assertEqual(delivery_fee(50, 2, "Пловдив"), 0)
def test_free_even_when_heavy(self): # Така е днес: безплатната доставка маха и надценката за тегло. self.assertEqual(delivery_fee(60, 25, "Пловдив"), 0)Стъпка по стъпка, с думи
- Пет теста, по един за всеки ред от пробата. Всеки вика delivery_fee и сравнява с отговора, който дава днес.
- delivery_fee(20, 2, "София") връща 4.5. Тестът го заковава.
- delivery_fee(20, 2, "Пловдив") връща 6.5. Тестът го заковава.
- delivery_fee(20, 16, "София") връща 7.5. Тестът го заковава.
- delivery_fee(50, 2, "Пловдив") връща 0. Тестът го заковава.
- delivery_fee(60, 25, "Пловдив") връща 0, макар пакетът да е 25 кг. Странно, но днес е така: тестът го пази, не го поправя.
- Колегата мести проверката за безплатна доставка най-отгоре и пише > вместо >=. Мрежата се пуска цялата.
- test_free_from_50 пада: очаквано 0, а е 6.5. Поръчка за точно 50 € вече плаща доставка.
- С >= петте теста са зелени. Промяната е безопасна: мрежата я провери.
С >= всичко е зелено:
def delivery_fee(total, kg, city): if total >= 50: return 0 fee = 4.50 if city != "София": fee = fee + 2.00 if city == "Видин": fee = fee + 3.00 if kg > 10: fee = fee + (kg - 10) * 0.50 return feeОК test_free_even_when_heavyОК test_free_from_50ОК test_heavyОК test_outside_sofiaОК test_sofiaТестове: 5, минали: 5, паднали: 0Промяната е същата като онази, която мина незабелязано цяла седмица. Разликата е само мрежата.
Пробвай
Предвиди изхода
Някой сменя реда за надценката за тегло и забравя да извади първите 10 кг:
fee = fee + kg * 0.50Тестовете от картата „Запиши какво прави днес“ се пускат пак. Какво става?
def delivery_fee(total, kg, city): fee = 4.50 if city != "София": fee = fee + 2.00 if city == "Видин": fee = fee + 3.00 if kg > 10: fee = fee + kg * 0.50 if total >= 50: fee = 0 return feeПробвай
Какво пишеш като очаквано
Пробата отпечата за 60 €, 25 кг и Пловдив: 0. Ти си сигурен, че правилната доставка е 14 €. Какво пишеш в теста преди промяната?
def test_free_even_when_heavy(self):
self.assertEqual(delivery_fee(60, 25, "Пловдив"), )Пишеш каквото функцията връща днес. Тестът пази поведението, докато сменяш формата. Ако доставката наистина трябва да е 14 €, това е отделна промяна: питаш магазина, сменяш правилото и тогава сменяш и теста, нарочно и с отделен commit.
Идеята
Малки стъпки
Мрежата помага най-много, когато падаш от ниско. Ако смениш двадесет неща наведнъж и един тест падне, не знаеш кое от двадесетте е виновно. Ако смениш едно и тестът падне, виновникът е ясен.
Затова се работи в ритъм: една малка промяна, пускаш тестовете, зелено, следващата. Червено значи: върни последната промяна (Ctrl+Z или git checkout на файла) и я направи на още по-малки парчета. Така кодът почти през цялото време е в състояние, което работи.
Подреди стъпките:
Направи една малка промянаПусни тестовете пакПусни тестовете: всички са зелениДобави тестове за пътя, който ще пипашЗелено: запази (commit). Червено: върни само последната промяна
Пробвай
Намери грешката
Някой е написал теста за 50 € така. После колегата прави промяната с total > 50 от картата „Безобидната промяна под мрежата“:
class DeliveryTest(unittest.TestCase): def test_sofia(self): self.assertEqual(delivery_fee(20, 2, "София"), 4.5)
def test_outside_sofia(self): self.assertEqual(delivery_fee(20, 2, "Пловдив"), 6.5)
def test_heavy(self): self.assertEqual(delivery_fee(20, 16, "София"), 7.5)
def test_free_from_50(self): print(delivery_fee(50, 2, "Пловдив"))
def test_free_even_when_heavy(self): # Така е днес: безплатната доставка маха и надценката за тегло. self.assertEqual(delivery_fee(60, 25, "Пловдив"), 0)6.5ОК test_free_even_when_heavyОК test_free_from_50ОК test_heavyОК test_outside_sofiaОК test_sofiaТестове: 5, минали: 5, паднали: 0Грешната сума е отпечатана, а тестовете са зелени. На кой ред е грешката?
Избери реда с грешката.
Тестът печата резултата, но не го сравнява с нищо. Тест пада само когато проверка в него не е изпълнена, затова този е зелен при всеки резултат. Сложи проверка на мястото на печатането: self.assertEqual(..., 0).
Идеята
Какво мрежата не хваща
Мрежата хваща само там, където е опъната. За Видин тест няма. Някой „подрежда“ градовете с else if:
def delivery_fee(total, kg, city): fee = 4.50 if city == "Видин": fee = fee + 3.00 elif city != "София": fee = fee + 2.00 if kg > 10: fee = fee + (kg - 10) * 0.50 if total >= 50: fee = 0 return feeОК test_free_even_when_heavyОК test_free_from_50ОК test_heavyОК test_outside_sofiaОК test_sofiaТестове: 5, минали: 5, паднали: 0Видин преди: 9.5Видин след: 7.5Тестовете са зелени, а доставката до Видин поевтиня с 2 €: преди Видин плащаше и 2 € извън София, и 3 € отгоре. Никой тест не минава през този път, затова никой не пада.
Преди да пипнеш код, виж кои пътища имат тестове и добави за тези, които ще пипаш. Това показва покритието (coverage): кои редове са минали при пускането на тестовете.
Пакетът coverage.py:coverage run -m unittest, после coverage report -m показва редовете без тест. В PyCharm е „Run with Coverage“.В Python има още нещо: компилатор, който да прочете целия файл, няма. Някой преименува fee на price на ръка и го забравя в клона за Видин:
def delivery_fee(total, kg, city): price = 4.50 if city != "София": price = price + 2.00 if city == "Видин": price = fee + 3.00 if kg > 10: price = price + (kg - 10) * 0.50 if total >= 50: price = 0 return priceПетте теста пак са зелени: никой от тях не стига до този ред. Грешката излиза чак когато някой поръча до Видин:
try: delivery_fee(20, 2, "Видин")except NameError as error: print("Видин:", error)Видин: name 'fee' is not definedЗатова тук тестовете пазят още повече, отколкото в Java и C#.
По-точно, за любопитните
Покритие 100% не значи, че всеки случай е проверен. Редът с total >= 50 е „покрит“ и от тест с 60 €, но точно 50 € е отделен случай, и само тестът за 50 € хваща >. Покритието казва къде мрежата има дупки. Колко е гъста там, където я има, зависи от случаите, които си избрал: границите (50 €, 10 кг), всеки клон на if и странните стойности от пробата.
Командите за покритие тук не се пускат: проверката на сайта е без мрежа и без външни пакети.
Упражнение
Мрежа за Видин
Реши задачата в редактора си.
Мрежа за Видин. Добави два теста към тестовете от картата „Запиши какво прави днес“: поръчка за 20 €, 2 кг до Видин и поръчка за 50 €, 2 кг до Видин. Първо пусни пробата, за да видиш какво връща кодът днес, и закови това.
ОК test_free_even_when_heavyОК test_free_from_50ОК test_heavyОК test_outside_sofiaОК test_sofiaОК test_vidinОК test_vidin_free_from_50Тестове: 7, минали: 7, паднали: 0После направи промяната с else if от картата „Какво мрежата не хваща“. Сега тя трябва да падне:
ОК test_free_even_when_heavyОК test_free_from_50ОК test_heavyОК test_outside_sofiaОК test_sofiaОК test_vidin_free_from_50ПАДА test_vidin: 7.5 != 9.5Тестове: 7, минали: 6, паднали: 1Още задачи
- Границата на теглото. Добави тест за точно 10 кг и за 11 кг. После смени
kg > 10наkg >= 10. Пада ли нещо? Защо? - Новият град. Добави Варна с 1 € отгоре. Първо тест за Варна, който пада, после правилото, после пак всички тестове.
- Проба за всичко. Напиши пробата с цикъл по таблица от случаи (сума, тегло, град), така че нов случай да е нов ред в таблицата.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Тестове с unittest · Имена в кода · Функции: една работа, малко параметри, без скрити странични ефекти · Миризмите: дълъг метод, голям клас, дублиране, примитиви вместо типове, дълга верига if
- Продължава в: Refactoring на малки стъпки · Упражнение: разхвърлян клас, изчистен стъпка по стъпка · Зависимостите: чужд код, който пуска твоят
- Задачи по темата: Чист код и refactoring