Към съдържанието
wannacode

Тестовете като предпазна мрежа

Накратко: Характеризиращите тестове (characterization tests) заковават какво прави старият код днес, затова хващат всяка неволна промяна на поведението, докато го подреждаш.

Проблем

Безобидната промяна

Магазинът смята доставката с метода Delivery.Fee. Писана е преди три години, тестове няма. Доставката е 4,50 €, извън София е с 2 € повече, до Видин с още 3 €, а над 10 кг се плаща по 0,50 € за всеки килограм отгоре. От 50 € нагоре е безплатна.

public static class Delivery
{
public static decimal Fee(decimal total, int kg, string city)
{
decimal fee = 4.50m;
if (city != "София")
{
fee = fee + 2.00m;
}
if (city == "Видин")
{
fee = fee + 3.00m;
}
if (kg > 10)
{
fee = fee + (kg - 10) * 0.50m;
}
if (total >= 50)
{
fee = 0;
}
return fee;
}
}

Миналия месец колега я „подреди“: щом поръчката е безплатна, защо да смятаме, и премести проверката най-отгоре. Написа total > 50 вместо total >= 50. Цяла седмица поръчките за точно 50 € плащаха доставка. Никой не забеляза, докато не дойдоха оплакванията.

Сега на теб ти трябва да добавиш нов град. Пипаш ли този метод? Ако не я пипаш, всяко ново правило се лепи отгоре и кодът става все по-страшен. Ако я пипаш, може да счупиш нещо, което работи, и да не разбереш.

Идеята

Мрежа под въжето

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

За кода мрежата са тестовете. Преди да смениш как е написан кодът, пишеш тестове, които казват какво прави той днес. После сменяш и пускаш тестовете. Зелено: поведението е същото. Червено: нещо се е сменило, и знаеш точно кое.

Такива тестове се казват характеризиращи (characterization tests): те не проверяват дали кодът е верен, а описват как се държи. Ако днес кодът прави нещо странно, тестът записва и странното. Това е предпазната мрежа: никакъв код не се пипа, докато под него няма тестове.

Виж го

Запиши какво прави днес

Не гадаеш какво би трябвало да връща методът. Питаш него. Една кратка програма я вика с няколко поръчки и печата отговорите:

Console.WriteLine("20 €, 2 кг, София → " + Delivery.Fee(20, 2, "София"));
Console.WriteLine("20 €, 2 кг, Пловдив → " + Delivery.Fee(20, 2, "Пловдив"));
Console.WriteLine("20 €, 16 кг, София → " + Delivery.Fee(20, 16, "София"));
Console.WriteLine("50 €, 2 кг, Пловдив → " + Delivery.Fee(50, 2, "Пловдив"));
Console.WriteLine("60 €, 25 кг, Пловдив → " + Delivery.Fee(60, 25, "Пловдив"));
Изход
20 €, 2 кг, София → 4.50
20 €, 2 кг, Пловдив → 6.50
20 €, 16 кг, София → 7.50
50 €, 2 кг, Пловдив → 0
60 €, 25 кг, Пловдив → 0

Последният ред е изненада. Пакетът е 25 кг, а доставката е безплатна: безплатната доставка маха и надценката за тегло. Грешка ли е? Може би. Но днес е така, клиентите го ползват, и решението дали да се смени не е твое, докато подреждаш кода. Записваш го такова, каквото е.

Всеки ред от пробата става тест:

using Xunit;
public class DeliveryTest
{
[Fact]
public void Sofia()
{
Assert.Equal(4.50m, Delivery.Fee(20, 2, "София"));
}
[Fact]
public void OutsideSofia()
{
Assert.Equal(6.50m, Delivery.Fee(20, 2, "Пловдив"));
}
[Fact]
public void Heavy()
{
Assert.Equal(7.50m, Delivery.Fee(20, 16, "София"));
}
[Fact]
public void FreeFrom50()
{
Assert.Equal(0m, Delivery.Fee(50, 2, "Пловдив"));
}
[Fact]
public void FreeEvenWhenHeavy()
{
// Така е днес: безплатната доставка маха и надценката за тегло.
Assert.Equal(0m, Delivery.Fee(60, 25, "Пловдив"));
}
}
Резултат
ОК FreeEvenWhenHeavy
ОК FreeFrom50
ОК Heavy
ОК OutsideSofia
ОК Sofia
Тестове: 5, минали: 5, паднали: 0

