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

От сценарий към стъпки

Накратко: Сценарият е текст. За да се пусне, всеки ред се свързва с метод в кода чрез израз: „количка за {сума} €“ хваща реда и подава сумата. Стъпките на един сценарий делят състояние: „Дадено“ записва количката, „Когато“ вика правилото, „То“ проверява. Хуковете вървят преди и след всеки сценарий. Инструментът е различен на всеки език, идеята е една.

Проблем

Сценарият е само текст

В модула за Gherkin написа сценариите за доставката. Проверката в курса ги пускаше срещу примерната система и казваше дали хващат грешките.

В истински проект такава проверка няма. Програмистът поглежда файла със сценариите и казва: „Компютърът не знае какво е „количка за 50,00 €“. Това е изречение на български.“ Прав е. Нещо трябва да преведе всеки ред в код.

Идеята

Дефиниции на стъпките

Ето сценариите. Във файла на проекта те са непроменени, същите, които бизнесът е прочел и приел:

delivery.feature
# language: bg
Функционалност: Безплатна доставка
Доставката е безплатна при поръчка от 50 € включително, иначе е 4,90 €.
Сценарий: Поръчка точно на границата
Дадено количка за 50,00 €
Когато поръчам
То доставката е безплатна
Сценарий: Поръчка малко под границата
Дадено количка за 49,99 €
Когато поръчам
То доставката е 4,90 €
Сценарий: Празна количка
Дадено празна количка
Когато поръчам
То поръчката е отказана

Всеки ред се свързва с метод в кода. Методът се казва дефиниция на стъпка (step definition). Над него стои текстът, който хваща:

src/test/java/shop/DeliverySteps.java
@Given("количка за {сума} €")
public void cart(int cents) {
total = cents;
}

Инструментът чете сценария ред по ред, намира дефиницията, чийто текст пасва, и я вика. Инструментите са различни на всеки език, но идеята е една:

  • Cucumber-JVM за Java;
  • Reqnroll за C# (наследникът на SpecFlow);
  • behave за Python;
  • cucumber-js за JavaScript.
По-точно, за любопитните

Cucumber е оригиналът, от Ruby. Cucumber-JVM и cucumber-js са официалните му версии, а Reqnroll и behave са отделни проекти със същата идея и същия Gherkin. В Cucumber-JVM анотациите са @Given, @When, @Then, но коя от трите е над метода, всъщност не е важно: текстът решава. Затова „Дадено“ от сценарий на български се хваща от @Given.

Ключовите думи на Gherkin на много езици, български включително, са в официалния речник на Gherkin. Редът # language: bg в началото казва на инструмента кои да чете; без него се приема английски.

Идеята

Изразите и типовете параметри

количка за {сума} € хваща всяка сума. {сума} е параметър: мястото, където е числото, и правилото как текстът става стойност. Сумата се пази в центове, като цяло число: „49,99“ става 4999. Така няма грешки от закръгляне.

Типът се описва веднъж, с регулярен израз („цифри, запетая, две цифри“) и функция, която прави числото:

src/test/java/shop/DeliverySteps.java
@ParameterType(name = "сума", value = "\\d+,\\d{2}")
public int amount(String text) {
return Integer.parseInt(text.replace(",", ""));
}

Изразите със {…} се казват Cucumber expressions. Те са по-четими от регулярните изрази. Вградени типове има за цели числа и думи ({int}, {word}), а сумата в евро със запетая е тип на проекта.

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

По подразбиране behave не ползва Cucumber expressions, а библиотеката parse, където параметърът се пише {total:Amount}, а типът се регистрира с register_type. (behave 1.3 може да ползва и Cucumber expressions, но тази поддръжка още е експериментална.) Reqnroll приема и двата вида изрази. В Reqnroll регулярният израз на типа е в скоби, защото стойността идва от групата в него.

Ако никоя дефиниция не хваща ред, стъпката е неопределена (undefined), и сценарият не минава. Инструментите обикновено отпечатват празна дефиниция, която да копираш. Ако го хващат две, стъпката е двусмислена, и това е грешка.

Пробвай

Кой ред, коя дефиниция?

1. Стъпката е „Дадено количка за 49,99 €“. Коя дефиниция я хваща?
2. В сценарий е написано „Дадено количка за 50 €“, без стотинките. Какво става?
3. Двама програмисти написаха една и съща дефиниция в два файла. Какво казва инструментът?

Идеята

Състоянието на един сценарий

„Когато поръчам“ няма сума в реда си. Откъде знае каква е количката? От състоянието на сценария: „Дадено“ го записва, „Когато“ го ползва, „То“ го проверява.

„Когато“ вика правилото. Ако то откаже с изключение, отказът се записва, а не спира сценария. Това „То поръчката е отказана“ ще провери:

src/test/java/shop/DeliverySteps.java
@When("поръчам")
public void order() {
try {
fee = Delivery.feeFor(total);
} catch (IllegalArgumentException e) {
refusal = e.getMessage();
}
}

„То“ сверява с очакваното. Тук е проверката, която може да падне:

src/test/java/shop/DeliverySteps.java
@Then("доставката е {сума} €")
public void feeIs(int cents) {
assertEquals(cents, fee);
}

Състоянието е ново за всеки сценарий. В Java и C# инструментът прави нов обект от класа със стъпките за всеки сценарий. В JavaScript обектът this (World) е нов, а в behave каквото сценарият сложи в context, се маха, когато сценарият свърши. Сценарият не бива да зависи от друг: всеки се пуска сам и в произволен ред.

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

В Java и C# състоянието са полетата на класа:

src/test/java/shop/DeliverySteps.java
// Състоянието на един сценарий: за всеки сценарий се прави нов обект.
private int total;
private int fee;
private String refusal;

