Тестове на ниво компонент
Накратко: Сценарият е за примерите, които бизнесът е договорил и иска да чете. Тестът на ниво компонент вика кода направо: десет суми за милисекунди, без стъпки и без Gherkin. Двата вида проверяват едно правило за различни читатели. Сценариите остават малко, а тестовете на компонент поемат техническите случаи.
Проблем
Петнадесет сценария за една граница
Програмистът реши да провери доставката добре: 1 цент, 10 €, 49,49 €, 49,50 €, 49,99 €, 50 €, 50,01 €, 100 €, 250 €, отрицателна сума. Записа всичко като сценарии.
Собственикът на магазина отвори файла със сценариите, за да види правилото за доставката. Видя петнадесет почти еднакви сценария и затвори файла. Живата документация вече не се четеше.
Идеята
Тестът вика кода направо
От курса по програмиране познаваш тестовете на ниво компонент: метод, който вика кода и сверява резултата. За правилото за доставката няма нужда от Gherkin и от стъпки. Тестът вика метода направо:
@ParameterizedTest(name = "feeFor({0}) = {1}")@CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" })void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total));}[Theory][InlineData(4999, 490)][InlineData(5000, 0)][InlineData(1, 490)][InlineData(25000, 0)]public void FeeFor(int total, int fee){ Assert.Equal(fee, Delivery.FeeFor(total));}@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == feeconst cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}Това е параметризиран тест: една проверка и таблица от случаи. Прилича на рамката на сценарий с „Примери“, но е за програмиста. Нов случай е нов ред в таблицата.
Отказът се проверява с отделен тест. Правилото хвърля изключение, и тестът проверява, че го хвърля:
@Test@DisplayName("празна количка се отказва")void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0));}[Fact(DisplayName = "празна количка се отказва")]public void EmptyCartIsRefused(){ Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0));}def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)test("празна количка се отказва", () => { assert.throws(() => deliveryFee(0));});По-точно, за любопитните
Инструментите са стандартните за всеки език: JUnit 5 (параметризираните тестове са в модула junit-jupiter-params), xUnit с [Theory] и [InlineData], pytest и вграденият в Node node:test. В курса по програмиране Python тестовете бяха с unittest. pytest е по-разпространеният: тестът е обикновена функция с assert, а parametrize прави таблицата.
Идеята
Кое правило на какво ниво
Правилото е едно. Проверките са на две нива, и всяко има своя читател:
- Сценарият е за примерите, които бизнесът е договорил: точно 50,00 €, 49,99 €, празна количка. По един-два примера на правило. Той е живата документация и бизнесът го чете.
- Тестът на ниво компонент е за техническите случаи: 1 цент, голяма сума, отрицателна сума, закръгляне. Те пазят кода, но не казват нищо ново за правилото.
Това, че границата е проверена и на двете нива, не е загуба. Сценарият казва какво е договорено. Тестът на компонента пада първи и точно там, където е грешката.
Правилото за избор е кратко: би ли бизнесът искал да прочете този пример? Ако да, сценарий. Ако не, тест на ниво компонент.
Пробвай
Сценарий или тест на компонент?
Идеята
Пускането
Целият файл с тестовете, пуснат срещу правилния код. Всичко минава:
package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;
class DeliveryTest { @ParameterizedTest(name = "feeFor({0}) = {1}") @CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" }) void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total)); }
@Test @DisplayName("празна количка се отказва") void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0)); }}ОК feeFor(1) = 490ОК feeFor(25000) = 0ОК feeFor(4999) = 490ОК feeFor(5000) = 0ОК празна количка се отказваТестове: 5, минали: 5, паднали: 0using Xunit;
namespace Shop;
public class DeliveryTest{ [Theory] [InlineData(4999, 490)] [InlineData(5000, 0)] [InlineData(1, 490)] [InlineData(25000, 0)] public void FeeFor(int total, int fee) { Assert.Equal(fee, Delivery.FeeFor(total)); }
[Fact(DisplayName = "празна количка се отказва")] public void EmptyCartIsRefused() { Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0)); }}ОК FeeFor(total: 1, fee: 490)ОК FeeFor(total: 25000, fee: 0)ОК FeeFor(total: 4999, fee: 490)ОК FeeFor(total: 5000, fee: 0)ОК празна количка се отказваТестове: 5, минали: 5, паднали: 0import pytest
from delivery import delivery_fee
@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == fee
def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)ОК test_empty_cart_is_refusedОК test_fee_for_total[1-490]ОК test_fee_for_total[25000-0]ОК test_fee_for_total[4999-490]ОК test_fee_for_total[5000-0]Тестове: 5, минали: 5, паднали: 0import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
const cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}
test("празна количка се отказва", () => { assert.throws(() => deliveryFee(0));});ОК deliveryFee(1) = 490ОК deliveryFee(25000) = 0ОК deliveryFee(4999) = 490ОК deliveryFee(5000) = 0ОК празна количка се отказваТестове: 5, минали: 5, паднали: 0Сега програмистът „опростява“ сравнението и закръгля сумата до цели евро преди него. 49,99 € става 50 €, и доставката става безплатна:
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 Math.round(total / 100.0) * 100 >= 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 Math.Round(total / 100.0) * 100 >= FreeFrom ? 0 : Fee; }}FREE_FROM = 5000FEE = 490
def delivery_fee(total): """Таксата за доставка в центове. total е сумата на количката, също в центове.""" if total <= 0: raise ValueError("Празна количка не се поръчва") return 0 if round(total / 100) * 100 >= FREE_FROM else FEEexport const FREE_FROM = 5000;export const FEE = 490;
// Таксата за доставка в центове. total е сумата на количката, също в центове.export function deliveryFee(total) { if (total <= 0) { throw new Error("Празна количка не се поръчва"); } return Math.round(total / 100) * 100 >= FREE_FROM ? 0 : FEE;}Същите тестове, срещу новия код:
package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;
class DeliveryTest { @ParameterizedTest(name = "feeFor({0}) = {1}") @CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" }) void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total)); }
@Test @DisplayName("празна количка се отказва") void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0)); }}ОК feeFor(1) = 490ОК feeFor(25000) = 0ОК feeFor(5000) = 0ОК празна количка се отказваПАДА feeFor(4999) = 490: expected: <490> but was: <0>Тестове: 5, минали: 4, паднали: 1using Xunit;
namespace Shop;
public class DeliveryTest{ [Theory] [InlineData(4999, 490)] [InlineData(5000, 0)] [InlineData(1, 490)] [InlineData(25000, 0)] public void FeeFor(int total, int fee) { Assert.Equal(fee, Delivery.FeeFor(total)); }
[Fact(DisplayName = "празна количка се отказва")] public void EmptyCartIsRefused() { Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0)); }}ОК FeeFor(total: 1, fee: 490)ОК FeeFor(total: 25000, fee: 0)ОК FeeFor(total: 5000, fee: 0)ОК празна количка се отказваПАДА FeeFor(total: 4999, fee: 490): Assert.Equal() Failure: Values differ Expected: 490 Actual: 0Тестове: 5, минали: 4, паднали: 1import pytest
from delivery import delivery_fee
@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == fee
def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)ОК test_empty_cart_is_refusedОК test_fee_for_total[1-490]ОК test_fee_for_total[25000-0]ОК test_fee_for_total[5000-0]ПАДА test_fee_for_total[4999-490]: assert 0 == 490Тестове: 5, минали: 4, паднали: 1import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
const cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}
test("празна количка се отказва", () => { assert.throws(() => deliveryFee(0));});ОК deliveryFee(1) = 490ОК deliveryFee(25000) = 0ОК deliveryFee(5000) = 0ОК празна количка се отказваПАДА deliveryFee(4999) = 490: 0 !== 490Тестове: 5, минали: 4, паднали: 1Пада точно един случай: 49,99 €. Името на теста казва сумата, а съобщението казва очакваното и полученото. Не трябва да търсиш коя стъпка от кой сценарий.
По-точно, за любопитните
Изходът тук е кратък отчет, подреден по име: инструментите се пускат наистина, но в обвивка, която печата само реда за всеки тест и броя. Имената на тестовете са различни на всеки език, защото всеки инструмент ги прави по своему: JUnit от name на параметризирания тест, xUnit от името на метода и аргументите, pytest от името на функцията и стойностите, node:test от текста, който му даваш.
Идеята
Защо не всичко с Gherkin
Gherkin е за разговора с бизнеса: примерите, договорени от трите амиго, записани така, че всеки да ги чете. Като инструмент за тестове той е скъп:
- всеки нов ред в сценарий иска стъпка, а стъпките се поддържат;
- сценарият минава през повече код, затова е по-бавен;
- когато падне, причината е по-далеч от съобщението.
И най-важното: петнадесет технически сценария скриват трите, които казват правилото. Живата документация е жива, докато някой я чете.
Затова сценариите са малко и казват правилото. Тестовете на ниво компонент са много и пазят кода. Това е и пирамидата: широка основа от бързи тестове, а отгоре по-малко, по-бавни и по-близо до клиента.
По-точно, за любопитните
Аслак Хелесьой, създателят на Cucumber, е писал неведнъж, че Cucumber е преди всичко инструмент за общ език и сътрудничество, а тестовете са страничен резултат. Ако сценариите не се пишат и четат заедно с бизнеса, остава скъп начин да се пишат тестове.
Упражнение
Двете нива
1. Кой тест какво пази. Въпросите са за сценариите от предишния урок и тестовете от този.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Нивата на тестване · Тестването в жизнения цикъл · Защо и какво да автоматизираш · От сценарий към стъпки
- Продължава в: API и потребителски интерфейс · Тестовете в CI · Промо кодове: от заявката до пускането
- Задачи по темата: Автоматизация