Hlavní navigace

Behavior-driven development v Pythonu s využitím knihovny Behave (druhá část)

Pavel Tišnovský

Ve druhé části článku o knihovně Behave integrující jazyk Gherkin s Pythonem si popíšeme další možnosti, které Gherkin programátorům a testerům nabízí. Jedná se především o možnost deklarace dat s využitím tabulek.

Doba čtení: 28 minut

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

20. Odkazy na Internetu

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)
Poznámka: schválně si zkuste manuálně vyzkoušet, jak funkce reaguje na různé vstupy, například na prázdný seznam, seznam objektů implementujících metodu __add__ apod.

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ě:

  1. 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)
  2. Postupně iterujeme přes všechny řádky tabulky
  3. 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
  4. Konvertovaný prvek uložíme do seznamu
  5. 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
Poznámka: chybná implementace testované funkce je součástí upraveného demonstračního příkladu, jehož zdrojové kódy naleznete na této adrese.

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)
Poznámka: povšimněte si zvláštnosti – obě funkce jsou pojmenovány stejně, a to proto, že jsem kostru definice testovacích kroků vytvořil na základě nápovědy zobrazené knihovnou Behave (viz úvodní část tohoto článku). Vzhledem k tomu, že implementace každého kroku má odlišnou anotaci, to nebude vadit.

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)
Poznámka: tato funkce je součástí knihoven novějších implementací Pythonu, ale zde ji pro jistotu definuji explicitně, protože ve starších (ještě pořád používaných verzích) ji nenalezneme.

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).

Poznámka: můžete taktéž využít REST API dostupné na adrese https://httpbin.org/, které je taktéž vhodné pro různé testy a kontroly.

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
Poznámka: chybná implementace testované funkce je součástí upraveného demonstračního příkladu, jehož zdrojové kódy naleznete na této adrese.

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_ver­sion1_wrong_url

20. Odkazy na Internetu

  1. Behave na GitHubu
    https://github.com/behave/behave
  2. behave 1.2.6 (PyPi)
    https://pypi.python.org/pypi/behave
  3. Dokumentace k Behave
    http://behave.readthedocs­.io/en/latest/
  4. Příklady použití Behave
    https://github.com/behave/be­have.example
  5. Příklady použití Behave použité v dnešním článku
    https://github.com/tisnik/python-behave-demos
  6. Cucumber data tables
    http://www.thinkcode.se/blog/2014/06/30/cu­cumber-data-tables
  7. Tables (Gherkin)
    http://docs.behat.org/en/v2­.5/guides/1.gherkin.html#ta­bles
  8. Predefined Data Types in parse
    https://jenisys.github.io/be­have.example/datatype/buil­tin_types.html
  9. Test Fixture (Wikipedia)
    https://en.wikipedia.org/wi­ki/Test_fixture
  10. Behavior-driven development (Wikipedia)
    https://en.wikipedia.org/wiki/Behavior-driven_development
  11. Cucumber
    https://cucumber.io/
  12. Jasmine
    https://jasmine.github.io/
  13. Pip (dokumentace)
    https://pip.pypa.io/en/stable/
  14. Tox
    https://tox.readthedocs.io/en/latest/
  15. Extrémní programování
    https://cs.wikipedia.org/wi­ki/Extr%C3%A9mn%C3%AD_pro­gramov%C3%A1n%C3%AD
  16. Programování řízené testy
    https://cs.wikipedia.org/wi­ki/Programov%C3%A1n%C3%AD_%C5%99%­C3%ADzen%C3%A9_testy
  17. Test-driven development (Wikipedia)
    https://en.wikipedia.org/wiki/Test-driven_development
  18. Python namespaces
    https://bytebaker.com/2008/07/30/pyt­hon-namespaces/
  19. Namespaces and Scopes
    https://www.python-course.eu/namespaces.php
  20. pdb — The Python Debugger
    https://docs.python.org/3­.6/library/pdb.html
  21. pdb – Interactive Debugger
    https://pymotw.com/2/pdb/
  22. functools.reduce
    https://docs.python.org/3­.6/library/functools.html#fun­ctools.reduce
Našli jste v článku chybu?