Obsah
1. Behavior-driven development v Pythonu s využitím knihovny Behave (závěrečná část)
2. Zopakování příkladu, popsaného v závěru předchozího článku
3. Výsledek běhu příkladu při použití existující URL i adresy neexistující
4. Druhý demonstrační příklad – vyhledání uživatelů na GitHubu podle jejich uživatelského jména
5. Implementace jednotlivých kroků testu ve druhém demonstračním příkladu
6. Výsledek spuštění druhého demonstračního příkladu
7. Třetí demonstrační příklad – použití tabulek se seznamem jmen uživatelů o očekávaných výsledků
8. Výsledek spuštění třetího demonstračního příkladu
9. Úprava příkladu pro uživatele GitHubu, u nichž není vyplněna společnost
10. Výsledky běhu čtvrtého demonstračního příkladu
11. Další vlastnosti nabízené knihovnou Behave a jazykem Gherkin
12. Použití víceřádkového textu v testovacím scénáři
13. Demonstrační příklad – použití víceřádkového textu v sekci Given
14. Výsledek běhu demonstračního příkladu
15. Demonstrační příklad – použití víceřádkového textu v sekci Then
16. Výsledek běhu demonstračního příkladu
17. Jak je možné zařídit opakování některých operací?
18. Výsledek běhu posledního demonstračního příkladu
19. Repositář s demonstračními příklady
1. Behavior-driven development v Pythonu s využitím knihovny Behave (závěrečná část)
I když je jazyk Gherkin, nad nímž je knihovna Behave postavena, naschvál navržen takovým způsobem, aby nebyl turingovsky úplný, je v něm možné zapisovat i relativně složité testovací scénáře. Mnohdy je však nutné využívat různé triky. Dnes se seznámíme s dalšími možnostmi tohoto jazyka v té podobě, v jaké je implementován právě v Pythonovské knihovně Behave. První příklady jsme si již v poněkud odlišné verzi představili při popisu knihovny Cucumber určené pro programovací jazyk Clojure.
2. Zopakování příkladu, popsaného v závěru předchozího článku
Nejprve si v krátkosti připomeneme demonstrační příklad popsaný v závěru předchozího článku. V tomto příkladu 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). V testovacím scénáři 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 je následující (ukazujeme si ji jen zde, ostatní příklady totiž budou mít naprosto shodnou strukturu):
. ├── feature_list.txt ├── features │ ├── environment.py │ ├── smoketest.feature │ └── steps │ └── common.py ├── requirements.in ├── requirements.txt └── run_tests.sh 2 directories, 7 files
Soubor environment.py uložený do adresáře features obsahuje deklaraci funkce nazvané _is_accessible, jejíž reference bude uložena do kontextu. Podobně do kontextu uložíme atribut nesoucí adresu REST API GitHubu:
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"
A konečně 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
3. Výsledek běhu příkladu při použití existující URL i adresy neexistující
V případě, že je URL pro REST API GitHubu zapsáno korektně, měl by test po svém spuštění vypsat tyto řádky oznamující, že se (jediný) testovací scénář se třemi kroky provedl bez chyby:
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.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
Pokud naopak URL schválně zapíšeme špatně, dojde k chybě, a to už v kroku Given, tj. vlastně při přípravě testu. Povšimněte si, že se kromě vlastní chyby vypíšou i informace o adrese, na kterou jsme se snažili připojit:
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
Pokud naopak URL ponecháme a změníme očekávaný návratový kód, dostaneme tyto zprávy (test spadne až v sekci Then):
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.635s When I access the API endpoint / # features/steps/common.py:13 0.616s Then I should receive 404 status code # features/steps/common.py:18 0.000s 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 21, in check_status_code assert context.response.status_code == status AssertionError Captured logging: INFO:urllib3.connectionpool:Starting new HTTPS connection (1): api.github.com INFO:urllib3.connectionpool:Starting new HTTPS connection (1): api.github.com 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 2 steps passed, 1 failed, 0 skipped, 0 undefined Took 0m1.252s
4. Druhý demonstrační příklad – vyhledání uživatelů na GitHubu podle jejich uživatelského jména
Nyní se zaměřme na nepatrně složitější testovací scénář. Budeme v něm na GitHubu vyhledávat uživatele podle jejich nicku (přezdívky) a zjišťovat jejich skutečné jméno a firmu, pro kterou pracují. Pro tento účel existuje v REST API GitHubu příslušný endpoint, takže implementace testovacích kroků bude poměrně jednoduchá (dokonce se ani nemusíme na GitHub přihlašovat). Nejprve si ukažme první verzi scénáře. Může vypadat takto:
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 Scenario: Check the user search feature Given GitHub is accessible When I search for user with nick torvalds Then I should receive 200 status code And I should receive proper JSON response And I should find the user with full name Linus Torvalds And I should find that the user works for company Linux Foundation
Teoreticky tedy můžeme psát i:
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 Scenario: Check the user search feature Given GitHub is accessible When I search for user with nick torvalds Then I should receive 200 status code Then I should receive proper JSON response Then I should find the user with full name Linus Torvalds Then I should find that the user works for company Linux Foundation
Nebo dokonce (dosti nelogicky):
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 Scenario: Check the user search feature Given GitHub is accessible When I search for user with nick torvalds Then I should receive 200 status code And I should receive proper JSON response But I should find the user with full name Linus Torvalds And I should find that the user works for company Linux Foundation
5. Implementace jednotlivých kroků testu ve druhém demonstračním příkladu
Nyní se podíváme na způsob implementace jednotlivých kroků testu. První část souboru common.py je shodná s předchozím příkladem (proto jsme si ho ostatně znovu ukázali):
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) @given('System is running') def running_system(context): """Ensure that the system is accessible.""" assert 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
Následuje implementace nových kroků. Nejdříve se jedná o krok, v němž se pokusíme vyhledat uživatele podle jeho nicku (přezdívky). Je to snadné, protože postačuje do REST API endpointu „/users/“ poslat nick a měl by se vrátit JSON s podrobnějšími informacemi o tomto uživateli. Odpověď, kterou získáme, uložíme bez dalšího zpracování do kontextu:
@when('I search for user with nick {nick}') def search_for_user(context, nick): url = urljoin(urljoin(context.api_url, "users/"), nick) context.response = requests.get(url)
Dále následuje test, který zjišťuje, jestli se v těle odpovědi skutečně nachází data ve formátu JSON. V tomto případě se pouze pokusíme o parsing dat se zahozením výsledku (parsing není „línou“ funkcí, takže by se skutečně měl provést):
@then('I should receive proper JSON response') def check_json_response(context): content_type = context.response.headers.get('content-type') assert content_type.startswith('application/json') context.data = context.response.json()
Poslední dva testovací kroky zjišťují informace uložené pod klíči „name“ a „company“ v datové struktuře získané z JSON dat. Provedeme i předběžné testy, zda se vůbec daná dvojice klíč+hodnota v datové struktuře vyskytuje:
@then('I should find the user with full name {fullname}') def check_user_full_name(context, fullname): assert context.data is not None assert 'name' in context.data value = context.data.get('name') assert value == fullname, "{e} != {v}".format(e=fullname, v=value) @then('I should find that the user works for company {company}') def check_company(context, company): assert context.data is not None assert 'company' in context.data value = context.data.get('company') assert value == company, "{e} != {v}".format(e=company, v=value)
6. Výsledek spuštění druhého demonstračního příkladu
Po spuštění druhého příkladu by se měly zobrazit tyto zprávy znamenající, že oba dva testovací scénáře dopadly podle očekávání správně:
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.626s When I access the API endpoint / # features/steps/common.py:19 0.638s Then I should receive 200 status code # features/steps/common.py:30 0.000s Scenario: Check the user search feature # features/smoketest.feature:9 Given GitHub is accessible # features/steps/common.py:8 0.627s When I search for user with nick torvalds # features/steps/common.py:24 0.660s Then I should receive 200 status code # features/steps/common.py:30 0.000s And I should receive proper JSON response # features/steps/common.py:36 0.000s And I should find the user with full name Linus Torvalds # features/steps/common.py:43 0.000s And I should find that the user works for company Linux Foundation # features/steps/common.py:51 0.000s 1 feature passed, 0 failed, 0 skipped 2 scenarios passed, 0 failed, 0 skipped 9 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m2.552s
7. Třetí demonstrační příklad – použití tabulek se seznamem jmen uživatelů o očekávaných výsledků
Připomeňme si, že jednou z velmi zajímavých možností, jakými je možné 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. Takovou tabulku si samozřejmě můžeme připravit i pro test s vyhledáním uživatelů na GitHubu (poslední scénář resp. přesněji řečeno osnova scénáře):
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 Scenario: Check the user search feature Given GitHub is accessible When I search for user with nick torvalds Then I should receive 200 status code And I should receive proper JSON response And I should find the user with full name Linus Torvalds And I should find that the user works for company Linux Foundation Scenario Outline: Check the user search feature, perform the search for more users Given GitHub is accessible When I search for user with nick Then I should receive 200 status code And I should receive proper JSON response And I should find the user with full name And I should find that the user works for company Examples: users |nick|fullname|company| |torvalds|Linus Torvalds|Linux Foundation| |brammool|Bram Moolenaar|Zimbu Labs| |tisnik|Pavel Tišnovský|Red Hat, Inc.|
Jak již pravděpodobně tušíte, není nutné v implementaci testovacích kroků provádět žádné změny, takže se jen v krátkosti podívejme na úplný obsah souboru features/steps/common.py. Ten naleznete na adrese https://github.com/tisnik/python-behave-demos/blob/master/github_test_version3/features/steps/common.py:
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) @given('System is running') def running_system(context): """Ensure that the system is accessible.""" assert is_accessible(context) @when('I access the API endpoint {url}') def access_endpoint(context, url): context.response = requests.get(context.api_url + url) @when('I search for user with nick {nick}') def search_for_user(context, nick): url = urljoin(urljoin(context.api_url, "users/"), nick) context.response = requests.get(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 @then('I should receive proper JSON response') def check_json_response(context): content_type = context.response.headers.get('content-type') assert content_type.startswith('application/json') context.data = context.response.json() @then('I should find the user with full name {fullname}') def check_user_full_name(context, fullname): assert context.data is not None assert 'name' in context.data value = context.data.get('name') assert value == fullname, "{e} != {v}".format(e=fullname, v=value) @then('I should find that the user works for company {company}') def check_company(context, company): assert context.data is not None assert 'company' in context.data value = context.data.get('company') assert value == company, "{e} != {v}".format(e=company, v=value)
8. Výsledek spuštění třetího demonstračního příkladu
Nyní je již vše připravené pro použití nového testovacího scénáře, takže si ho zkusme spustit:
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.637s When I access the API endpoint / # features/steps/common.py:19 0.637s Then I should receive 200 status code # features/steps/common.py:30 0.000s Scenario: Check the user search feature # features/smoketest.feature:9 Given GitHub is accessible # features/steps/common.py:8 2.065s When I search for user with nick torvalds # features/steps/common.py:24 0.658s Then I should receive 200 status code # features/steps/common.py:30 0.000s And I should receive proper JSON response # features/steps/common.py:36 0.000s And I should find the user with full name Linus Torvalds # features/steps/common.py:43 0.000s And I should find that the user works for company Linux Foundation # features/steps/common.py:51 0.000s Scenario Outline: Check the user search feature, perform the search for more users -- @1.1 users # features/smoketest.feature:27 Given GitHub is accessible # features/steps/common.py:8 0.636s When I search for user with nick torvalds # features/steps/common.py:24 0.657s Then I should receive 200 status code # features/steps/common.py:30 0.000s And I should receive proper JSON response # features/steps/common.py:36 0.000s And I should find the user with full name Linus Torvalds # features/steps/common.py:43 0.000s And I should find that the user works for company Linux Foundation # features/steps/common.py:51 0.000s Scenario Outline: Check the user search feature, perform the search for more users -- @1.2 users # features/smoketest.feature:28 Given GitHub is accessible # features/steps/common.py:8 0.657s When I search for user with nick brammool # features/steps/common.py:24 0.641s Then I should receive 200 status code # features/steps/common.py:30 0.000s And I should receive proper JSON response # features/steps/common.py:36 0.000s And I should find the user with full name Bram Moolenaar # features/steps/common.py:43 0.000s And I should find that the user works for company Zimbu Labs # features/steps/common.py:51 0.000s Scenario Outline: Check the user search feature, perform the search for more users -- @1.3 users # features/smoketest.feature:29 Given GitHub is accessible # features/steps/common.py:8 0.635s When I search for user with nick tisnik # features/steps/common.py:24 0.645s Then I should receive 200 status code # features/steps/common.py:30 0.000s And I should receive proper JSON response # features/steps/common.py:36 0.000s And I should find the user with full name Pavel Tišnovský # features/steps/common.py:43 0.000s And I should find that the user works for company Red Hat, Inc. # features/steps/common.py:51 0.000s 1 feature passed, 0 failed, 0 skipped 5 scenarios passed, 0 failed, 0 skipped 27 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m7.873s
Celkový počet kroků zde dosahuje hodnoty 27. Je tomu tak pochopitelně z toho důvodu, že se všechny kroky v posledním scénáři opakují třikrát, pokaždé pro jiný nick.
9. Úprava příkladu pro uživatele GitHubu, u nichž není vyplněna společnost
Někteří uživatelé přihlášení na GitHubu nemají vyplněnou společnost. To je kupodivu případ i Riche Hickeyho – autora programovacího jazyka Clojure i dalších zajímavých projektů. Nejdříve tedy Riche přidáme do tabulky v testovacím scénáři:
Feature: Smoke test Scenario Outline: Check the user search feature, perform the search for more users Given GitHub is accessible When I search for user with nick Then I should receive 200 status code And I should receive proper JSON response And I should find the user with full name And I should find that the user works for company Examples: users |nick|fullname|company| |torvalds|Linus Torvalds|Linux Foundation| |brammool|Bram Moolenaar|Zimbu Labs| |richhickey|Rich Hickey|| |tisnik|Pavel Tišnovský|Red Hat, Inc.|
A následně rozšíříme soubor common.py o další testovací krok:
@then('I should find that the user works for company ') def check_company_not_set(context): assert context.data is not None assert 'company' in context.data value = context.data.get('company', '') assert not value
Zdrojový kód souboru common.py se tedy rozšíří následovně:
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) @given('System is running') def running_system(context): """Ensure that the system is accessible.""" assert is_accessible(context) @when('I access the API endpoint {url}') def access_endpoint(context, url): context.response = requests.get(context.api_url + url) @when('I search for user with nick {nick}') def search_for_user(context, nick): url = urljoin(urljoin(context.api_url, "users/"), nick) context.response = requests.get(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 @then('I should receive proper JSON response') def check_json_response(context): content_type = context.response.headers.get('content-type') assert content_type.startswith('application/json') context.data = context.response.json() @then('I should find the user with full name {fullname}') def check_user_full_name(context, fullname): assert context.data is not None assert 'name' in context.data value = context.data.get('name') assert value == fullname, "{e} != {v}".format(e=fullname, v=value) @then('I should find that the user works for company {company}') def check_company(context, company): assert context.data is not None assert 'company' in context.data value = context.data.get('company', '') assert value == company, "{e} != {v}".format(e=company, v=value) @then('I should find that the user works for company ') def check_company_not_set(context): assert context.data is not None assert 'company' in context.data value = context.data.get('company', '') assert not value
10. Výsledky běhu čtvrtého demonstračního příkladu
Po spuštění čtvrtého demonstračního příkladu se na standardním výstupu zobrazí následující řádky. Povšimněte si toho, že je správně nalezen i Rich Hickey, který ve svém profilu žádnou společnost neuvedl:
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:19 0.587s Then I should receive 200 status code # features/steps/common.py:30 0.000s Scenario: Check the user search feature # features/smoketest.feature:9 Given GitHub is accessible # features/steps/common.py:8 0.615s When I search for user with nick torvalds # features/steps/common.py:24 0.633s Then I should receive 200 status code # features/steps/common.py:30 0.000s And I should receive proper JSON response # features/steps/common.py:36 0.000s And I should find the user with full name Linus Torvalds # features/steps/common.py:43 0.000s And I should find that the user works for company Linux Foundation # features/steps/common.py:51 0.000s Scenario Outline: Check the user search feature, perform the search for more users -- @1.1 users # features/smoketest.feature:27 Given GitHub is accessible # features/steps/common.py:8 0.575s When I search for user with nick torvalds # features/steps/common.py:24 0.639s Then I should receive 200 status code # features/steps/common.py:30 0.000s And I should receive proper JSON response # features/steps/common.py:36 0.000s And I should find the user with full name Linus Torvalds # features/steps/common.py:43 0.000s And I should find that the user works for company Linux Foundation # features/steps/common.py:51 0.000s Scenario Outline: Check the user search feature, perform the search for more users -- @1.2 users # features/smoketest.feature:28 Given GitHub is accessible # features/steps/common.py:8 0.648s When I search for user with nick brammool # features/steps/common.py:24 0.628s Then I should receive 200 status code # features/steps/common.py:30 0.000s And I should receive proper JSON response # features/steps/common.py:36 0.000s And I should find the user with full name Bram Moolenaar # features/steps/common.py:43 0.000s And I should find that the user works for company Zimbu Labs # features/steps/common.py:51 0.000s Scenario Outline: Check the user search feature, perform the search for more users -- @1.3 users # features/smoketest.feature:29 Given GitHub is accessible # features/steps/common.py:8 0.611s When I search for user with nick richhickey # features/steps/common.py:24 0.611s Then I should receive 200 status code # features/steps/common.py:30 0.000s And I should receive proper JSON response # features/steps/common.py:36 0.000s And I should find the user with full name Rich Hickey # features/steps/common.py:43 0.000s And I should find that the user works for company # features/steps/common.py:59 0.000s Scenario Outline: Check the user search feature, perform the search for more users -- @1.4 users # features/smoketest.feature:30 Given GitHub is accessible # features/steps/common.py:8 0.580s When I search for user with nick tisnik # features/steps/common.py:24 0.610s Then I should receive 200 status code # features/steps/common.py:30 0.000s And I should receive proper JSON response # features/steps/common.py:36 0.000s And I should find the user with full name Pavel Tišnovský # features/steps/common.py:43 0.000s And I should find that the user works for company Red Hat, Inc. # features/steps/common.py:51 0.000s 1 feature passed, 0 failed, 0 skipped 6 scenarios passed, 0 failed, 0 skipped 33 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m7.369s
11. Další vlastnosti nabízené knihovnou Behave a jazykem Gherkin
V navazujících kapitolách si popíšeme některé další zajímavé vlastnosti knihovny Behave popř. jazyka Gherkin. V první řadě se seznámíme se způsobem použití víceřádkových textů (řetězců) v testovacích scénářích. Takové texty lze použít na mnoha místech, například pro kontrolu různých API, funkcí či knihoven pracujících s testem; teoreticky lze přímo do scénáře zapsat i krátký datový soubor ve formátu JSON, YAML či XML apod. Již dopředu si můžeme říct, že víceřádkové texty je možné použít ve všech krocích, tj. v Given, When, Then i And a But. Na závěr si ukážeme jednu z možností použití „smyček“. Ty sice jazyk Gherkin neobsahuje (jedná se o schválně omezený DSL), ovšem smyčky samozřejmě je možné implementovat v jednotlivých testovacích krocích a posléze můžeme jejich výstup uložit do kontextu a zpracovat v dalších krocích.
12. Použití víceřádkového textu v testovacím scénáři
Mnohdy se setkáme se situací, v níž je zapotřebí otestovat chování funkcí, do nichž je možné předávat relativně dlouhé texty, někdy i texty víceřádkové. Může se jednat například o funkci nazvanou count_words, která má vrátit počet slov nalezených v textu. Jedna z implementací může vypadat takto (ve skutečnosti lze funkci zkrátit, nicméne si vyzkoušejme explicitní určený znaku, kde dojke k rozdělení řetězce):
def count_words(text): line = text.replace('\n', ' ') words = line.split(' ') return len(words)
V navazující kapitole si ukážeme, jak je možné tuto situaci velmi elegantně vyřešit.
13. Demonstrační příklad – použití víceřádkového textu v sekci Given
V následujícím demonstračním příkladu, jehož úplnou podobu naleznete na adrese https://github.com/tisnik/python-behave-demos/tree/master/multiline_text1 je ukázáno, jakým způsobem je možné přidat jednořádkový i víceřádkový text do sekce Given. Nejprve se podívejme na celý testovací scénář, v němž jsou skutečně použity texty zapsané pod řádkem s Given. Povšimněte si použití ztrojených uvozovek, podobně jako je tomu v Pythonu:
Feature: Count words function test Scenario: Check the function count_words() Given a sample text """ Velmi kratka veta. """ When I count all words in text Then I should get 3 as a result Scenario: Check the function count_words() Given a sample text """ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. """ When I count all words in text Then I should get 19 as a result
Implementace testovacích kroků je poměrně přímočará, až na následující „trik“, který nám zajistí, že se (víceřádkový) text zapsaný v sekci Given neztratí (atribut context.text je totiž v každém kroku přepsán, takže si obsah textu musíme uložit do jiného atributu):
@given(u'a sample text') def a_sample_text(context): assert context.text context.input = context.text
Úplný zdrojový kód souboru features/steps/common.py vypadá následovně:
from behave import given, then, when # import testovane funkce from src.count_words import count_words @given(u'a sample text') def a_sample_text(context): assert context.text context.input = context.text @when(u'I count all words in text') def step_impl(context): """Zavolani testovane funkce.""" context.result = count_words(context.input) @then('I should get {expected:d} as a result') def check_result(context, expected): """Porovnani vypocteneho vysledku s vysledkem ocekavanym.""" assert context.result == expected, \ "Wrong result: {r} != {e}".format(r=context.result, e=expected)
14. Výsledek běhu demonstračního příkladu
Podívejme se nyní na výsledek spuštění tohoto demonstračního příkladu. Vzhledem k tomu, že je testovaná funkce implementovaná korektně, dostaneme následující výsledky:
Feature: Count words function test # features/count_words.feature:1 Scenario: Check the function count_words() # features/count_words.feature:3 Given a sample text # features/steps/common.py:7 0.000s """ Velmi kratka veta. """ When I count all words in text # features/steps/common.py:13 0.000s Then I should get 3 as a result # features/steps/common.py:19 0.000s Scenario: Check the function count_words() # features/count_words.feature:11 Given a sample text # features/steps/common.py:7 0.000s """ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. """ When I count all words in text # features/steps/common.py:13 0.000s Then I should get 19 as a result # features/steps/common.py:19 0.000s 1 feature passed, 0 failed, 0 skipped 2 scenarios passed, 0 failed, 0 skipped 6 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m0.001s
Ovšem v případě, že testovanou funkci změníme, například následovně:
def count_words(text): words = text.split(' ') return len(words)
skončí druhý testovací scénář s víceřádkovým textem s chybou:
Feature: Count words function test # features/count_words.feature:1 Scenario: Check the function count_words() # features/count_words.feature:3 Given a sample text # features/steps/common.py:7 0.000s """ Velmi kratka veta. """ When I count all words in text # features/steps/common.py:13 0.000s Then I should get 3 as a result # features/steps/common.py:19 0.000s Scenario: Check the function count_words() # features/count_words.feature:11 Given a sample text # features/steps/common.py:7 0.000s """ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. """ When I count all words in text # features/steps/common.py:13 0.000s Then I should get 19 as a result # features/steps/common.py:19 0.000s Assertion Failed: Wrong result: 18 != 19 Failing scenarios: features/count_words.feature:11 Check the function count_words() 0 features passed, 1 failed, 0 skipped 1 scenario passed, 1 failed, 0 skipped 5 steps passed, 1 failed, 0 skipped, 0 undefined Took 0m0.001s
15. Demonstrační příklad – použití víceřádkového textu v sekci Then
Předpokládejme nyní, že budeme chtít testovat uživatelem definovanou funkci pojmenovanou to_uppercase, jejíž tělo je velmi jednoduché:
def to_uppercase(text): return text.upper()
Nyní budeme potřebovat, aby se víceřádkový text použil nejenom v sekci Given, ale i v sekci Then. Testovací scénáře se změní následovně:
Feature: Count words function test Scenario: Check the function count_words() Given a sample text """ Velmi kratka veta. """ When I translate the text to upper case Then I should get the following text as a result """ VELMI KRATKA VETA. """ Scenario: Check the function count_words() Given a sample text """ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. """ When I translate the text to upper case Then I should get the following text as a result """ LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT, SED DO EIUSMOD TEMPOR INCIDIDUNT UT LABORE ET DOLORE MAGNA ALIQUA. """
Ve skutečnosti bude příprava testovacích kroků jednoduchá, protoře atribut context.text bude korektně připraven u všech kroků testu, tedy jak ve funkci anotované @given, tak i ve funkci s anotací @then:
from behave import given, then, when # import testovane funkce from src.to_uppercase import to_uppercase @given(u'a sample text') def a_sample_text(context): assert context.text context.input = context.text @when(u'I translate the text to upper case') def step_impl(context): """Zavolani testovane funkce.""" context.result = to_uppercase(context.input) @then('I should get the following text as a result') def check_result(context): """Porovnani vypocteneho vysledku s vysledkem ocekavanym.""" assert context.result == context.text, \ "Wrong result: {r} != {e}".format(r=context.result, e=context.text)
16. Výsledek běhu demonstračního příkladu
Opět se podívejme na výsledek běhu tohoto demonstračního příkladu. Všechny testy by měly skončit v pořádku:
Feature: Count words function test # features/to_upper.feature:1 Scenario: Check the function count_words() # features/to_upper.feature:3 Given a sample text # features/steps/common.py:7 0.000s """ Velmi kratka veta. """ When I translate the text to upper case # features/steps/common.py:13 0.000s Then I should get the following text as a result # features/steps/common.py:19 0.000s """ VELMI KRATKA VETA. """ Scenario: Check the function count_words() # features/to_upper.feature:14 Given a sample text # features/steps/common.py:7 0.000s """ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. """ When I translate the text to upper case # features/steps/common.py:13 0.000s Then I should get the following text as a result # features/steps/common.py:19 0.000s """ LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT, SED DO EIUSMOD TEMPOR INCIDIDUNT UT LABORE ET DOLORE MAGNA ALIQUA. """ 1 feature passed, 0 failed, 0 skipped 2 scenarios passed, 0 failed, 0 skipped 6 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m0.001s
Pokud testovanou funkci omylem změníme na:
def to_uppercase(text): return text.title()
Dojde samozřejmě k detekci této chyby v obou scénářích (povšimněte si okomentování chyby):
Feature: Count words function test # features/to_upper.feature:1 Scenario: Check the function count_words() # features/to_upper.feature:3 Given a sample text # features/steps/common.py:7 0.000s """ Velmi kratka veta. """ When I translate the text to upper case # features/steps/common.py:13 0.000s Then I should get the following text as a result # features/steps/common.py:19 0.000s """ VELMI KRATKA VETA. """ Assertion Failed: Wrong result: Velmi Kratka Veta. != VELMI KRATKA VETA. Scenario: Check the function count_words() # features/to_upper.feature:14 Given a sample text # features/steps/common.py:7 0.000s """ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. """ When I translate the text to upper case # features/steps/common.py:13 0.000s Then I should get the following text as a result # features/steps/common.py:19 0.000s """ LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT, SED DO EIUSMOD TEMPOR INCIDIDUNT UT LABORE ET DOLORE MAGNA ALIQUA. """ Assertion Failed: Wrong result: Lorem Ipsum Dolor Sit Amet, Consectetur Adipisicing Elit, Sed Do Eiusmod Tempor Incididunt Ut Labore Et Dolore Magna Aliqua. != LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT, SED DO EIUSMOD TEMPOR INCIDIDUNT UT LABORE ET DOLORE MAGNA ALIQUA. Failing scenarios: features/to_upper.feature:3 Check the function count_words() features/to_upper.feature:14 Check the function count_words() 0 features passed, 1 failed, 0 skipped 0 scenarios passed, 2 failed, 0 skipped 4 steps passed, 2 failed, 0 skipped, 0 undefined Took 0m0.001s
17. Jak je možné zařídit opakování některých operací?
V závěrečné části článku se podívejme na jeden příklad, jehož složitější varianta je používána v praxi. Předpokládejme, že je zapotřebí nějakou službu přímo v testu restartovat (chaos monkey testing) a současně sledovat, jak se přitom chová jiná služba. Sledování lze provádět například opakovaným přístupem na REST API endpoint se zadaným počtem opakování a časem mezi opakováními:
Feature: Smoke test Scenario: Check the REST API service entry point Given REST API service is accessible When I access the API endpoint /get Then I should receive 200 status code Scenario: Repeatedly check the REST API service entry point Given REST API service is accessible When I access the API endpoint /get 5 times with 2 seconds delay Then I should get 200 status code for all calls
Jak se tento testovací krok (resp. dva kroky) může implementovat? Jedno z nejjednodušších řešení může vypadat následovně. Povšimněte si, že URL, počet opakování i prodleva mezi voláním REST API jsou konfigurovatelné:
@when('I access the API endpoint {url} {repeat_count:d} times with {delay:d} seconds delay') def access_url_repeatedly(context, url, repeat_count, delay): context.api_call_results = [] url = context.api_url + url # repeatedly call REST API endpoint and collect HTTP status codes for i in range(repeat_count): response = requests.get(url) context.api_call_results.append(response.status_code) time.sleep(delay)
Tento krok by měl vytvořit seznam s HTTP kódy volání a uložit ho do kontextu.
Následně můžeme získat ty kódy, které neodpovídají očekávané hodnotě a vypsat je na výstup přes příkaz assert. Pokud žádný takový kód není nalezen (všechna volání skončí bez chyby), druhá část příkazu assert se samozřejmě neprovede:
@then('I should get {status:d} status code for all calls') def check_status_code_for_all_calls(context, status): """Check the HTTP status codes returned by the REST API.""" wrong_calls = [code for code in context.api_call_results if code != status] assert not wrong_calls, \ "Wrong code returned {n} times: {codes}".format(n=len(wrong_calls), codes=wrong_calls)
18. Výsledek běhu posledního demonstračního příkladu
Výsledek prvního testovacího scénáře s korektním zápisem endpointu REST API bude vypadat takto (nikde vlastně nevidíme, že se API skutečně volalo vícekrát):
Feature: Smoke test # features/smoketest.feature:1 Scenario: Check the REST API service entry point # features/smoketest.feature:3 Given REST API service is accessible # features/steps/common.py:9 0.827s When I access the API endpoint /get # features/steps/common.py:14 0.622s Then I should receive 200 status code # features/steps/common.py:30 0.000s Scenario: Repeatedly check the REST API service entry point # features/smoketest.feature:8 Given REST API service is accessible # features/steps/common.py:9 0.783s When I access the API endpoint /get 5 times with 0 seconds delay # features/steps/common.py:19 3.114s Then I should get 200 status code for all calls # features/steps/common.py:36 0.000s 1 feature passed, 0 failed, 0 skipped 2 scenarios passed, 0 failed, 0 skipped 6 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m5.346s
Pokud naopak voláme špatný endpoint, bude tato skutečnost správně detekována v obou testech. Povšimněte si, jakým způsobem druhý test vypsal počet špatných HTTP kódů:
Feature: Smoke test # features/smoketest.feature:1 Scenario: Check the REST API service entry point # features/smoketest.feature:3 Given REST API service is accessible # features/steps/common.py:9 0.838s When I access the API endpoint /not-existing # features/steps/common.py:14 0.605s Then I should receive 200 status code # features/steps/common.py:30 0.000s 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 33, in check_status_code assert context.response.status_code == status AssertionError Scenario: Repeatedly check the REST API service entry point # features/smoketest.feature:8 Given REST API service is accessible # features/steps/common.py:9 0.777s When I access the API endpoint /not-existing 5 times with 0 seconds delay # features/steps/common.py:19 3.102s Then I should get 200 status code for all calls # features/steps/common.py:36 0.000s Assertion Failed: Wrong code returned 5 times: [404, 404, 404, 404, 404] Failing scenarios: features/smoketest.feature:3 Check the REST API service entry point features/smoketest.feature:8 Repeatedly check the REST API service entry point 0 features passed, 1 failed, 0 skipped 0 scenarios passed, 2 failed, 0 skipped 4 steps passed, 2 failed, 0 skipped, 0 undefined Took 0m5.322s
19. Repositář s demonstračními příklady
Všech devět 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:
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