Пет теста, пет зелени. Мрежата е опъната.

По-точно, за любопитните

Тестовете тук се пускат както в урока за тестовете от модул „Програми в истинския свят“. Проверената програма е проект с Delivery.cs, DeliveryTest.cs и Runner.cs, който пуска xUnit през AssemblyRunner и подрежда резултатите по име. Така изходът е един и същ при всяко пускане.

Стойностите в теста са каквото C# отпечата. Парите са decimal, затова 4.50m е точно 4.50, а 0m е нула.

Виж го

Безобидната промяна под мрежата

Сега колегата прави същата промяна, но под мрежа. Проверката отива най-отгоре:

public static class Delivery
{
public static decimal Fee(decimal total, int kg, string city)
{
if (total > 50)
{
return 0;
}
decimal fee = 4.50m;
if (city != "София")
{
fee = fee + 2.00m;
}
if (city == "Видин")
{
fee = fee + 3.00m;
}
if (kg > 10)
{
fee = fee + (kg - 10) * 0.50m;
}
return fee;
}
}
Резултат
ОК FreeEvenWhenHeavy
ОК Heavy
ОК OutsideSofia
ОК Sofia
ПАДА FreeFrom50
Assert.Equal() Failure: Values differ
Expected: 0
Actual: 6.50
Тестове: 5, минали: 4, паднали: 1

Един тест е червен и казва точно кой случай: поръчка за 50 € извън София. Очаквано 0, а е 6,50 €. Грешката е хваната на бюрото, не от клиент.

public class DeliveryTest
{
[Fact]
public void Sofia()
{
Assert.Equal(4.50m, Delivery.Fee(20, 2, "София"));
}
[Fact]
public void OutsideSofia()
{
Assert.Equal(6.50m, Delivery.Fee(20, 2, "Пловдив"));
}
[Fact]
public void Heavy()
{
Assert.Equal(7.50m, Delivery.Fee(20, 16, "София"));
}
[Fact]
public void FreeFrom50()
{
Assert.Equal(0m, Delivery.Fee(50, 2, "Пловдив"));
}
[Fact]
public void FreeEvenWhenHeavy()
{
// Така е днес: безплатната доставка маха и надценката за тегло.
Assert.Equal(0m, Delivery.Fee(60, 25, "Пловдив"));
}
}
Стъпка по стъпка, с думи
  1. Пет теста, по един за всеки ред от пробата. Всеки вика Delivery.Fee и сравнява с отговора, който дава днес.
  2. Delivery.Fee(20, 2, "София") връща 4.50. Тестът го заковава.
  3. Delivery.Fee(20, 2, "Пловдив") връща 6.50. Тестът го заковава.
  4. Delivery.Fee(20, 16, "София") връща 7.50. Тестът го заковава.
  5. Delivery.Fee(50, 2, "Пловдив") връща 0. Тестът го заковава.
  6. Delivery.Fee(60, 25, "Пловдив") връща 0, макар пакетът да е 25 кг. Странно, но днес е така: тестът го пази, не го поправя.
  7. Колегата мести проверката за безплатна доставка най-отгоре и пише > вместо >=. Мрежата се пуска цялата.
  8. FreeFrom50 пада: очаквано 0, а е 6.50. Поръчка за точно 50 € вече плаща доставка.
  9. С >= петте теста са зелени. Промяната е безопасна: мрежата я провери.

С >= всичко е зелено:

public static class Delivery
{
public static decimal Fee(decimal total, int kg, string city)
{
if (total >= 50)
{
return 0;
}
decimal fee = 4.50m;
if (city != "София")
{
fee = fee + 2.00m;
}
if (city == "Видин")
{
fee = fee + 3.00m;
}
if (kg > 10)
{
fee = fee + (kg - 10) * 0.50m;
}
return fee;
}
}
Резултат
ОК FreeEvenWhenHeavy
ОК FreeFrom50
ОК Heavy
ОК OutsideSofia
ОК Sofia
Тестове: 5, минали: 5, паднали: 0

Промяната е същата като онази, която мина незабелязано цяла седмица. Разликата е само мрежата.

Пробвай

Предвиди изхода

Някой сменя реда за надценката за тегло и забравя да извади първите 10 кг:

fee = fee + kg * 0.50m;

Тестовете от картата „Запиши какво прави днес“ се пускат пак. Какво става?

public static class Delivery
{
public static decimal Fee(decimal total, int kg, string city)
{
decimal fee = 4.50m;
if (city != "София")
{
fee = fee + 2.00m;
}
if (city == "Видин")
{
fee = fee + 3.00m;
}
if (kg > 10)
{
fee = fee + kg * 0.50m;
}
if (total >= 50)
{
fee = 0;
}
return fee;
}
}
Отговор

Пробвай

Какво пишеш като очаквано

Пробата отпечата за 60 €, 25 кг и Пловдив: 0. Ти си сигурен, че правилната доставка е 14 €. Какво пишеш в теста преди промяната?

[Fact]
public void FreeEvenWhenHeavy()
{
    Assert.Equal(, Delivery.Fee(60, 25, "Пловдив"));
}

Идеята

Малки стъпки

Мрежата помага най-много, когато падаш от ниско. Ако смениш двадесет неща наведнъж и един тест падне, не знаеш кое от двадесетте е виновно. Ако смениш едно и тестът падне, виновникът е ясен.

Затова се работи в ритъм: една малка промяна, пускаш тестовете, зелено, следващата. Червено значи: върни последната промяна (Ctrl+Z или git checkout на файла) и я направи на още по-малки парчета. Така кодът почти през цялото време е в състояние, което работи.

Подреди стъпките:

  1. Направи една малка промяна
  2. Пусни тестовете пак
  3. Пусни тестовете: всички са зелени
  4. Добави тестове за пътя, който ще пипаш
  5. Зелено: запази (commit). Червено: върни само последната промяна

Пробвай

Намери грешката

Някой е написал теста за 50 € така. После колегата прави промяната с total > 50 от картата „Безобидната промяна под мрежата“:

public class DeliveryTest
{
[Fact]
public void Sofia()
{
Assert.Equal(4.50m, Delivery.Fee(20, 2, "София"));
}
[Fact]
public void OutsideSofia()
{
Assert.Equal(6.50m, Delivery.Fee(20, 2, "Пловдив"));
}
[Fact]
public void Heavy()
{
Assert.Equal(7.50m, Delivery.Fee(20, 16, "София"));
}
[Fact]
public void FreeFrom50()
{
Console.WriteLine(Delivery.Fee(50, 2, "Пловдив"));
}
[Fact]
public void FreeEvenWhenHeavy()
{
// Така е днес: безплатната доставка маха и надценката за тегло.
Assert.Equal(0m, Delivery.Fee(60, 25, "Пловдив"));
}
}
Резултат
6.50
ОК FreeEvenWhenHeavy
ОК FreeFrom50
ОК Heavy
ОК OutsideSofia
ОК Sofia
Тестове: 5, минали: 5, паднали: 0

Грешната сума е отпечатана, а тестовете са зелени. На кой ред е грешката?

Избери реда с грешката.

Идеята

Какво мрежата не хваща

Мрежата хваща само там, където е опъната. За Видин тест няма. Някой „подрежда“ градовете с else if:

