От сценарий към стъпки
Накратко: Сценарият е текст. За да се пусне, всеки ред се свързва с метод в кода чрез израз: „количка за {сума} €“ хваща реда и подава сумата. Стъпките на един сценарий делят състояние: „Дадено“ записва количката, „Когато“ вика правилото, „То“ проверява. Хуковете вървят преди и след всеки сценарий. Инструментът е различен на всеки език, идеята е една.
Проблем
Сценарият е само текст
В модула за Gherkin написа сценариите за доставката. Проверката в курса ги пускаше срещу примерната система и казваше дали хващат грешките.
В истински проект такава проверка няма. Програмистът поглежда файла със сценариите и казва: „Компютърът не знае какво е „количка за 50,00 €“. Това е изречение на български.“ Прав е. Нещо трябва да преведе всеки ред в код.
Идеята
Дефиниции на стъпките
Ето сценариите. Във файла на проекта те са непроменени, същите, които бизнесът е прочел и приел:
# language: bgФункционалност: Безплатна доставка Доставката е безплатна при поръчка от 50 € включително, иначе е 4,90 €.
Сценарий: Поръчка точно на границата Дадено количка за 50,00 € Когато поръчам То доставката е безплатна
Сценарий: Поръчка малко под границата Дадено количка за 49,99 € Когато поръчам То доставката е 4,90 €
Сценарий: Празна количка Дадено празна количка Когато поръчам То поръчката е отказанаВсеки ред се свързва с метод в кода. Методът се казва дефиниция на стъпка (step definition). Над него стои текстът, който хваща:
@Given("количка за {сума} €")public void cart(int cents) { total = cents;}[Given("количка за {сума} €")]public void Cart(int cents) => total = cents;@given("количка за {total:Amount} €")def step_cart(context, total): context.total = totalGiven("количка за {сума} €", function (cents) { this.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. Така няма грешки от закръгляне.
Типът се описва веднъж, с регулярен израз („цифри, запетая, две цифри“) и функция, която прави числото:
@ParameterType(name = "сума", value = "\\d+,\\d{2}")public int amount(String text) { return Integer.parseInt(text.replace(",", ""));}[StepArgumentTransformation(@"(\d+,\d{2})", Name = "сума")]public int Amount(string text) => int.Parse(text.Replace(",", ""));def parse_amount(text): # {сума} в стъпките: „49,99“ става 4999 цента. return int(text.replace(",", ""))
parse_amount.pattern = r"\d+,\d{2}"register_type(Amount=parse_amount)defineParameterType({ name: "сума", regexp: /\d+,\d{2}/, transformer: (text) => Number(text.replace(",", "")),});Изразите със {…} се казват Cucumber expressions. Те са по-четими от регулярните изрази. Вградени типове има за цели числа и думи ({int}, {word}), а сумата в евро със запетая е тип на проекта.
По-точно, за любопитните
По подразбиране behave не ползва Cucumber expressions, а библиотеката parse, където параметърът се пише {total:Amount}, а типът се регистрира с register_type. (behave 1.3 може да ползва и Cucumber expressions, но тази поддръжка още е експериментална.) Reqnroll приема и двата вида изрази. В Reqnroll регулярният израз на типа е в скоби, защото стойността идва от групата в него.
Ако никоя дефиниция не хваща ред, стъпката е неопределена (undefined), и сценарият не минава. Инструментите обикновено отпечатват празна дефиниция, която да копираш. Ако го хващат две, стъпката е двусмислена, и това е грешка.
Пробвай
Кой ред, коя дефиниция?
Идеята
Състоянието на един сценарий
„Когато поръчам“ няма сума в реда си. Откъде знае каква е количката? От състоянието на сценария: „Дадено“ го записва, „Когато“ го ползва, „То“ го проверява.
„Когато“ вика правилото. Ако то откаже с изключение, отказът се записва, а не спира сценария. Това „То поръчката е отказана“ ще провери:
@When("поръчам")public void order() { try { fee = Delivery.feeFor(total); } catch (IllegalArgumentException e) { refusal = e.getMessage(); }}[When("поръчам")]public void Order(){ try { fee = Delivery.FeeFor(total); } catch (ArgumentException e) { refusal = e.Message; }}@when("поръчам")def step_order(context): try: context.fee = delivery_fee(context.total) except ValueError as e: context.refusal = str(e)When("поръчам", function () { try { this.fee = deliveryFee(this.total); } catch (e) { this.refusal = e.message; }});„То“ сверява с очакваното. Тук е проверката, която може да падне:
@Then("доставката е {сума} €")public void feeIs(int cents) { assertEquals(cents, fee);}[Then("доставката е {сума} €")]public void FeeIs(int cents) => Assert.Equal(cents, fee);@then("доставката е {fee:Amount} €")def step_fee_is(context, fee): assert context.fee == fee, f"доставката е {context.fee}, очаквано {fee}"Then("доставката е {сума} €", function (cents) { assert.equal(this.fee, cents);});Състоянието е ново за всеки сценарий. В Java и C# инструментът прави нов обект от класа със стъпките за всеки сценарий. В JavaScript обектът this (World) е нов, а в behave каквото сценарият сложи в context, се маха, когато сценарият свърши. Сценарият не бива да зависи от друг: всеки се пуска сам и в произволен ред.
По-точно, за любопитните
В Java и C# състоянието са полетата на класа:
// Състоянието на един сценарий: за всеки сценарий се прави нов обект.private int total;private int fee;private String refusal;// Състоянието на един сценарий: за всеки сценарий се прави нов обект.private int total;private int fee;private string refusal;Когато стъпките станат много, те се делят в няколко класа. Тогава общото състояние е в отделен обект, който инструментът подава на всеки клас (в Cucumber-JVM с библиотека за подаване на зависимости като PicoContainer, в Reqnroll с вградения му контейнер). В behave context и в cucumber-js this (обектът World) вече са общи за всички файлове със стъпки.
Идеята
Хуковете
Понякога нещо трябва да стане преди или след всеки сценарий: да се изчисти базата, да се отвори връзка, да се запише какво е станало. Това са хуковете (hooks). Тук хукът след сценария записва с каква количка е паднал, ако е паднал:
// След всеки сценарий: ако е паднал, записва с каква количка.@Afterpublic void showCart(Scenario scenario) { if (scenario.isFailed()) { System.out.println("количка: " + total + " цента"); }}// След всеки сценарий: ако е паднал, записва с каква количка.[AfterScenario]public void ShowCart(){ if (scenario.TestError != null) { Console.WriteLine("количка: " + total + " цента"); }}def before_scenario(context, scenario): # Преди всеки сценарий: нищо не е отказано. context.refusal = None
def after_scenario(context, scenario): # След всеки сценарий: ако е паднал, записва с каква количка. if scenario.status == "failed": print(f"количка: {context.total} цента")import { After, Before, Status } from "@cucumber/cucumber";
// Преди всеки сценарий: нищо не е отказано.Before(function () { this.refusal = null;});
// След всеки сценарий: ако е паднал, записва с каква количка.After(function ({ result }) { if (result.status === Status.FAILED) { console.log(`количка: ${this.total} цента`); }});Едно правило: хукът подготвя и почиства, но не проверява и не крие стъпки, които са важни за читателя. Ако всеки сценарий иска количка, това е „Предистория“ в сценария, където бизнесът я вижда, а не хук.
Пробвай
Редът на изпълнение
Подреди какво вика инструментът за сценария „Поръчка точно на границата“.
Когато поръчамДадено количка за 50,00 €Хукът след сценарияХукът преди сценарияТо доставката е безплатна
Хуковете обграждат сценария. Между тях стъпките вървят в реда, в който са написани. После следващият сценарий започва с нов хук преди и с ново състояние.
Идеята
Пускането
Ето правилото, срещу което вървят сценариите:
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; }}namespace Shop;
public static class Delivery{ public const int FreeFrom = 5000; public const int Fee = 490;
// Таксата за доставка в центове. total е сумата на количката, също в центове. public static int FeeFor(int total) { if (total <= 0) { throw new ArgumentException("Празна количка не се поръчва"); } return total >= FreeFrom ? 0 : Fee; }}FREE_FROM = 5000FEE = 490
def delivery_fee(total): """Таксата за доставка в центове. total е сумата на количката, също в центове.""" if total <= 0: raise ValueError("Празна количка не се поръчва") return 0 if total >= FREE_FROM else FEEexport const FREE_FROM = 5000;export const FEE = 490;
// Таксата за доставка в центове. total е сумата на количката, също в центове.export function deliveryFee(total) { if (total <= 0) { throw new Error("Празна количка не се поръчва"); } return total >= FREE_FROM ? 0 : FEE;}Инструментът пуска трите сценария. Всички минават:
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, паднали: 0using Reqnroll;using Xunit;
namespace Shop;
[Binding]public class DeliverySteps(ScenarioContext scenario){ // Състоянието на един сценарий: за всеки сценарий се прави нов обект. private int total; private int fee; private string refusal;
// {сума} в стъпките: „49,99“ става 4999 цента. [StepArgumentTransformation(@"(\d+,\d{2})", Name = "сума")] public int Amount(string text) => int.Parse(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 (ArgumentException e) { refusal = e.Message; } }
[Then("доставката е {сума} €")] public void FeeIs(int cents) => Assert.Equal(cents, fee);
[Then("доставката е безплатна")] public void Free() => Assert.Equal(0, fee);
[Then("поръчката е отказана")] public void Refused() => Assert.NotNull(refusal);
// След всеки сценарий: ако е паднал, записва с каква количка. [AfterScenario] public void ShowCart() { if (scenario.TestError != null) { Console.WriteLine("количка: " + total + " цента"); } }}ОК Поръчка малко под границатаОК Поръчка точно на границатаОК Празна количкаСценарии: 3, минали: 3, паднали: 0from behave import given, register_type, then, when
from delivery import delivery_fee
def parse_amount(text): # {сума} в стъпките: „49,99“ става 4999 цента. return int(text.replace(",", ""))
parse_amount.pattern = r"\d+,\d{2}"register_type(Amount=parse_amount)
@given("количка за {total:Amount} €")def step_cart(context, total): context.total = total
@given("празна количка")def step_empty_cart(context): context.total = 0
@when("поръчам")def step_order(context): try: context.fee = delivery_fee(context.total) except ValueError as e: context.refusal = str(e)
@then("доставката е {fee:Amount} €")def step_fee_is(context, fee): assert context.fee == fee, f"доставката е {context.fee}, очаквано {fee}"
@then("доставката е безплатна")def step_free(context): assert context.fee == 0, f"доставката е {context.fee}, очаквано 0"
@then("поръчката е отказана")def step_refused(context): assert context.refusal, "поръчката е приета"ОК Поръчка малко под границатаОК Поръчка точно на границатаОК Празна количкаСценарии: 3, минали: 3, паднали: 0import { Given, When, Then, defineParameterType } from "@cucumber/cucumber";import assert from "node:assert/strict";import { deliveryFee } from "../../delivery.js";
// {сума} в стъпките: „49,99“ става 4999 цента.defineParameterType({ name: "сума", regexp: /\d+,\d{2}/, transformer: (text) => Number(text.replace(",", "")),});
Given("количка за {сума} €", function (cents) { this.total = cents;});
Given("празна количка", function () { this.total = 0;});
When("поръчам", function () { try { this.fee = deliveryFee(this.total); } catch (e) { this.refusal = e.message; }});
Then("доставката е {сума} €", function (cents) { assert.equal(this.fee, cents);});
Then("доставката е безплатна", function () { assert.equal(this.fee, 0);});
Then("поръчката е отказана", function () { assert.ok(this.refusal, "поръчката е приета");});ОК Поръчка малко под границатаОК Поръчка точно на границатаОК Празна количкаСценарии: 3, минали: 3, паднали: 0Сега програмистът сменя >= на >. Същата грешка, която в модула за Gherkin беше мутант, сега е в истинския код. Сценарият на границата пада, а хукът казва с каква количка:
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, паднали: 1namespace Shop;
public static class Delivery{ public const int FreeFrom = 5000; public const int Fee = 490;
// Таксата за доставка в центове. total е сумата на количката, също в центове. public static int FeeFor(int total) { if (total <= 0) { throw new ArgumentException("Празна количка не се поръчва"); } return total > FreeFrom ? 0 : Fee; }}количка: 5000 центаОК Поръчка малко под границатаОК Празна количкаПАДА Поръчка точно на границата: Assert.Equal() Failure: Values differ Expected: 0 Actual: 490Сценарии: 3, минали: 2, паднали: 1FREE_FROM = 5000FEE = 490
def delivery_fee(total): """Таксата за доставка в центове. total е сумата на количката, също в центове.""" if total <= 0: raise ValueError("Празна количка не се поръчва") return 0 if total > FREE_FROM else FEEколичка: 5000 центаОК Поръчка малко под границатаОК Празна количкаПАДА Поръчка точно на границата: ASSERT FAILED: доставката е 490, очаквано 0Сценарии: 3, минали: 2, паднали: 1export const FREE_FROM = 5000;export const FEE = 490;
// Таксата за доставка в центове. total е сумата на количката, също в центове.export function deliveryFee(total) { if (total <= 0) { throw new Error("Празна количка не се поръчва"); } return total > FREE_FROM ? 0 : FEE;}количка: 5000 центаОК Поръчка малко под границатаОК Празна количкаПАДА Поръчка точно на границата: 490 !== 0Сценарии: 3, минали: 2, паднали: 1Съобщението е различно на всеки език, защото всеки инструмент го казва по своему. Смисълът е един: очакваната такса е 0, получената е 490 цента.
По-точно, за любопитните
Изходът тук е кратък отчет, а не обичайният изход на инструмента: един ред за всеки сценарий, подредени по име, и броят. Сценариите в курса се пускат наистина, с Cucumber-JVM 8, Reqnroll 3, behave 1.3 и cucumber-js 13, но в обвивка, която печата само този отчет. В проект инструментът печата повече: всяка стъпка, времената и къде точно е паднало.
Анотациите в Java (@Given, @After) и атрибутите в C# ([Given], [AfterScenario]) са тези от модула „Програми в истинския свят“: етикети над метода, които инструментът намира с reflection.
Упражнение
Стъпките в работа
1. Промени и капани. Въпросите са за стъпките на доставката по-горе.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Рамка на сценарий и Примери · Жива документация · Гранични стойности · Защо и какво да автоматизираш
- Продължава в: Тестове на ниво компонент · API и потребителски интерфейс · Тестовете в CI · Промо кодове: от заявката до пускането
- Задачи по темата: Автоматизация