Когато стъпките станат много, те се делят в няколко класа. Тогава общото състояние е в отделен обект, който инструментът подава на всеки клас (в Cucumber-JVM с библиотека за подаване на зависимости като PicoContainer, в Reqnroll с вградения му контейнер). В behave context и в cucumber-js this (обектът World) вече са общи за всички файлове със стъпки.

Идеята

Хуковете

Понякога нещо трябва да стане преди или след всеки сценарий: да се изчисти базата, да се отвори връзка, да се запише какво е станало. Това са хуковете (hooks). Тук хукът след сценария записва с каква количка е паднал, ако е паднал:

src/test/java/shop/DeliverySteps.java
// След всеки сценарий: ако е паднал, записва с каква количка.
@After
public void showCart(Scenario scenario) {
if (scenario.isFailed()) {
System.out.println("количка: " + total + " цента");
}
}

Едно правило: хукът подготвя и почиства, но не проверява и не крие стъпки, които са важни за читателя. Ако всеки сценарий иска количка, това е „Предистория“ в сценария, където бизнесът я вижда, а не хук.

Пробвай

Редът на изпълнение

Подреди какво вика инструментът за сценария „Поръчка точно на границата“.

  1. Когато поръчам
  2. Дадено количка за 50,00 €
  3. Хукът след сценария
  4. Хукът преди сценария
  5. То доставката е безплатна

Идеята

Пускането

Ето правилото, срещу което вървят сценариите:

src/main/java/shop/Delivery.java
package shop;
public class Delivery {
public static final int FREE_FROM = 5000;
public static final int FEE = 490;
// Таксата за доставка в центове. total е сумата на количката, също в центове.
public static int feeFor(int total) {
if (total <= 0) {
throw new IllegalArgumentException("Празна количка не се поръчва");
}
return total >= FREE_FROM ? 0 : FEE;
}
}

Инструментът пуска трите сценария. Всички минават:

src/test/java/shop/DeliverySteps.java
package shop;
import io.cucumber.java.After;
import io.cucumber.java.ParameterType;
import io.cucumber.java.Scenario;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class DeliverySteps {
// Състоянието на един сценарий: за всеки сценарий се прави нов обект.
private int total;
private int fee;
private String refusal;
// {сума} в стъпките: „49,99“ става 4999 цента.
@ParameterType(name = "сума", value = "\\d+,\\d{2}")
public int amount(String text) {
return Integer.parseInt(text.replace(",", ""));
}
@Given("количка за {сума} €")
public void cart(int cents) {
total = cents;
}
@Given("празна количка")
public void emptyCart() {
total = 0;
}
@When("поръчам")
public void order() {
try {
fee = Delivery.feeFor(total);
} catch (IllegalArgumentException e) {
refusal = e.getMessage();
}
}
@Then("доставката е {сума} €")
public void feeIs(int cents) {
assertEquals(cents, fee);
}
@Then("доставката е безплатна")
public void free() {
assertEquals(0, fee);
}
@Then("поръчката е отказана")
public void refused() {
assertNotNull(refusal, "поръчката е приета");
}
// След всеки сценарий: ако е паднал, записва с каква количка.
@After
public void showCart(Scenario scenario) {
if (scenario.isFailed()) {
System.out.println("количка: " + total + " цента");
}
}
}
Изход
ОК Поръчка малко под границата
ОК Поръчка точно на границата
ОК Празна количка
Сценарии: 3, минали: 3, паднали: 0

Сега програмистът сменя >= на >. Същата грешка, която в модула за Gherkin беше мутант, сега е в истинския код. Сценарият на границата пада, а хукът казва с каква количка:

src/main/java/shop/Delivery.java
package shop;
public class Delivery {
public static final int FREE_FROM = 5000;
public static final int FEE = 490;
// Таксата за доставка в центове. total е сумата на количката, също в центове.
public static int feeFor(int total) {
if (total <= 0) {
throw new IllegalArgumentException("Празна количка не се поръчва");
}
return total > FREE_FROM ? 0 : FEE;
}
}
Изход
количка: 5000 цента
ОК Поръчка малко под границата
ОК Празна количка
ПАДА Поръчка точно на границата: expected: <0> but was: <490>
Сценарии: 3, минали: 2, паднали: 1

Съобщението е различно на всеки език, защото всеки инструмент го казва по своему. Смисълът е един: очакваната такса е 0, получената е 490 цента.

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

Изходът тук е кратък отчет, а не обичайният изход на инструмента: един ред за всеки сценарий, подредени по име, и броят. Сценариите в курса се пускат наистина, с Cucumber-JVM 8, Reqnroll 3, behave 1.3 и cucumber-js 13, но в обвивка, която печата само този отчет. В проект инструментът печата повече: всяка стъпка, времената и къде точно е паднало.

Анотациите в Java (@Given, @After) и атрибутите в C# ([Given], [AfterScenario]) са тези от модула „Програми в истинския свят“: етикети над метода, които инструментът намира с reflection.

Упражнение

Стъпките в работа

1. Промени и капани. Въпросите са за стъпките на доставката по-горе.

1. Отделът по маркетинг иска стъпка „То поръчката е приета“ и в сценариите за доставката. Какво трябва да пипнеш?
2. Сценарият „Празна количка“ минава, макар че в кода е изтрит отказът. Защо може да е така?
3. Сумата в стъпките се пази като 49,99 (число с плаваща запетая) вместо в центове. Кой риск се появява?

Тест

Провери се

1. Какво е дефиниция на стъпка?
2. Как „Когато поръчам“ знае каква е количката?
3. За какво е хукът „след сценария“ в урока?
4. Сценариите минават на правилния код, а на кода с „>“ вместо „>=“ пада един. Какво показва това?

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