Hlavní navigace

Behavior-driven development v Pythonu s využitím knihovny Behave (závěrečná část)

17. 4. 2018
Doba čtení: 34 minut

Sdílet

V dnešním článku o knihovně Behave integrující jazyk Gherkin s Pythonem si ukážeme další možnosti, které se nám při psaní testovacích scénářů nabízí. Jedná se například o podporu deklarace víceřádkových textů, trik pro opakování operací atd.

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

20. Odkazy na Internetu

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
Poznámka: statusem je zde myšlen jeden z těchto kódů.

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

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

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
Poznámka: klauzule And je možné nahradit za Then se stejným významem. Dokonce je možné použít i Bud, taktéž s naprosto totožným významem (!). Případnou negaci podmínky u But si musí programátor zajistit sám.

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_tes­t_version3/features/steps/com­mon.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
Poznámka: nejedná se o příliš elegantní řešení tohoto problému. Schválně jsem vynechal ještě stručnější zápis – o jaký jde?

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, ThenAnd 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):

CS24_early

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:

Projekt Popis Cesta
github_test_version1 test dostupnosti REST API https://github.com/tisnik/python-behave-demos/tree/github_test_version1
github_test_version1_wrong_url test NEdostupnosti REST API https://github.com/tisnik/python-behave-demos/tree/github_test_ver­sion1_wrong_url
github_test_version1_wrong_code test špatného HTTP kódu https://github.com/tisnik/python-behave-demos/tree/github_test_ver­sion1_wrong_code
github_test_version2 test dostupnosti REST API https://github.com/tisnik/python-behave-demos/tree/github_test_version2
github_test_version3 test dostupnosti REST API https://github.com/tisnik/python-behave-demos/tree/github_test_version3
github_test_version4 test dostupnosti REST API https://github.com/tisnik/python-behave-demos/tree/github_test_version4
   
multiline_text1 víceřádkový text v sekci Given https://github.com/tisnik/python-behave-demos/tree/multiline_text1
multiline_text2 víceřádkový text v sekci Then https://github.com/tisnik/python-behave-demos/tree/multiline_text2
multiline_text3 použití string.title namísto string.upper https://github.com/tisnik/python-behave-demos/tree/multiline_text3
   
check_rest_api_repeatedly opakované volání REST API https://github.com/tisnik/python-behave-demos/tree/check_rest_api_repeatedly
check_rest_api_repeatedly_wrong_url opakované volání špatného REST API https://github.com/tisnik/python-behave-demos/tree/check_rest_api_re­peatedly_wrong_url

20. Odkazy na Internetu

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

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.