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