Obsah
1. Behavior-driven development v Pythonu s využitím knihovny Behave (druhá část)
2. Využití dat zapsaných do tabulek
3. Zápis dat ve formě tabulky do testovacího scénáře
5. Výsledek běhu prvního demonstračního příkladu
6. Výsledek běhu příkladu ve chvíli, kdy je výpočet nekorektní
7. Druhý demonstrační příklad – konverze měn (směnné kurzy)
8. Implementace jednotlivých kroků testu
9. Výsledek běhu druhého demonstračního příkladu
10. Výsledek běhu příkladu ve chvíli, kdy je výpočet převodu měn nekorektní
11. Kombinace předchozích příkladů – tabulky v Given i v osnově testů
12. Implementace jednotlivých kroků testu
13. Výsledek běhu pátého demonstračního příkladu
14. Výsledky ve chvíli, kdy je výpočet převodu měn naprogramován nekorektně
15. Testovací scénáře pro kontrolu funkčnosti REST API
16. Soubory environment.py a common.py
17. Spuštění testu se zobrazením výsledků
18. Výsledek testu ve chvíli, kdy je REST API nedostupné
19. Repositář s demonstračními příklady
1. Behavior-driven development v Pythonu s využitím knihovny Behave (druhá část)
V prvním článku o knihovně Behave, jejímž úkolem je integrace doménově specifického jazyka Gherkin do Pythonu, jsme si ukázali, jakým způsobem se zapisují jednotlivé testovací scénáře a jak se jednotlivé kroky těchto scénářů mapují na funkce definované přímo v Pythonu. Také jsme si řekli, jaký je význam takzvaného testovacího kontextu, tj. objektu, který drží informace o stavu testů. Tento objekt většinou potřebujeme z toho důvodu, že testovací scénáře zapisované v jazyku Gherkin vlastně odpovídají konečnému automatu. Dnes si ukážeme některé další možnosti tohoto jazyka. Pravděpodobně nejužitečnější je podpora pro deklaraci dat s využitím tabulek, které se velmi jednoduše zapisují do testovacích scénářů. S tabulkami jsme se již setkali při popisu tzv. osnov (scenario outline), ovšem jejich použití je poněkud širší, což uvidíme již v prvním demonstračním příkladu popsaném v navazujících kapitolách.
2. Využití dat zapsaných do tabulek
První demonstrační příklad bude velmi jednoduchý. Budeme se v něm snažit otestovat funkci nazvanou sum, které se předá seznam číselných hodnot (popř. celočíselných, hodnot s plovoucí řádovou čárkou, komplexních čísel) a jejím výsledkem by měl být součet těchto hodnot. Tuto funkci je samozřejmě možné napsat různými způsoby; pod tímto odstavcem je zobrazena varianta funkce zapsaná funkcionálně (interně se postupně zpracovávají jednotlivé prvky seznamu, aplikuje se na ně zvolená funkce a výsledek se ukládá do akumulátoru):
from functools import reduce def sum(numbers): return reduce(lambda x, y: x+y, numbers)
Nyní si vytvoříme projekt, v němž bude umístěn jak modul s testovanou funkcí, tak i konfigurace knihovny Behave, vlastní testovací scénář i modul implementující jednotlivé kroky testů. Struktura celého projektu bude vypadat následovně (to pro nás není nic nového, protože jsme projekty s podobnou strukturou používali již v předchozím článku):
├── feature_list.txt ├── features │ ├── steps │ │ └── common.py │ └── sum.feature ├── requirements.in ├── requirements.txt ├── run_tests.sh └── src └── sum.py
Připravit je nutné soubor requirements.txt obsahující seznam knihoven, na nichž náš projekt závisí:
behave pytest
Dále si pro jistotu ukažme obsah skriptu, který slouží pro spuštění testů. V tomto skriptu je možné řídit, zda se mají testy spustit ve virtuálním prostředí Pythonu (tím pádem instalace proběhnou v rámci lokálního adresáře), nebo zda se virtuální prostředí nemá použít (v tomto případě je ovšem nutné, aby byly všechny potřebné knihovny již nainstalovány, ať již pro celý systém přes pip3 install … nebo pro aktivního uživatele pomocí pip3 install –user …):
#!/bin/bash -ex export NOVENV=1 function prepare_venv() { virtualenv -p python3 venv && source venv/bin/activate && python3 `which pip3` install -r requirements.txt } [ "$NOVENV" == "1" ] || prepare_venv || exit 1 PYTHONDONTWRITEBYTECODE=1 python3 `which behave` --tags=-skip -D dump_errors=true @feature_list.txt $@
3. Zápis dat ve formě tabulky do testovacího scénáře
Nejzajímavější bude způsob zápisu testovacího scénáře. Nyní totiž již nebude praktické použít přístup známý z minula, kdy jsme testovali mnohem jednodušší funkci add se dvěma vstupy. U funkce sum akceptující obecně libovolně dlouhý seznam bude výhodnější (= přehlednější, stručnější) použít pro deklaraci vstupních dat tabulku. Tu lze umístit přímo do klauzule Given, kam se tato „přípravná fáze“ testů obvykle umisťuje. Tabulku zapíšeme jednoduše způsobem, který připomíná zápis tabulek v AsciiDocu či podobném formátu:
Feature: Sum function test 1 Scenario: Check the function sum() Given a list of integers |value | | 1 | | 10 | | 100 | | 1000 |
Následně kroky testovacího scénáře již jsou snadno pochopitelné – v klauzuli When je specifikováno volání testované funkce, v klauzuli Then pak porovnání s očekávaným výsledkem:
When I summarize all those integers Then I should get 1111 as a result
Podobně můžeme zapsat i testy pro menší počet prvků v tabulce, aby bylo možné otestovat chování testované funkce i v těchto nepatrně mezních případech:
Scenario: Check the function sum() for two inputs only Given a list of integers |value | | 1 | | 2 | When I summarize all those integers Then I should get 3 as a result
popř.:
Scenario: Check the function sum() for one input only Given a list of integers |value | | 42 | When I summarize all those integers Then I should get 42 as a result
Všechny výše uvedené kroky jsou zapsány do souboru sum.feature, který je do struktury projektu umístěn následovně:
├── feature_list.txt ├── features │ ├── steps │ │ └── common.py │ └── sum.feature ├── requirements.in ├── requirements.txt ├── run_tests.sh └── src └── sum.py
4. Načtení dat z tabulky
Samotný testovací scénář již máme zapsaný, ovšem mnohem důležitější je jeho integrace s implementací testovacích kroků. Nejsložitější pravděpodobně bude zpracování tabulky uvedené v klauzuli Given. Implementaci testovacích kroků zapíšeme do souboru common.py, který je v projektu uložen takto:
├── feature_list.txt ├── features │ ├── steps │ │ └── common.py │ └── sum.feature ├── requirements.in ├── requirements.txt ├── run_tests.sh └── src └── sum.py
Nejprve si uveďme celý obsah souboru common.py, teprve poté si popíšeme, jak vlastně funguje první funkce:
from behave import given, then, when # import testovane funkce from src.sum import sum @given(u'a list of integers') def list_of_integers(context): # seznam, do ktereho se ulozi hodnoty z tabulky numbers = [] # iterace pres radky tabulky for row in context.table: # ziskani hodnoty ze sloupce "value" a prevod na int numbers.append(int(row["value"])) # zapamatovani hodnot context.numbers = numbers @when(u'I summarize all those integers') def step_impl(context): """Zavolani testovane funkce.""" context.result = sum(context.numbers) @then('I should get {expected:d} as a result') def check_integer_result(context, expected): """Porovnani vypocteneho vysledku s vysledkem ocekavanym.""" assert context.result == expected, \ "Wrong result: {r} != {e}".format(r=context.result, e=expected)
Nyní se dostáváme k nejzajímavější části – zpracování dat z tabulky, která je zapsána v testovacím scénáři. Data zpracujeme následovně:
- Získáme referenci na objekt představující tabulku s daným jménem (tento objekt vytvoří knihovna Behave a pokud jde o jednu tabulku, nemusíme ani jméno tabulky použít)
- Postupně iterujeme přes všechny řádky tabulky
- V každém kroku iterace přečteme prvek ze sloupce „value“ a jelikož se jedná o řetězec, provedeme převod na číselnou hodnotu
- Konvertovaný prvek uložíme do seznamu
- Samotný seznam se následně stane součástí testovacího kontextu (do něj můžeme ukládat libovolná data)
Celý postup je v Pythonu implementován následujícím způsobem. Zejména je důležité pochopit způsob získání reference na tabulku a posléze přístup k prvku v daném sloupci:
@given(u'a list of integers') def list_of_integers(context): # seznam, do ktereho se ulozi hodnoty z tabulky numbers = [] # iterace pres radky tabulky for row in context.table: # ziskani hodnoty ze sloupce "value" a prevod na int numbers.append(int(row["value"])) # zapamatovani hodnot context.numbers = numbers
5. Výsledek běhu prvního demonstračního příkladu
Všechny důležité části projektu jsme si již popsali, takže se nyní pojďme podívat, jak dopadne výsledek testování. V případě, že pro spuštění testů použijeme výše uvedený skript, měly by se na standardní výstup vypsat následující zprávy s informacemi o tom, že se testování zdařilo:
Feature: Sum function test 1 # features/sum.feature:1 Scenario: Check the function sum() # features/sum.feature:3 Given a list of integers # features/steps/common.py:5 | value | | 1 | | 10 | | 100 | | 1000 | When I summarize all those integers # features/steps/common.py:13 Then I should get 1111 as a result # features/steps/common.py:18 Scenario: Check the function sum() for two inputs only # features/sum.feature:14 Given a list of integers # features/steps/common.py:5 | value | | 1 | | 2 | When I summarize all those integers # features/steps/common.py:13 Then I should get 3 as a result # features/steps/common.py:18 Scenario: Check the function sum() for one input only # features/sum.feature:23 Given a list of integers # features/steps/common.py:5 | value | | 42 | When I summarize all those integers # features/steps/common.py:13 Then I should get 42 as a result # features/steps/common.py:18 1 feature passed, 0 failed, 0 skipped 3 scenarios passed, 0 failed, 0 skipped 9 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m0.001s
Obrázek 1: Zprávy vypsané knihovnou Behave při spuštění prvního příkladu.
6. Výsledek běhu příkladu ve chvíli, kdy je výpočet nekorektní
Pokud ovšem v testované funkci záměrně uděláme chybu…:
from functools import reduce def sum(numbers): return reduce(lambda x, y: x+y*2, numbers)
…dopadnou testy zcela odlišně:
Feature: Sum function test 1 # features/sum.feature:1 Scenario: Check the function sum() # features/sum.feature:3 Given a list of integers # features/steps/common.py:7 0.000s | value | | 1 | | 10 | | 100 | | 1000 | When I summarize all those integers # features/steps/common.py:21 0.000s Then I should get 1111 as a result # features/steps/common.py:27 0.000s Assertion Failed: Wrong result: 2221 != 1111 Scenario: Check the function sum() for two inputs only # features/sum.feature:14 Given a list of integers # features/steps/common.py:7 0.000s | value | | 1 | | 2 | When I summarize all those integers # features/steps/common.py:21 0.000s Then I should get 3 as a result # features/steps/common.py:27 0.000s Assertion Failed: Wrong result: 5 != 3 Scenario: Check the function sum() for one input only # features/sum.feature:23 Given a list of integers # features/steps/common.py:7 0.000s | value | | 42 | When I summarize all those integers # features/steps/common.py:21 0.000s Then I should get 42 as a result # features/steps/common.py:27 0.000s Failing scenarios: features/sum.feature:3 Check the function sum() features/sum.feature:14 Check the function sum() for two inputs only 0 features passed, 1 failed, 0 skipped 1 scenario passed, 2 failed, 0 skipped 7 steps passed, 2 failed, 0 skipped, 0 undefined Took 0m0.001s
Obrázek 2: Zprávy vypsané knihovnou Behave při spuštění příkladu, u něhož je pokažený výpočet.
7. Druhý demonstrační příklad – konverze měn
Tabulky s daty, které jsou typicky použité v klauzuli Given, samozřejmě mohou obsahovat i složitější údaje. V předchozím příkladu se celá tabulka sestávala z jediného sloupce čísel, což je ta nejjednodušší možnost. Ukažme si tedy složitější příklad. V něm budeme testovat převod měn, tj. kolik Kč zaplatíme za stanovený počet jednotek jiné měny. Z tohoto důvodu bude přímo v testovacím scénáři uvedena tabulka s kurzy měn, v testovacím kroku pak budeme zjišťovat, kolik Kč zaplatíme, pokud nakoupíme X jednotek jiné měny. Testovací scénář tedy může vypadat například následovně:
Feature: Exchange rate test Scenario: Check the exchange rate calculation Given the following exchange rate table | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 10 CAD Then I should receive 161.72 CZK Scenario: Check the exchange rate calculation Given the following exchange rate table | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 1 HRK Then I should receive 3.407 CZK When I sell 2 HRK Then I should receive 6.814 CZK Scenario: Check the exchange rate calculation Given the following exchange rate table | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 1000 CZK Then I should receive 1000 CZK
8. Implementace jednotlivých kroků testu
Při implementaci testovacích kroků již tedy musíme zpracovat tabulku se dvěma sloupci, přičemž první sloupec obsahuje jednoznačné (unikátní) klíče a druhý sloupec hodnoty typu float. Přirozenou datovou strukturou, kterou v Pythonu použijeme, bude tedy slovník (mapa), přičemž klíče budou představovány kódem měny (řetězce „USD“, „HKR“…) a hodnotami budou příslušné převodní kurzy. Programová smyčka z předchozího příkladu, v němž jsme naplňovali seznam, se tedy změní na smyčku, ve které postupně naplňujeme slovník, který je následně přiřazen k testovacímu kontextu:
@given(u'the following exchange rate table') def exchange_rate_table_init(context): # slovnik do ktereho se ulozi smenne kurzy tbl = {} # iterace pres vsechny radky tabulky v kroku Given for row in context.table: # nacist kod meny a aktualni kurz currency = row["currency"] rate = float(row["rate"]) # ulozit do slovniku tbl[currency] = rate # slovnik se stane soucasti testovaciho kontextu context.exchange_rate_table = tbl
Další krok testu představovaný klauzulí When zjistí kód měny z věty, která je zapsána v testovacím scénáři a taktéž, kolik jednotek této měny potřebujeme zakoupit. Následně vypočte potřebný objem Kč (povšimněte si, že vlastně vůbec nevoláme žádnou testovanou funkci, ale přímočaře celý výpočet provedeme v implementaci kroku testu, což je samozřejmě značné zjednodušení, které při reálném testování nenastane):
@when(u'I sell {sold} {currency}') def step_impl(context, sold, currency): """Vypocet na zaklade smenneho kurzu.""" exchange_rate = context.exchange_rate_table[currency] context.result = exchange_rate * float(sold)
Poslední krok představovaný klauzulí Then již teoreticky není nijak složitý, protože v něm jen budeme potřebovat zjistit, jestli vypočtený objem Kč odpovídá očekávané hodnotě:
@then(u'I should receive {amount:g} CZK') def step_impl(context, amount): """Porovnani vypocteneho vysledku s vysledkem ocekavanym.""" assert isclose(context.result, amount, rel_tol=1e-5), \ "Wrong result: {r} != {a}".format(r=context.result, a=amount)
Jak ovšem záhy zjistíte, není korektní přímo porovnávat dvě hodnoty typu float, protože textová podoba čísel neodpovídá jejich vnitřní reprezentaci (více viz články na toto téma [1] a [2]. Z tohoto důvodu je ještě v testech definována pomocná funkce pro zjištění, do jaké míry se od sebe dvě hodnoty typu float odlišují:
def isclose(a, b, rel_tol=1e-09, abs_tol=0.0): """Pomocna funkce pro porovnani dvou cisel s plovouci radovou carkou.""" return abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)
Nový projekt má prakticky shodnou strukturu s předchozím projektem, pouze v něm chybí testovaný modul:
. ├── feature_list.txt ├── features │ ├── exchange_rate.feature │ └── steps │ └── common.py ├── requirements.in ├── requirements.txt └── run_tests.sh 2 directories, 6 files
9. Výsledek běhu druhého demonstračního příkladu
Pokud nyní testovací scénář spustíme, měly by se na standardní výstup vypsat následující řádky:
Feature: Exchange rate test # features/exchange_rate.feature:1 Scenario: Check the exchange rate calculation # features/exchange_rate.feature:3 Given the following exchange rate table # features/steps/common.py:12 0.000s | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 10 CAD # features/steps/common.py:22 0.000s Then I should receive 161.72 CZK # features/steps/common.py:28 0.000s Scenario: Check the exchange rate calculation # features/exchange_rate.feature:13 Given the following exchange rate table # features/steps/common.py:12 0.000s | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 1 HRK # features/steps/common.py:22 0.000s Then I should receive 3.407 CZK # features/steps/common.py:28 0.000s When I sell 2 HRK # features/steps/common.py:22 0.000s Then I should receive 6.814 CZK # features/steps/common.py:28 0.000s Scenario: Check the exchange rate calculation # features/exchange_rate.feature:25 Given the following exchange rate table # features/steps/common.py:12 0.000s | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 1000 CZK # features/steps/common.py:22 0.000s Then I should receive 1000 CZK # features/steps/common.py:28 0.000s 1 feature passed, 0 failed, 0 skipped 3 scenarios passed, 0 failed, 0 skipped 11 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m0.002s
Obrázek 3: Zprávy vypsané knihovnou Behave při spuštění třetího příkladu s konverzí měn.
10. Výsledek běhu příkladu ve chvíli, kdy je výpočet převodu měn nekorektní
Pro zajímavost nyní schválně pokazíme tu část kódu, která počítá převod měn:
Jestliže testovací scénář spustíme s pokaženým (resp. přesněji řečeno s nekorektně pracujícím výpočtem), dostaneme podle očekávání výsledky s množstvím chyb:
Feature: Exchange rate test # features/exchange_rate.feature:1 Scenario: Check the exchange rate calculation # features/exchange_rate.feature:3 Given the following exchange rate table # features/steps/common.py:4 0.000s | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 10 CAD # features/steps/common.py:21 0.000s Then I should receive 161.72 CZK # features/steps/common.py:28 0.000s Assertion Failed: Wrong result: 323.44 != 161.72 Scenario: Check the exchange rate calculation # features/exchange_rate.feature:13 Given the following exchange rate table # features/steps/common.py:4 0.000s | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 1 HRK # features/steps/common.py:21 0.000s Then I should receive 3.407 CZK # features/steps/common.py:28 0.000s Assertion Failed: Wrong result: 6.814 != 3.407 When I sell 2 HRK # None Then I should receive 6.814 CZK # None Scenario: Check the exchange rate calculation # features/exchange_rate.feature:25 Given the following exchange rate table # features/steps/common.py:4 0.000s | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 1000 CZK # features/steps/common.py:21 0.000s Then I should receive 1000 CZK # features/steps/common.py:28 0.000s Assertion Failed: Wrong result: 2000.0 != 1000.0
Na obrázku níže jsou dobře vidět ty části testovacích scénářů, které se vůbec nespustily. Je to vlastně logické, protože každý scénář testuje (či by měl testovat) ucelenou část chování aplikace s na sebe navazujícími kroky, takže když se první krok nepovede (start rakety), tak nemá smysl testovat, zda jsme se dostali na oběžnou dráhu:
Obrázek 4: Azurovou barvou jsou zvýrazněny ty kroky testovacího scénáře, které se vůbec nespustily.
Poznámka: i „pokažený“ test je součástí speciální verze demonstračního příkladu, jehož úplný zdrojový kód naleznete na této adrese.
11. Kombinace předchozích příkladů – tabulky v Given i v osnově testů
Další demonstrační příklad (který se již přibližuje reálným způsobům použití knihovny Behave) kombinuje možnosti tabulek popsaných minule a dnes. Minule jsme se totiž zabývali tím, jak popsat ty kroky testu, které se neustále opakují, pouze se do nich dosazují jiné hodnoty a očekávají se jiné výsledky. Dnes jsme se naproti tomu dozvěděli, jak je možné deklarovat tabulková data přímo v testovacím scénáři. Tyto dvě technologie je samozřejmě možné velmi jednoduše spojit dohromady. Ostatně se stačí podívat na nový testovací scénář, který by již měl být pochopitelný. První část scénáře odpovídá předchozímu příkladu. Jedná se jen o klauzuli Given, ovšem povšimněte si, že namísto běžného scénáře používáme osnovu (scenario outline):
Feature: Exchange rate test Scenario Outline: Check the exchange rate calculation Given the following exchange rate table | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 |
Ovšem druhá část již testuje různé transakce (převody měn) a to právě s využitím tabulek. Tabulka jednoduše obsahuje jednotlivé transakce, tj. jakou měnu se chystáme koupit a v jakém objemu. Samozřejmě je nutné uvést i očekávanou sumu, kterou bude nutné zaplatit v Kč:
When I sell <sold> <currency> Then I should receive <amount> CZK Examples: sold | sold | currency | amount | | 1 | CZK | 1.000 | | 10 | CZK | 10.000 | | 1 | CAD | 16.172 | | 100 | CAD | 1617.200 | | 2 | HRK | 6.814 |
12. Implementace jednotlivých kroků testu
Asi nebude větším překvapením zjištění, že existence druhé tabulky (té v popisu osnovy) se vlastně nijak neodrazí na tom, jak budou muset být zapsány implementace jednotlivých kroků testu. Je tomu tak z toho důvodu, že (de facto) převod tabulky na smyčku je proveden intepretrem jazyka Gherkin (tedy knihovnou Behave):
from behave import given, then, when @given(u'the following exchange rate table') def exchange_rate_table_init(context): # slovnik do ktereho se ulozi smenne kurzy tbl = {} # iterace pres vsechny radky tabulky v kroku Given for row in context.table: # nacist kod meny a aktualni kurz currency = row["currency"] rate = float(row["rate"]) # ulozit do slovniku tbl[currency] = rate # slovnik se stane soucasti testovaciho kontextu context.exchange_rate_table = tbl @when(u'I sell {sold} {currency}') def step_impl(context, sold, currency): """Vypocet na zaklade smenneho kurzu.""" exchange_rate = context.exchange_rate_table[currency] context.result = exchange_rate * float(sold) @then(u'I should receive {amount:g} CZK') def step_impl(context, amount): """Porovnani vypocteneho vysledku s vysledkem ocekavanym.""" assert isclose(context.result, amount, rel_tol=1e-5), \ "Wrong result: {r} != {a}".format(r=context.result, a=amount) def isclose(a, b, rel_tol=1e-09, abs_tol=0.0): """Pomocna funkce pro porovnani dvou cisel s plovouci radovou carkou.""" return abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)
13. Výsledek běhu třetího demonstračního příkladu
Opět se podívejme, jak bude vypadat výsledek spuštění celého testovacího scénáře. Povšimněte si, že skutečně došlo k „rozbalení“ testové osnovy do jednotlivých samostatně spouštěných scénářů:
Feature: Exchange rate test # features/exchange_rate.feature:1 Scenario Outline: Check the exchange rate calculation -- @1.1 sold # features/exchange_rate.feature:15 Given the following exchange rate table # features/steps/common.py:12 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 1 CZK # features/steps/common.py:22 Then I should receive 1.000 CZK # features/steps/common.py:28 Scenario Outline: Check the exchange rate calculation -- @1.2 sold # features/exchange_rate.feature:16 Given the following exchange rate table # features/steps/common.py:12 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 10 CZK # features/steps/common.py:22 Then I should receive 10.000 CZK # features/steps/common.py:28 Scenario Outline: Check the exchange rate calculation -- @1.3 sold # features/exchange_rate.feature:17 Given the following exchange rate table # features/steps/common.py:12 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 1 CAD # features/steps/common.py:22 Then I should receive 16.172 CZK # features/steps/common.py:28 Scenario Outline: Check the exchange rate calculation -- @1.4 sold # features/exchange_rate.feature:18 Given the following exchange rate table # features/steps/common.py:12 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 100 CAD # features/steps/common.py:22 Then I should receive 1617.200 CZK # features/steps/common.py:28 Scenario Outline: Check the exchange rate calculation -- @1.4 sold # features/exchange_rate.feature:18 Given the following exchange rate table # features/steps/common.py:12 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 100 CAD # features/steps/common.py:22 Then I should receive 1617.200 CZK # features/steps/common.py:28 Scenario Outline: Check the exchange rate calculation -- @1.5 sold # features/exchange_rate.feature:19 Given the following exchange rate table # features/steps/common.py:12 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 2 HRK # features/steps/common.py:22 Then I should receive 6.814 CZK # features/steps/common.py:28 1 feature passed, 0 failed, 0 skipped 5 scenarios passed, 0 failed, 0 skipped 15 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m0.002s
Obrázek 5: Výsledek běhu pátého demonstračního příkladu tak, jak vypadá na konzoli.
14. Výsledky ve chvíli, kdy je výpočet převodu měn naprogramován nekorektně
V případě, že naschvál provedeme výpočet převodu měn špatně, například úpravou zvýrazněného výrazu:
@when(u'I sell {sold} {currency}') def step_impl(context, sold, currency): """Vypocet na zaklade smenneho kurzu.""" exchange_rate = context.exchange_rate_table[currency] context.result = exchange_rate * float(sold) / 2
bude tento problém detekován v konstrukci assert, resp. přesněji řečeno vyvolá assert výjimku, kterou zpracuje knihovna Gherkin a do testů vypíše zprávu přiřazenou k výjimce při její konstrukci (to je velmi důležité, protože výsledky testů by měly být stejně čitelné, jako samotný zápis testovacího scénáře):
@then(u'I should receive {amount:g} CZK') def step_impl(context, amount): """Porovnani vypocteneho vysledku s vysledkem ocekavanym.""" assert isclose(context.result, amount, rel_tol=1e-5), \ "Wrong result: {r} != {a}".format(r=context.result, a=amount)
V následující sekvenci zpráv vypsaných knihovnou Behave jsou zvýrazněny všechny pády (způsobené porovnáním v konstrukci assert):
Feature: Exchange rate test # features/exchange_rate.feature:1 Scenario Outline: Check the exchange rate calculation -- @1.1 sold # features/exchange_rate.feature:15 Given the following exchange rate table # features/steps/common.py:4 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 1 CZK # features/steps/common.py:21 Then I should receive 1.000 CZK # features/steps/common.py:28 Assertion Failed: Wrong result: 0.5 != 1.0 Scenario Outline: Check the exchange rate calculation -- @1.2 sold # features/exchange_rate.feature:16 Given the following exchange rate table # features/steps/common.py:4 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 10 CZK # features/steps/common.py:21 Then I should receive 10.000 CZK # features/steps/common.py:28 Assertion Failed: Wrong result: 5.0 != 10.0 Scenario Outline: Check the exchange rate calculation -- @1.3 sold # features/exchange_rate.feature:17 Given the following exchange rate table # features/steps/common.py:4 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 1 CAD # features/steps/common.py:21 Then I should receive 16.172 CZK # features/steps/common.py:28 Assertion Failed: Wrong result: 8.086 != 16.172 Scenario Outline: Check the exchange rate calculation -- @1.4 sold # features/exchange_rate.feature:18 Given the following exchange rate table # features/steps/common.py:4 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | When I sell 1 CAD # features/steps/common.py:21 Then I should receive 16.172 CZK # features/steps/common.py:28 Assertion Failed: Wrong result: 8.086 != 16.172 Scenario Outline: Check the exchange rate calculation -- @1.4 sold # features/exchange_rate.feature:18 Given the following exchange rate table # features/steps/common.py:4 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 100 CAD # features/steps/common.py:21 Then I should receive 1617.200 CZK # features/steps/common.py:28 Assertion Failed: Wrong result: 808.6 != 1617.2 Scenario Outline: Check the exchange rate calculation -- @1.5 sold # features/exchange_rate.feature:19 Given the following exchange rate table # features/steps/common.py:4 | currency | rate | | CZK | 1.000 | | CAD | 16.172 | | HRK | 3.407 | | USD | 20.655 | When I sell 2 HRK # features/steps/common.py:21 Then I should receive 6.814 CZK # features/steps/common.py:28 Assertion Failed: Wrong result: 3.407 != 6.814 Failing scenarios: features/exchange_rate.feature:15 Check the exchange rate calculation -- @1.1 sold features/exchange_rate.feature:16 Check the exchange rate calculation -- @1.2 sold features/exchange_rate.feature:17 Check the exchange rate calculation -- @1.3 sold features/exchange_rate.feature:18 Check the exchange rate calculation -- @1.4 sold features/exchange_rate.feature:19 Check the exchange rate calculation -- @1.5 sold 0 features passed, 1 failed, 0 skipped 0 scenarios passed, 5 failed, 0 skipped 10 steps passed, 5 failed, 0 skipped, 0 undefined Took 0m0.002s
Obrázek 6: Výsledek běhu ve chvíli, kdy je výpočet převodu měn naschvál naprogramován nekorektně.
15. Testovací scénáře pro kontrolu funkčnosti REST API
Další dva demonstrační příklady, s nimiž se dnes seznámíme, již budou zaměřeny poněkud praktičtěji a budou vlastně do značné míry kopírovat příklady, které jsme si již ukázali při popisu knihovny Cucumber určené pro programovací jazyk Clojure [1] [2]. V těchto příkladech budeme testovat REST API, konkrétně REST API nabízené GitHubem (z toho důvodu, že toto API může použít kdokoli, ani se v některých případech nemusí nijak autentizovat).
První demonstrační příklad bude velmi jednoduchý, protože v něm pouze zjistíme, zda je REST API dostupné a zda volané endpointy vrací správné stavové kódy protokolu HTTP. Testovací scénář bude vypadat následovně:
Feature: Smoke test Scenario: Check the GitHub API entry point Given GitHub is accessible When I access the API endpoint / Then I should receive 200 status code
Struktura tohoto projektu bude nepatrně složitější, než jsme byli zvyklí u předchozích příkladů, a to z toho důvodu, že do projektu přidáme další důležitý soubor nazvaný environment.py, který je uložen do adresáře features, což naznačuje, že je nějak spojen s testovacími scénáři:
. ├── feature_list.txt ├── features │ ├── environment.py │ ├── smoketest.feature │ └── steps │ └── common.py ├── requirements.in ├── requirements.txt └── run_tests.sh 2 directories, 7 files
Změní se také soubor requirements.txt, a to z toho důvodu, že pro přístup k REST API GitHubu budeme používat knihovnu requests:
behave pytest requests
16. Soubory environment.py a common.py
Soubor environment.py uložený do adresáře features se prozatím v demonstračních příkladech neobjevoval, ale v praxi se s ním setkáme velmi často. Právě v tomto modulu jsou totiž definovány například funkce spouštěné před všemi testovacími scénáři či před jednotlivými kroky atd. Naše verze tohoto souboru bude prozatím velmi jednoduchá, protože v ní budeme deklarovat funkci nazvanou _is_accessible, jejíž reference bude uložena do kontextu. Podobně do kontextu uložíme atribut nesoucí adresu REST API GitHubu.
To vše se provede ve speciální funkci se jménem before_all, protože tato funkce je vyhledána a spuštěna před spuštěním testovacího scénáře (specialita této funkce spočívá v jejím jménu a způsobu jejího vyhledání knihovnou Behave). Nastavení provedená v této funkci budou viditelná ve všech testovacích scénářích (v jiných případech se totiž kontext mezi scénáři znovu inicializuje). Za povšimnutí stojí i nastavení logování, resp. přesněji řečeno zachytávání logovacích zpráv, aby se mohly v případě problémů zobrazit ve výsledcích spuštěných testů (celá problematika je však složitější a budeme se jí zabývat příště):
import json import os.path from behave.log_capture import capture import requests def _is_accessible(context, accepted_codes=None): accepted_codes = accepted_codes or {200, 401} url = context.api_url try: res = requests.get(url) return res.status_code in accepted_codes except requests.exceptions.ConnectionError as e: print("Connection error: {e}".format(e=e)) return False def before_all(context): """Perform setup before the first event.""" context.is_accessible = _is_accessible context.api_url = "https://api.github.com"
Vidíme, že do kontextu skutečně byly přidány další dva atributy, z nichž jeden obsahuje referenci na funkci a druhý adresu REST API (abychom ji nemuseli složitě přidávat přímo do testů, ale mohli ji načíst například z konfiguračního souboru).
Další soubor pojmenovaný common.py již obsahuje implementace jednotlivých kroků testů. Povšimněte si, že v kroku when pošleme dotaz na REST API a do kontextu uložíme celou odpověď, včetně statusu a případného těla s daty. V kroku then pak již jen zjišťujeme HTTP kód:
import json from behave import given, then, when from urllib.parse import urljoin import requests @given('GitHub is accessible') def initial_state(context): assert context.is_accessible(context) @when('I access the API endpoint {url}') def access_endpoint(context, url): context.response = requests.get(context.api_url + url) @then('I should receive {status:d} status code') def check_status_code(context, status): """Check the HTTP status code returned by the REST API.""" assert context.response.status_code == status
17. Spuštění testu se zobrazením výsledků
REST API GitHubu je většinou velmi dobře dostupné, takže by se po spuštění testů (opět skriptem) měla na standardní výstup vypsat zpráva o úspěšném přístupu – REST API endpoint na dotaz vrátí HTTP kód 200:
Feature: Smoke test # features/smoketest.feature:1 @smoketest Scenario: Check the GitHub API entry point # features/smoketest.feature:4 Given GitHub is accessible # features/steps/common.py:8 0.647s When I access the API endpoint / # features/steps/common.py:13 0.634s Then I should receive 200 status code # features/steps/common.py:18 0.000s 1 feature passed, 0 failed, 0 skipped 1 scenario passed, 0 failed, 0 skipped 3 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m1.281s
18. Výsledek testu ve chvíli, kdy je REST API nedostupné
Nepatrnou úpravou adresy REST API, která je uložena v souboru environment.py:
def before_all(context): """Perform setup before the first event.""" context.is_accessible = _is_accessible context.api_url = "https://xyzzy.github.com"
samozřejmě podle očekávání dosáhneme toho, že testy skončí s chybou. Tato chyba je pro nás o to zajímavější, že nastane již na začátku testovacího scénáře, tj. v kroku Given. Vzhledem k tomu, že chyba nebyla žádným způsobem zpracována, zobrazí se na výstupu testů celý stack trace:
Feature: Smoke test # features/smoketest.feature:1 Scenario: Check the GitHub API entry point # features/smoketest.feature:4 Given GitHub is accessible # features/steps/common.py:8 0.415s Traceback (most recent call last): File "/usr/local/lib/python3.4/dist-packages/behave/model.py", line 1456, in run match.run(runner.context) File "/usr/local/lib/python3.4/dist-packages/behave/model.py", line 1903, in run self.func(context, *args, **kwargs) File "features/steps/common.py", line 10, in initial_state assert context.is_accessible(context) AssertionError Captured logging: INFO:urllib3.connectionpool:Starting new HTTPS connection (1): xyzzy.github.com When I access the API endpoint / # None Then I should receive 200 status code # None Failing scenarios: features/smoketest.feature:4 Check the GitHub API entry point 0 features passed, 1 failed, 0 skipped 0 scenarios passed, 1 failed, 0 skipped 0 steps passed, 1 failed, 2 skipped, 0 undefined Took 0m0.415s
19. Repositář s demonstračními příklady
Všech osm demonstračních projektů, které jsme si dnes popsali bylo uloženo do repositáře, který naleznete na adrese https://github.com/tisnik/python-behave-demos. V tabulce jsou zobrazeny odkazy na tyto projekty:
Projekt | Popis | Cesta |
---|---|---|
table_data | deklarace dat v Given | https://github.com/tisnik/python-behave-demos/tree/table_data |
table_data_invalid_calc | detekce nekorektního výpočtu | https://github.com/tisnik/python-behave-demos/tree/table_data_invalid_calc |
exchange_rate1 | konverze měn | https://github.com/tisnik/python-behave-demos/tree/exchange_rate1 |
exchange_rate2 | vylepšení testů tabulkou | https://github.com/tisnik/python-behave-demos/tree/exchange_rate2 |
exchange_rate1_invalid_calc | detekce nekorektního výpočtu | https://github.com/tisnik/python-behave-demos/tree/exchange_rate1_invalid_calc |
exchange_rate2_invalid_calc | detekce nekorektního výpočtu | https://github.com/tisnik/python-behave-demos/tree/exchange_rate2_invalid_calc |
github_test_version1 | test dostupnosti REST API | https://github.com/tisnik/python-behave-demos/tree/github_test_version1 |
github_test_version1_wrong_url | test NEdostupnosti REST API | https://github.com/tisnik/python-behave-demos/tree/github_test_version1_wrong_url |
20. Odkazy na Internetu
- Behave na GitHubu
https://github.com/behave/behave - behave 1.2.6 (PyPi)
https://pypi.python.org/pypi/behave - Dokumentace k Behave
http://behave.readthedocs.io/en/latest/ - Příklady použití Behave
https://github.com/behave/behave.example - Příklady použití Behave použité v dnešním článku
https://github.com/tisnik/python-behave-demos - Cucumber data tables
http://www.thinkcode.se/blog/2014/06/30/cucumber-data-tables - Tables (Gherkin)
http://docs.behat.org/en/v2.5/guides/1.gherkin.html#tables - Predefined Data Types in parse
https://jenisys.github.io/behave.example/datatype/builtin_types.html - Test Fixture (Wikipedia)
https://en.wikipedia.org/wiki/Test_fixture - Behavior-driven development (Wikipedia)
https://en.wikipedia.org/wiki/Behavior-driven_development - Cucumber
https://cucumber.io/ - Jasmine
https://jasmine.github.io/ - Pip (dokumentace)
https://pip.pypa.io/en/stable/ - Tox
https://tox.readthedocs.io/en/latest/ - Extrémní programování
https://cs.wikipedia.org/wiki/Extr%C3%A9mn%C3%AD_programov%C3%A1n%C3%AD - Programování řízené testy
https://cs.wikipedia.org/wiki/Programov%C3%A1n%C3%AD_%C5%99%C3%ADzen%C3%A9_testy - Test-driven development (Wikipedia)
https://en.wikipedia.org/wiki/Test-driven_development - Python namespaces
https://bytebaker.com/2008/07/30/python-namespaces/ - Namespaces and Scopes
https://www.python-course.eu/namespaces.php - pdb — The Python Debugger
https://docs.python.org/3.6/library/pdb.html - pdb – Interactive Debugger
https://pymotw.com/2/pdb/ - functools.reduce
https://docs.python.org/3.6/library/functools.html#functools.reduce