Hlavní navigace

Behavior-driven development v Pythonu s využitím knihovny Behave

3. 4. 2018
Doba čtení: 29 minut

Sdílet

S jazykem Gherkin navrženým tak, aby se v něm mohly čitelným a přirozeným způsobem psát testovací scénáře, jsme se již setkali v souvislosti s Clojure. Díky existenci knihovny Behave je možné Gherkin použít i v populárním Pythonu.

Obsah

1. Behavior-driven development v Pythonu s využitím knihovny Behave

2. Projekt s jedním modulem, který bude testován s využitím knihovny Behave

3. Zdrojový kód testovaného modulu, skript s testovacím scénářem

4. Pomocné soubory v adresáři s projektem

5. Soubor common.py s definicí testovacích kroků

6. Spuštění skriptu s testem, výsledek testů

7. Vylepšení implementace kroků testů i vlastního testovacího scénáře

8. Spuštění druhé varianty skriptu s testy, výsledek testů

9. Testování funkcí z nativních knihoven

10. Projekt, v němž budeme testovat vlastnosti nativní funkce

11. Upravený testovací scénář

12. Upravená implementace testovacích kroků

13. Spuštění skriptu s testy nativní funkce, výsledek testů

14. Spuštění skriptu ve chvíli, kdy není nativní funkce nalezena

15. Další modifikace testovacího scénáře – určení typů parametrů předávaných nativní funkci

16. Modifikace kroků testů

17. Spuštění modifikovaného skriptu s testy nativní funkce, výsledek testů

18. Repositář s demonstračními příklady

19. Odkazy na Internetu

1. Behavior-driven development v Pythonu s využitím knihovny Behave

Ve dvou článcích [1] [2] o programovacím jazyku Clojure jsme se věnovali dnes poměrně populárnímu tématu: popisu integrace doménově specifického jazyka Gherkin určeného pro popis testovacích scénářů s jazykem Clojure. Ve skutečnosti ovšem není Gherkin v žádném případě určen pouze pro použití společně s Clojure, ale jedná se o DSL integrovatelný i do mnoha dalších programovacích jazyků. Dnes si představíme knihovnu Behave, s jejíž pomocí se Gherkin integruje do jazyka Python. Ve skutečnosti se bude jednat o téměř ideální spojení, protože Gherkin i Python používají podobný způsob zápisu, v němž i odsazení jednotlivých programových řádků je součástí syntaxe (naproti tomu se Gherkin a Clojure ze syntaktického hlediska zcela odlišují).

Obrázek 1: Ukázka scénářů napsaných v doménově specifickém jazyce Gherkin.

2. Projekt s jedním modulem, který bude testován s využitím knihovny Behave

Jazyk Gherkin je navržen takovým způsobem, aby ho uživatelé (nemusí se totiž nutně jednat pouze o programátory) mohli začít používat prakticky okamžitě, tj. bez nutnosti studia sáhodlouhých manuálů. I z toho důvodu si možnosti tohoto doménově specifického jazyka postupně ukážeme na několika demonstračních příkladech. První příklad bude (prozatím) velmi jednoduchý, protože bude obsahovat jediný modul (naprogramovaný v Pythonu), který budeme chtít otestovat. I přesto se však bude jednat o plnohodnotný projekt, jehož struktura odpovídá struktuře projektů složitějších a sofistikovanějších. Adresář s projektem i s testovacím scénářem by měl vypadat následovně:

├── feature_list.txt
├── features
│   ├── adder.feature
│   └── steps
│       └── common.py
├── requirements.in
├── requirements.txt
├── run_tests.sh
└── src
    └── adder.py
 
3 directories, 7 files

V projektu můžeme vidět několik typů souborů:

Soubor Popis
src/adder.py vlastní modul, který budeme chtít otestovat
requirements.in/requirements.txt soubory pro pip (instalátor balíčků)
feature_list.txt seznam testovacích scénářů, které se mají spustit
features/* adresář obsahující testovací scénáře i implementaci jednotlivých kroků testů
run_tests.sh pomocný skript pro spuštění testovacích scénářů

3. Zdrojový kód testovaného modulu, skript s testovacím scénářem

Modul adder.py, který vlastně tvoří celou testovanou aplikaci, je velmi stručný, protože obsahuje jedinou funkci nazvanou add, jež – jak ostatně její název naznačuje – slouží k součtu dvou numerických hodnot popř. i pro aplikaci operátoru + na všechny typy operandů, které tento operátor podporují (řetězce, seznamy, n-tice, instance uživatelem deklarovaných tříd atd. atd.). Zdrojový kód tohoto modulu tedy sestává z pouhých dvou řádků:

def add(x, y):
    return x + y

Druhým důležitým souborem je vlastní testovací scénář nazvaný adder.feature, který je uložen v podadresáři features. Tento testovací scénář je naprogramován v jazyku Gherkin, konkrétně v jeho výchozí anglické „mutaci“ (použitím jiných jazykových mutací se budeme zabývat příště). V následujícím výpisu jsou klíčová slova rozeznávaná interpretrem označena tučně:

Feature: Adder test
 
  Scenario: Check the function add()
    Given The function add is callable
    When I call function add with arguments 1 and 2
    Then I should get 3 as a result
Povšimněte si, že Gherkin je z hlediska sémantiky velmi jednoduchý jazyk. Obsahuje deklarace jednotlivých testovacích scénářů, počáteční podmínku a potom sérii kroků ve stylu „když udělám X, stane se (očekávám) Y“. Syntax je také jednoduchá (alespoň prozatím), protože první slovo je klíčové a jednotlivé bloky (zde scénáře) jsou odsazeny, podobně jako v samotném Pythonu či ve formátu YAML.

4. Pomocné soubory v adresáři s projektem

Další pomocné soubory, které v projektu nalezneme, mají následující obsah.

Prvním důležitým souborem je soubor pojmenovaný feature_list.txt. V tomto souboru je uložen seznam testovacích scénářů, které se mají spustit. Prozatím máme vytvořen jen jediný testovací scénář (popsaný v předchozí kapitole), takže tento soubor bude obsahovat jediný řádek s relativním jménem souboru se scénářem:

features/adder.feature
Pokud bude soubor obsahovat více scénářů, můžete použít znak # pro jejich cílené zakomentování, což se může v některých případech hodit.

Další dva soubory requirements.in a requirements.txt obsahují seznam modulů (knihoven), které budeme v systému potřebovat proto, aby bylo možné testovací scénáře spustit. Prozatím je obsah i těchto souborů dosti minimalistický, protože budeme potřebovat dvě knihovny (kromě standardních knihoven Pythonu):

behave
pytest

A konečně následuje soubor pojmenovaný run_tests.sh. Ten slouží ke spuštění testovacích scénářů. Povšimněte si, že na základě obsahu proměnné NOVENV využíváme virtuální prostředí Pythonu, což mj. znamená, že instalace potřebných knihoven (pytest a behave) bude provedena pouze lokálně, bez zásahu do systémových oblastí souborového systému. Pokud máte všechny moduly v systému již nainstalované, není nutné virtuální prostředí použít a proměnná NOVENV může zůstat nastavena na hodnotě 1. Skript je připraven pro Python 3.x, ovšem můžete si ho velmi snadno upravit i pro použití v Pythonu 2.x:

#!/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 $@

Pokud budete chtít všechny závislé moduly nainstalovat do virtuálního prostředí, proveďte v souboru run_tests.sh následující změnu:

export NOVENV=0

5. Soubor common.py s definicí testovacích kroků

Velmi důležitým souborem, na němž jsou celé testy postaveny, je soubor common.py, který je umístěný v podadresáři features/steps/. V tomto souboru jsou implementovány jednotlivé kroky testu, tj. implementace řádků začínajících na Given, When a Then.

Ve skutečnosti se tento soubor nemusí jmenovat common.py. Můžeme použít i jiné jméno, například steps.py či foobar.py. Dokonce můžete kroky rozdělit do většího množství souborů, například foo.py a bar.py; vše podle vlastního uvážení a potřeb projektu. Jen je zapotřebí zachovat podadresář, v němž jsou tyto soubory umístěny.

Nejprve se podívejme, jak soubor vypadá, následně si jednotlivé řádky popíšeme:

from behave import given, then, when
from src.adder import add
 
 
@given('The function {function_name} is callable')
def initial_state(context, function_name):
    pass
 
 
@when('I call function {function} with arguments {x:d} and {y:d}')
def call_add(context, function, x, y):
    context.result = add(x, y)
 
 
@then('I should get {expected:d} as a result')
def check_integer_result(context, expected):
    assert context.result == expected, \
        "Wrong result: {r} != {e}".format(r=context.result, e=expected)

Na začátku souboru nejdříve provedeme import anotací given, when a then:

from behave import given, then, when

Následuje import vlastní testované funkce:

from src.adder import add

Dále jsou implementovány jednotlivé kroky testu. Jedná se o běžné funkce, které ovšem obsahují anotaci given, when nebo then. V anotaci je uveden text, který se Gherkin snaží spárovat s příslušnými řádky textu. V tomto textu je možné používat proměnné části, které jsou uzavřeny do složených závorek {} a obsahují buď pouze jméno parametru předaného funkci popř. jméno parametru následované typem (:d pro dekadické číslice atd.).

První funkce odpovídá klauzuli given. Povšimněte si, že se funkci, která implementuje tento krok, vždy předává parametr obsahující kontext (což je uživatelsky modifikovaný objekt) a taktéž parametr nazvaný function_name. Toto jméno odpovídá proměnné části textu z anotace. Při volání testovací funkce se do tohoto parametru dosadí skutečný text zapsaný uživatelem do souboru adder.feature:

@given('The function {function_name} is callable')
def initial_state(context, function_name):
    pass

U druhé funkce je použita anotace, jejíž text obsahuje celkem tři proměnné části. Z toho důvodu je testovací funkce volána se čtyřmi parametry (na prvním místě je vždy kontext). Uvnitř testovací funkce zavoláme testovanou funkci add a zapamatujeme si její výsledek. Povšimněte si, že interpret jazyka Gherkin je celkem inteligentní, protože z popisu proměnných částí {x:d} a {y:d} poznal, že se jedná o celá čísla, takže tyto parametry skutečně na celá čísla sám převedl:

@when('I call function {function} with arguments {x:d} and {y:d}')
def call_add(context, function, x, y):
    context.result = add(x, y)

Poslední funkce s popisem kroku testu porovnává očekávaný výsledek s výsledkem vypočteným funkcí add. Povšimněte si, že objekt předaný v parametru context výsledek skutečně obsahuje, protože právě tento objekt slouží k zapamatování stavu celého testovacího scénáře popř. jeho jednotlivých částí (celá situace je ve skutečnosti nepatrně složitější a budeme se jí zabývat příště):

@then('I should get {expected:d} as a result')
def check_integer_result(context, expected):
    assert context.result == expected, \
        "Wrong result: {r} != {e}".format(r=context.result, e=expected)

6. Spuštění skriptu s testem, výsledek testů

Pokud spustíme soubor run_tests.sh, měly by se na standardním výstupu objevit následující řádky (zde se předpokládá, že je zakázáno použití virtuálního prostředí Pythonu):

$ ./run_tests.sh
 
+ export NOVENV=1
+ NOVENV=1
+ '[' 1 == 1 ']'
++ which behave
+ PYTHONDONTWRITEBYTECODE=1
+ LD_LIBRARY_PATH=lib
+ python3 /usr/local/bin/behave --tags=-skip -D dump_errors=true @feature_list.txt
Feature: Adder test # features/adder.feature:1
 
  Scenario: Check the function add()                # features/adder.feature:4
    Given The function add is callable              # features/steps/common.py:5 0.000s
    When I call function add with arguments 1 and 2 # features/steps/common.py:10 0.000s
    Then I should get 3 as a result                 # features/steps/common.py:15 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 0m0.000s

Zprávy jsou implicitně obarveny, takže ve skutečnosti bude výsledek vypadat spíše takto:

Obrázek 2: Terminál s výstupem vytvořeným testovacím scénářem.

V případě, že povolíte použití virtuálního prostředí Pythonu, bude výstup poněkud odlišný, protože se nejdřív toto prostředí nastaví a vzápětí se do něj nainstalují potřebné moduly pytest a behave:

Using base prefix '/usr'
New python executable in venv/bin/python3
Also creating executable in venv/bin/python
Installing setuptools, pip...done.
Running virtualenv with interpreter /usr/bin/python3
Downloading/unpacking behave (from -r requirements.txt (line 1))
Downloading/unpacking pytest (from -r requirements.txt (line 2))
Downloading/unpacking six>=1.11 (from behave->-r requirements.txt (line 1))
  Downloading six-1.11.0-py2.py3-none-any.whl
Downloading/unpacking parse>=1.8.2 (from behave->-r requirements.txt (line 1))
  Downloading parse-1.8.2.tar.gz
  Running setup.py (path:/home/tester/temp/python/python-behave-demos/test_python_function1/venv/build/parse/setup.py) egg_info for package parse
 
Downloading/unpacking parse-type>=0.4.2 (from behave->-r requirements.txt (line 1))
  Downloading parse_type-0.4.2-py2.py3-none-any.whl
Downloading/unpacking more-itertools>=4.0.0 (from pytest->-r requirements.txt (line 2))
Requirement already satisfied (use --upgrade to upgrade): setuptools in ./venv/lib/python3.4/site-packages (from pytest->-r requirements.txt (line 2))
Downloading/unpacking py>=1.5.0 (from pytest->-r requirements.txt (line 2))
Downloading/unpacking pluggy>=0.5,<0.7 (from pytest->-r requirements.txt (line 2))
  Downloading pluggy-0.6.0.tar.gz
  Running setup.py (path:/home/tester/temp/python/python-behave-demos/test_python_function1/venv/build/pluggy/setup.py) egg_info for package pluggy
    /usr/lib/python3.4/distutils/dist.py:260: UserWarning: Unknown distribution option: 'python_requires'
      warnings.warn(msg)
 
    warning: no files found matching 'CHANGELOG'
    warning: no previously-included files matching '*.pyc' found under directory '*'
    warning: no previously-included files matching '*.pyo' found under directory '*'
Downloading/unpacking attrs>=17.4.0 (from pytest->-r requirements.txt (line 2))
  Downloading attrs-17.4.0-py2.py3-none-any.whl
Installing collected packages: behave, pytest, six, parse, parse-type, more-itertools, py, pluggy, attrs
  Running setup.py install for parse
 
  Running setup.py install for pluggy
    /usr/lib/python3.4/distutils/dist.py:260: UserWarning: Unknown distribution option: 'python_requires'
      warnings.warn(msg)
 
    warning: no files found matching 'CHANGELOG'
    warning: no previously-included files matching '*.pyc' found under directory '*'
    warning: no previously-included files matching '*.pyo' found under directory '*'
Successfully installed behave pytest six parse parse-type more-itertools py pluggy attrs
Cleaning up...
Feature: Adder test # features/adder.feature:1
 
  Scenario: Check the function add()                # features/adder.feature:3
    Given The function add is callable              # features/steps/common.py:5
    When I call function add with arguments 1 and 2 # features/steps/common.py:10
    Then I should get 3 as a result                 # features/steps/common.py:15
 
1 feature passed, 0 failed, 0 skipped
1 scenario passed, 0 failed, 0 skipped
3 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.000s

7. Vylepšení implementace kroků testů i vlastního testovacího scénáře

V této kapitole si popíšeme některá vylepšení, která lze provést v implementaci jednotlivých kroků testů i vlastního testovacího scénáře. V první řadě doplníme funkci initial_state o kontrolu, zda je testovaná funkce viditelná a zda ji lze zavolat (tj. zda se jedná o funkci a nikoli o objekt jiného typu). Soubor common.py se tedy změní takto:

from behave import given, then, when
from src.adder import add
 
 
@given('The function {function_name} is callable')
def initial_state(context, function_name):
    g = globals()
    assert function_name in g, "Function is not visible"
    assert callable(g[function_name]), "Not a function"
 
 
@when('I call function {function} with arguments {x:d} and {y:d}')
def call_add(context, function, x, y):
    context.result = add(x, y)
 
 
@then('I should get {expected:d} as a result')
def check_integer_result(context, expected):
    assert context.result == expected, \
        "Wrong result: {r} != {e}".format(r=context.result, e=expected)

Jedna z velmi zajímavých možností, jak testovací scénáře rozšířit, spočívá v tom, že se specifikuje tabulka či tabulky se vstupními hodnotami a očekávanými výsledky. Do testovacího scénáře přidáme tzv. osnovu (Scenario Outline), takže se celý scénář adder.feature změní následovně:

Feature: Adder test
 
  Scenario: Check the function add()
    Given The function add is callable
    When I call function add with arguments 1 and 2
    Then I should get 3 as a result
 
  Scenario Outline: Thorough checking function add()
    Given The function add is callable
    When I call function add with arguments <x> and <y>
    Then I should get <result> as a result
 
     Examples: result
     |x|y|result|
     # basic arithmetic
     |          0| 0|          0|
     |          1| 2|          3|
     |          1|-2|         -1|
     # no overflows at 16 bit limits
     |      32767| 1|      32768|
     |      65535| 1|      65536|
     # integer overflow in Python?
     | 2147483648| 1| 2147483649|
     |-2147483647|-1|-2147483648|
     |-2147483648|-1|-2147483649|

Tento scénář se bude pro každý řádek tabulky opakovat, přičemž v každé iteraci se namísto textů <x>, <y> a <result> dosadí hodnoty z příslušného sloupce tabulky. Jedná se přitom o pouhou textovou substituci, takže ve skutečnosti je možné s tabulkami provádět i dosti složité operace.

8. Spuštění druhé varianty skriptu s testy, výsledek testů

Pokud nyní spustíme testovací scénář již známým skriptem run_tests.sh, měl by výsledek vypadat přibližně následovně:

Feature: Adder test # features/adder.feature:1
 
  Scenario: Check the function add()                # features/adder.feature:4
    Given The function add is callable              # features/steps/common.py:5
    When I call function add with arguments 1 and 2 # features/steps/common.py:12
    Then I should get 3 as a result                 # features/steps/common.py:17
 
  Scenario Outline: Thorough checking function add() -- @1.1 result  # features/adder.feature:17
    Given The function add is callable                               # features/steps/common.py:5
    When I call function add with arguments 0 and 0                  # features/steps/common.py:12
    Then I should get 0 as a result                                  # features/steps/common.py:17
 
  Scenario Outline: Thorough checking function add() -- @1.2 result  # features/adder.feature:18
    Given The function add is callable                               # features/steps/common.py:5
    When I call function add with arguments 1 and 2                  # features/steps/common.py:12
    Then I should get 3 as a result                                  # features/steps/common.py:17
 
  Scenario Outline: Thorough checking function add() -- @1.3 result  # features/adder.feature:19
    Given The function add is callable                               # features/steps/common.py:5
    When I call function add with arguments 1 and -2                 # features/steps/common.py:12
    Then I should get -1 as a result                                 # features/steps/common.py:17
 
  Scenario Outline: Thorough checking function add() -- @1.4 result  # features/adder.feature:21
    Given The function add is callable                               # features/steps/common.py:5
    When I call function add with arguments 32767 and 1              # features/steps/common.py:12
    Then I should get 32768 as a result                              # features/steps/common.py:17
 
  Scenario Outline: Thorough checking function add() -- @1.5 result  # features/adder.feature:22
    Given The function add is callable                               # features/steps/common.py:5
    When I call function add with arguments 65535 and 1              # features/steps/common.py:12
    Then I should get 65536 as a result                              # features/steps/common.py:17
 
  Scenario Outline: Thorough checking function add() -- @1.6 result  # features/adder.feature:24
    Given The function add is callable                               # features/steps/common.py:5
    When I call function add with arguments 2147483648 and 1         # features/steps/common.py:12
    Then I should get 2147483649 as a result                         # features/steps/common.py:17
 
  Scenario Outline: Thorough checking function add() -- @1.7 result  # features/adder.feature:25
    Given The function add is callable                               # features/steps/common.py:5
    When I call function add with arguments -2147483647 and -1       # features/steps/common.py:12
    Then I should get -2147483648 as a result                        # features/steps/common.py:17
 
  Scenario Outline: Thorough checking function add() -- @1.8 result  # features/adder.feature:26
    Given The function add is callable                               # features/steps/common.py:5
    When I call function add with arguments -2147483648 and -1       # features/steps/common.py:12
    Then I should get -2147483649 as a result                        # features/steps/common.py:17
 
1 feature passed, 0 failed, 0 skipped
9 scenarios passed, 0 failed, 0 skipped
27 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.002s

Obrázek 3: Terminál s výstupem vytvořeným druhou variantou testovacího scénáře.

9. Testování funkcí z nativních knihoven

Kombinaci Python + Gherkin je možné použít i ve chvíli, kdy je nutné vytvořit testovací scénáře popisující chování funkcí z nativních knihoven, například funkcí naprogramovaných v klasickém céčku. V této kapitole si tedy jen v rychlosti ukážeme, jakým způsobem je vlastně možné přeložit céčkovou funkci do sdílené knihovny, načíst tuto knihovnu do běžícího interpretru Pythonu a následně nativní funkci zavolat z Pythonu s využitím knihovny ctypes.

Základem bude zdrojový kód uložený do souboru adder.c. Tento zdrojový kód bude obsahovat jen jedinou funkci add:

extern int add(int x, int y)
{
    return x+y;
}

Soubor adder.c přeložíme do objektového souboru adder.o a následně z tohoto objektového souboru vytvoříme sdílenou knihovnu (shared library) pojmenovanou libadder.so:

gcc -Wall -ansi -c -fPIC adder.c -o adder.o
 
gcc -shared -Wl,-soname,libadder.so -o libadder.so adder.o

Následuje důležitá část – načtení sdílené knihovny do běžícího interpretu jazyka Python, získání reference na nativní funkci add a zavolání této funkce z Pythonu (s poloautomatickou typovou konverzí):

import ctypes
 
libname = "libadder.so"
 
adder = ctypes.CDLL(libname)
 
for x in range(0, 6):
    for y in range(0, 6):
        result = adder.add(x,y)
        print("{x} + {y} = {z}".format(x=x, y=y, z=result))
Poznámka: aby bylo možné sdílenou knihovnu nalézt, je nutné nastavit proměnnou prostředí LD_LIBRARY_PATH (jinak by se knihovna hledala v /usr/lib popř. /usr/lib64 atd. ale nikoli v aktuálním adresáři.). V našem konkrétním případě to znamená, že se interpret Pythonu zavolá takto:
LD_LIBRARY_PATH=. python3 call_adder.py

Pokud byla knihovna vytvořena korektně, byla nalezena, načtena do interpretru a našla se v ní funkce add, měl by skript vypsat tento výstup:

0 + 0 = 0
0 + 1 = 1
0 + 2 = 2
0 + 3 = 3
0 + 4 = 4
0 + 5 = 5
1 + 0 = 1
1 + 1 = 2
1 + 2 = 3
1 + 3 = 4
1 + 4 = 5
1 + 5 = 6
2 + 0 = 2
2 + 1 = 3
2 + 2 = 4
2 + 3 = 5
2 + 4 = 6
2 + 5 = 7
3 + 0 = 3
3 + 1 = 4
3 + 2 = 5
3 + 3 = 6
3 + 4 = 7
3 + 5 = 8
4 + 0 = 4
4 + 1 = 5
4 + 2 = 6
4 + 3 = 7
4 + 4 = 8
4 + 5 = 9
5 + 0 = 5
5 + 1 = 6
5 + 2 = 7
5 + 3 = 8
5 + 4 = 9
5 + 5 = 10

Pokud naopak k nalezení a/nebo načtení knihovny z různých důvodů nedošlo, vypíše se odlišná zpráva a skript je ihned poté ukončen:

Traceback (most recent call last):
  File "call_adder.py", line 5, in <module>
    adder = ctypes.CDLL(libname)
  File "/usr/lib/python3.4/ctypes/__init__.py", line 351, in __init__
    self._handle = _dlopen(self._name, mode)
OSError: libadder.so: cannot open shared object file: No such file or directory

10. Projekt, v němž budeme testovat vlastnosti nativní funkce

Nyní se podívejme na strukturu projektu, v němž budeme testovat základní vlastnosti vybrané nativní funkce na základě testovacího scénáře. Celá struktura projektu bude založena na projektech předchozích, ovšem s tím rozdílem, že se v projektu nově objevil adresář lib obsahující zdrojový kód napsaný v jazyku C a skript určený pro vytvoření nativní knihovny:

.
├── feature_list.txt
├── features
│   ├── environment.py
│   ├── smoketest.feature
│   └── steps
│       └── common.py
├── lib
│   ├── adder.c
│   ├── clean.sh
│   └── make_library.sh
├── requirements.in
├── requirements.txt
└── run_tests.sh
 
3 directories, 10 files

11. Upravený testovací scénář

Testovací scénář nyní upravíme, a to takovým způsobem, že budeme očekávat přetečení výsledků za předpokladu, že velikost datového typu int je 32 bitů. Pokud tato podmínka nebude splněna, testy selžou:

Feature: Smoke test
 
  Scenario: Check the function int add(int, int)
    Given The library libadder.so is loaded
    When I call native function add with arguments 1 and 2
    Then I should get 3 as a result
 
  Scenario Outline: Thorough checking function int add(int, int)
    Given The library libadder.so is loaded
    When I call native function add with arguments <x> and <y>
    Then I should get <result> as a result
 
     Examples: result
     |x|y|result|
     # basic arithmetic
     |          0| 0|          0|
     |          1| 2|          3|
     |          1|-2|         -1|
     # no overflows at 16 bit limits
     |      32767| 1|      32768|
     |      65535| 1|      65536|
     # integer overflow
     | 2147483648| 1|-2147483647|
     |-2147483647|-1|-2147483648|
     |-2147483648|-1| 2147483647|

12. Upravená implementace testovacích kroků

Samozřejmě budeme muset upravit i implementaci testovacích kroků. Ovšem ještě předtím vytvoříme nový soubor nazvaný environment.py, který bude uložen do adresáře features, tj. do stejného adresáře, v němž se nachází i všechny soubory .feature. V tomto modulu budou deklarovány pomocné funkce volané automaticky interpretrem jazyka Gherkin. Nejdříve provedeme import potřebných modulů (ctypes pro načtení nativní knihovny, druhý modul pro logování, což prozatím nevyužijeme):

from behave.log_capture import capture
import ctypes

V tomto modulu si dále vytvoříme pomocnou funkci určenou pro načtení nativní knihovny a uložení reference na ni do kontextu:

def _load_library(context, library_name):
    if context.tested_library is None:
        context.tested_library = ctypes.CDLL(library_name)

Důležitá je funkce pojmenovaná before_all. Tato funkce je zavolána automaticky před vlastním testovacím scénářem a typicky se v ní nastavuje počáteční stav kontextu (což je jediný parametr předaný funkci při jejím volání):

def before_all(context):
    """Perform setup before the first event."""
    context.tested_library = None
    context.load_library = _load_library

Implementace kroků testu se opět nachází v souboru common.py. Povšimněte si provedených změn, především toho, že v given se pokusíme načíst nativní knihovnu zadaného jména. Pokud se načtení nativní knihovny podaří, bude v dalších krocích dostupná v atributu context.tested_library:

from behave import given, then, when
 
 
@given('The library {library_name} is loaded')
def initial_state(context, library_name):
    context.load_library(context, library_name)
 
 
@when('I call native function add with arguments {x:d} and {y:d}')
def call_add(context, x, y):
    context.result = context.tested_library.add(x, y)
 
 
@then('I should get {result:d} as a result')
def check_integer_result(context, result):
    assert context.result == result, "Expected result: {e}, returned value: {r}".format(e=result, r=context.result)

13. Spuštění skriptu s testy nativní funkce, výsledek testů

Pokud byla nativní funkce korektně přeložena, měly by se po spuštění skriptu run_tests.sh na standardním výstupu objevit následující řádky produkované knihovnou Behave:

Feature: Smoke test # features/smoketest.feature:1
 
  @smoketest
  Scenario: Check the function int add(int, int)           # features/smoketest.feature:4
    Given The library libadder.so is loaded                # features/steps/common.py:4
    When I call native function add with arguments 1 and 2 # features/steps/common.py:9
    Then I should get 3 as a result                        # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.1 result  # features/smoketest.feature:17
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with arguments 0 and 0                       # features/steps/common.py:9
    Then I should get 0 as a result                                              # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.2 result  # features/smoketest.feature:18
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with arguments 1 and 2                       # features/steps/common.py:9
    Then I should get 3 as a result                                              # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.3 result  # features/smoketest.feature:19
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with arguments 1 and -2                      # features/steps/common.py:9
    Then I should get -1 as a result                                             # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.4 result  # features/smoketest.feature:21
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with arguments 32767 and 1                   # features/steps/common.py:9
    Then I should get 32768 as a result                                          # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.5 result  # features/smoketest.feature:22
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with arguments 65535 and 1                   # features/steps/common.py:9
    Then I should get 65536 as a result                                          # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.6 result  # features/smoketest.feature:24
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with arguments 2147483648 and 1              # features/steps/common.py:9
    Then I should get -2147483647 as a result                                    # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.7 result  # features/smoketest.feature:25
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with arguments -2147483647 and -1            # features/steps/common.py:9
    Then I should get -2147483648 as a result                                    # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.8 result  # features/smoketest.feature:26
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with arguments -2147483648 and -1            # features/steps/common.py:9
    Then I should get 2147483647 as a result                                     # features/steps/common.py:14
 
1 feature passed, 0 failed, 0 skipped
9 scenarios passed, 0 failed, 0 skipped
27 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.003s

Obrázek 4: Výsledek běhu testovacích scénářů ve chvíli, kdy se nativní funkce chová podle očekávání.

14. Spuštění skriptu ve chvíli, kdy není nativní funkce nalezena

V případě, že jsme vynechali krok překladu nativní funkce popř. se nepodařilo nativní knihovnu vůbec načíst, bude výsledek testů mnohem delší a pesimističtější:

Feature: Smoke test # features/smoketest.feature:1
 
  @smoketest
  Scenario: Check the function int add(int, int)           # features/smoketest.feature:4
    Given The library libadder.so is loaded                # features/steps/common.py:4
      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 6, in initial_state
          context.load_library(context, library_name)
        File "features/environment.py", line 7, in _load_library
          context.tested_library = ctypes.CDLL(library_name)
        File "/usr/lib/python3.4/ctypes/__init__.py", line 351, in __init__
          self._handle = _dlopen(self._name, mode)
      OSError: libadder.so: cannot open shared object file: No such file or directory
 
    When I call native function add with arguments 1 and 2 # None
    Then I should get 3 as a result                        # None

…následují informace o pádu všech dalších testů…

Failing scenarios:
  features/smoketest.feature:4  Check the function int add(int, int)
  features/smoketest.feature:17  Thorough checking function int add(int, int) -- @1.1 result
  features/smoketest.feature:18  Thorough checking function int add(int, int) -- @1.2 result
  features/smoketest.feature:19  Thorough checking function int add(int, int) -- @1.3 result
  features/smoketest.feature:21  Thorough checking function int add(int, int) -- @1.4 result
  features/smoketest.feature:22  Thorough checking function int add(int, int) -- @1.5 result
  features/smoketest.feature:24  Thorough checking function int add(int, int) -- @1.6 result
  features/smoketest.feature:25  Thorough checking function int add(int, int) -- @1.7 result
  features/smoketest.feature:26  Thorough checking function int add(int, int) -- @1.8 result
 
0 features passed, 1 failed, 0 skipped
0 scenarios passed, 9 failed, 0 skipped
0 steps passed, 9 failed, 18 skipped, 0 undefined
Took 0m0.002s

Obrázek 5: Výsledek běhu testovacího scénáře ve chvíli, kdy se nepodařilo načíst nativní funkci.

15. Další modifikace testovacího scénáře – určení typů parametrů předávaných nativní funkci

Nyní si testovací scénář nepatrně modifikujeme, protože uvedeme i typy parametrů předávaných do nativní funkce. V následujícím výpisu jsou změněné části zvýrazněny tučně:

Feature: Smoke test
 
  Scenario: Check the function int add(int, int)
    Given The library libadder.so is loaded
    When I call native function add with integer arguments 1 and 2
    Then I should get 3 as a result
 
  Scenario Outline: Thorough checking function int add(int, int)
    Given The library libadder.so is loaded
    When I call native function add with integer arguments <x> and <y>
    Then I should get <result> as a result
 
     Examples: result
     |x|y|result|
     # basic arithmetic
     |          0| 0|          0|
     |          1| 2|          3|
     |          1|-2|         -1|
     # no overflows at 16 bit limits
     |      32767| 1|      32768|
     |      65535| 1|      65536|
     # integer overflow
     | 2147483648| 1|-2147483647|
     |-2147483647|-1|-2147483648|
     |-2147483648|-1| 2147483647|

16. Modifikace kroků testů

I jednotlivé implementace testovacích kroků budeme modifikovat. Zejména se to týká kroku:

„I call native function add with integer arguments {x:d} and {y:d}“

který je nahrazen za obecnější krok, v němž se jméno funkce vyskytuje jako proměnný text:

„I call native function {function} with integer arguments {x:d} and {y:d}“

Podívejme se nyní na způsob zavolání funkce, jejíž jméno je proměnné:

from behave import given, then, when
 
 
@given('The library {library_name} is loaded')
def initial_state(context, library_name):
    context.load_library(context, library_name)
 
 
@when('I call native function {function} with integer arguments {x:d} and {y:d}')
def call_add(context, function, x, y):
    context.result = getattr(context.tested_library, function)(x, y)
 
 
@then('I should get {result:d} as a result')
def check_integer_result(context, result):
    assert context.result == result
Poznámka: tímto způsobem je vlastně možné po nepatrné úpravě scénáře testovat libovolnou (nativní) funkci.

17. Spuštění modifikovaného skriptu s testy nativní funkce, výsledek testů

Po spuštění modifikovaného skriptu s testy by se měly na standardním výstupu objevit tyto zprávy, které nás informují o tom, že se nativní funkce add skutečně chová podle očekávání:

CS24_early

Feature: Smoke test # features/smoketest.feature:1
 
  Scenario: Check the function int add(int, int)                   # features/smoketest.feature:4
    Given The library libadder.so is loaded                        # features/steps/common.py:4
    When I call native function add with integer arguments 1 and 2 # features/steps/common.py:9
    Then I should get 3 as a result                                # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.1 result  # features/smoketest.feature:17
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with integer arguments 0 and 0               # features/steps/common.py:9
    Then I should get 0 as a result                                              # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.2 result  # features/smoketest.feature:18
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with integer arguments 1 and 2               # features/steps/common.py:9
    Then I should get 3 as a result                                              # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.3 result  # features/smoketest.feature:19
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with integer arguments 1 and -2              # features/steps/common.py:9
    Then I should get -1 as a result                                             # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.4 result  # features/smoketest.feature:21
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with integer arguments 32767 and 1           # features/steps/common.py:9
    Then I should get 32768 as a result                                          # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.5 result  # features/smoketest.feature:22
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with integer arguments 65535 and 1           # features/steps/common.py:9
    Then I should get 65536 as a result                                          # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.6 result  # features/smoketest.feature:24
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with integer arguments 2147483648 and 1      # features/steps/common.py:9
    Then I should get -2147483647 as a result                                    # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.7 result  # features/smoketest.feature:25
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with integer arguments -2147483647 and -1    # features/steps/common.py:9
    Then I should get -2147483648 as a result                                    # features/steps/common.py:14
 
  Scenario Outline: Thorough checking function int add(int, int) -- @1.8 result  # features/smoketest.feature:26
    Given The library libadder.so is loaded                                      # features/steps/common.py:4
    When I call native function add with integer arguments -2147483648 and -1    # features/steps/common.py:9
    Then I should get 2147483647 as a result                                     # features/steps/common.py:14
 
1 feature passed, 0 failed, 0 skipped
9 scenarios passed, 0 failed, 0 skipped
27 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.003s

18. Repositář s demonstračními příklady

Všech pět demonstračních projektů které jsme si dnes popsali, byly společně s projektem obsahujícím pouze nativní funkci volanou z Pythonu, uloženy do repositáře, který naleznete na adrese https://github.com/tisnik/python-behave-demos. V první tabulce jsou zobrazeny odkazy na tyto projekty:

Projekt Popis Cesta
test_python_function1 první příklad s jedinou testovanou funkcí https://github.com/tisnik/python-behave-demos/tree/master/test_pyt­hon_function1
test_python_function2 vylepšení o osnovu atd. https://github.com/tisnik/python-behave-demos/tree/master/test_pyt­hon_function2
test_python_function3 změna struktury (více .features atd.) https://github.com/tisnik/python-behave-demos/tree/master/test_pyt­hon_function3
native_lib ukázka volání nativní funkce https://github.com/tisnik/python-behave-demos/tree/master/native_lib
test_native_lib1 test nativní funkce https://github.com/tisnik/python-behave-demos/tree/master/test_native_lib1
test_native_lib2 vylepšený test https://github.com/tisnik/python-behave-demos/tree/master/test_native_lib2

Ve druhé tabulce jsou zobrazeny odkazy na demonstrační příklady, které budou použity ve druhé části tohoto článku:

19. 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. Test Fixture (Wikipedia)
    https://en.wikipedia.org/wi­ki/Test_fixture
  7. Behavior-driven development (Wikipedia)
    https://en.wikipedia.org/wiki/Behavior-driven_development
  8. Cucumber
    https://cucumber.io/
  9. Jasmine
    https://jasmine.github.io/
  10. Pip (dokumentace)
    https://pip.pypa.io/en/stable/
  11. Tox
    https://tox.readthedocs.io/en/latest/
  12. Extrémní programování
    https://cs.wikipedia.org/wi­ki/Extr%C3%A9mn%C3%AD_pro­gramov%C3%A1n%C3%AD
  13. Programování řízené testy
    https://cs.wikipedia.org/wi­ki/Programov%C3%A1n%C3%AD_%C5%99%­C3%ADzen%C3%A9_testy
  14. Test-driven development (Wikipedia)
    https://en.wikipedia.org/wiki/Test-driven_development
  15. Python namespaces
    https://bytebaker.com/2008/07/30/pyt­hon-namespaces/
  16. Namespaces and Scopes
    https://www.python-course.eu/namespaces.php
  17. pdb — The Python Debugger
    https://docs.python.org/3­.6/library/pdb.html
  18. pdb – Interactive Debugger
    https://pymotw.com/2/pdb/

Byl pro vás článek přínosný?

Autor článku

Vystudoval VUT FIT a v současné době pracuje na projektech vytvářených v jazycích Python a Go.