public static class Delivery
{
public static decimal Fee(decimal total, int kg, string city)
{
decimal fee = 4.50m;
if (city == "Видин")
{
fee = fee + 3.00m;
}
else if (city != "София")
{
fee = fee + 2.00m;
}
if (kg > 10)
{
fee = fee + (kg - 10) * 0.50m;
}
if (total >= 50)
{
fee = 0;
}
return fee;
}
}
Тестовете
ОК FreeEvenWhenHeavy
ОК FreeFrom50
ОК Heavy
ОК OutsideSofia
ОК Sofia
Тестове: 5, минали: 5, паднали: 0
Видин преди и след
Видин преди: 9.50
Видин след: 7.50

Тестовете са зелени, а доставката до Видин поевтиня с 2 €: преди Видин плащаше и 2 € извън София, и 3 € отгоре. Никой тест не минава през този път, затова никой не пада.

Преди да пипнеш код, виж кои пътища имат тестове и добави за тези, които ще пипаш. Това показва покритието (coverage): кои редове са минали при пускането на тестовете.

Шаблонът dotnet new xunit вече има coverlet.collector: dotnet test --collect:"XPlat Code Coverage" пише отчет, който ReportGenerator прави на страница. Rider и Visual Studio Enterprise показват покритието в редактора.Едно нещо обаче компилаторът пази и без тест. Ако преименуваш fee на ръка и го забравиш в клона за Видин, програмата не се компилира: fee вече не съществува. Грешката е намерена, преди да има тест.
По-точно, за любопитните

Покритие 100% не значи, че всеки случай е проверен. Редът с total >= 50 е „покрит“ и от тест с 60 €, но точно 50 € е отделен случай, и само тестът за 50 € хваща >. Покритието казва къде мрежата има дупки. Колко е гъста там, където я има, зависи от случаите, които си избрал: границите (50 €, 10 кг), всеки клон на if и странните стойности от пробата.

Командите за покритие тук не се пускат: проверката на сайта е без мрежа и без външни пакети.

Упражнение

Мрежа за Видин

Реши задачата в редактора си.

Мрежа за Видин. Добави два теста към тестовете от картата „Запиши какво прави днес“: поръчка за 20 €, 2 кг до Видин и поръчка за 50 €, 2 кг до Видин. Първо пусни пробата, за да видиш какво връща кодът днес, и закови това.

Резултат
ОК FreeEvenWhenHeavy
ОК FreeFrom50
ОК Heavy
ОК OutsideSofia
ОК Sofia
ОК Vidin
ОК VidinFreeFrom50
Тестове: 7, минали: 7, паднали: 0

После направи промяната с else if от картата „Какво мрежата не хваща“. Сега тя трябва да падне:

Резултат
ОК FreeEvenWhenHeavy
ОК FreeFrom50
ОК Heavy
ОК OutsideSofia
ОК Sofia
ОК VidinFreeFrom50
ПАДА Vidin
Assert.Equal() Failure: Values differ
Expected: 9.50
Actual: 7.50
Тестове: 7, минали: 6, паднали: 1

Още задачи

  • Границата на теглото. Добави тест за точно 10 кг и за 11 кг. После смени kg > 10 на kg >= 10. Пада ли нещо? Защо?
  • Новият град. Добави Варна с 1 € отгоре. Първо тест за Варна, който пада, после правилото, после пак всички тестове.
  • Проба за всичко. Напиши пробата с цикъл по таблица от случаи (сума, тегло, град), така че нов случай да е нов ред в таблицата.

Тест

Провери се

1. Пробата показва, че поръчка за 60 € с пакет от 25 кг се доставя безплатно. Според теб това е грешка. Какво пишеш в теста преди промяната?
2. Пет теста минават. Пренареждаш функцията и един пада. Какво правиш?
3. Защо стъпките са малки и тестовете се пускат след всяка?
4. Всички тестове са зелени. Значи ли това, че промяната не е сменила нищо?
5. Тест вика функцията и печата резултата, без да го сравнява. Какво пази той?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·