pytest-bdd

Pytest-bdd implements a subset of the [gherkin] language to enable automating project requirements testing and to facilitate behavioral driven development

Пример проекта

pip install pytest pip install pytest-bdd

Структура проекта

[project root directory]
|‐‐ [product code packages]
|-- [test directories]
|   |-- features
|   |   `-- *.feature
|   `-- step_defs
|       |-- __init__.py
|       |-- conftest.py
|       `-- test_*.py
`-- [pytest.ini|tox.ini|setup.cfg]
@web @duckduckgo
Feature: DuckDuckGo Web Browsing
  As a web surfer,
  I want to find information online,
  So I can learn new things and get tasks done.

  # The "@" annotations are tags
  # One feature can have multiple scenarios
  # The lines immediately after the feature title are just comments

  Scenario: Basic DuckDuckGo Search
    Given the DuckDuckGo home page is displayed
    When the user searches for "panda"
    Then results are shown for "panda"

Обратите внимание, что в файле feature допускается только одна feature.

import pytest

from pytest_bdd import scenarios, given, when, then, parsers
from selenium import webdriver
from selenium.webdriver.common.keys import Keys

# Constants

DUCKDUCKGO_HOME = 'https://duckduckgo.com/'

# Scenarios

scenarios('../features/web.feature')

# Fixtures

@pytest.fixture
def browser():
    b = webdriver.Firefox()
    b.implicitly_wait(10)
    yield b
    b.quit()

# Given Steps

@given('the DuckDuckGo home page is displayed')
def ddg_home(browser):
    browser.get(DUCKDUCKGO_HOME)

# When Steps

@when(parsers.parse('the user searches for "{phrase}"'))
def search_phrase(browser, phrase):
    search_input = browser.find_element_by_id('search_form_input_homepage')
    search_input.send_keys(phrase + Keys.RETURN)

# Then Steps

@then(parsers.parse('results are shown for "{phrase}"'))
def search_results(browser, phrase):
    # Check search result list
    # (A more comprehensive test would check results for matching phrases)
    # (Check the list before the search phrase for correct implicit waiting)
    links_div = browser.find_element_by_id('links')
    assert len(links_div.find_elements_by_xpath('//div')) > 0
    # Check search phrase
    search_input = browser.find_element_by_id('search_form_input')
    assert search_input.get_attribute('value') == phrase

launch: pytest tests/step_defs

Более подробный пример

Функции, декорированные как scenario, ведут себя как обычные тестовые функции и будут выполняться после всех шагов сценария. Рекомендуется по возможности помещать всю логику внутрь when, then и and.

Иногда приходится объявлять одни и те же установки или шаги разными именами для лучшей читабельности. Чтобы использовать одну и ту же пошаговую функцию с несколькими именами шагов, ее можно декорировать несколько раз. Указанные псевдонимы шагов являются независимыми и будут выполняться каждый раз при упоминании.

@given("I have an article")
@given("there's an article")
def article(author, target_fixture="article"):
    return create_test_article(author=author)

Сами шаги можно использовать с разными параметрами повторно. При этом доступно несколько типов парсеров параметра шага:

  • string (the default)
  • parse (based on: pypi_parse) - предоставляет простой синтаксический анализатор, который заменяет регулярные выражения для параметров шага удобочитаемым синтаксисом, например {param:Type}. Синтаксис вдохновлен встроенной в Python функцией string.format(). Параметры шага должны использовать синтаксис именованных полей pypi_parse в определениях шага. Именованные поля извлекаются, дополнительно преобразуются типы, а затем используются в качестве аргументов функции шага. Поддерживает преобразования типов с помощью преобразователей типов, передаваемых через extra_types.
  • cfparse (extends: pypi_parse, based on: pypi_parse_type) - предоставляет расширенный синтаксический анализатор с поддержкой «поля кардинальности» (CF)
  • re
# cparse example
from pytest_bdd import parsers

@given(
    parsers.cfparse("there are {start:Number} cucumbers", extra_types={"Number": int}),
    target_fixture="cucumbers",
)
def given_cucumbers(start):
    return {"start": start, "eat": 0}

# for re
@given(
    parsers.re(r"there are (?P<start>\d+) cucumbers"),
    converters={"start": int},
    target_fixture="cucumbers",
)
def given_cucumbers(start):
    return {"start": start, "eat": 0}

Кроме того,можно имплементировать собственные парсеры.

Иногда нужен такой заданный шаг, который обязательно менял бы фикстуру только для определенного теста (сценария), а для остальных тестов он оставался бы нетронутым. Для этого в given декораторе существует специальный параметр target_fixture. В этом примере существующая фикстура foo будет переопределена шагом только для сценария, в котором она используется.

Feature: Target fixture
    Scenario: Test given fixture injection
        Given I have injecting given
        Then foo should be "injected foo"
from pytest_bdd import given

@pytest.fixture
def foo():
    return "foo"


@given("I have injecting given", target_fixture="foo")
def injecting_given():
    return "injected foo"


@then('foo should be "injected foo"')
def foo_is_foo(foo):
    assert foo == 'injected foo'

Как и Gherkin, pytest-bdd поддерживает многострочные шаги (также известные как строки документа). Но гораздо более чистым и мощным способом:

Feature: Multiline steps
    Scenario: Multiline step using sub indentation
        Given I have a step with:
            Some
            Extra
            Lines
        Then the text should be parsed with correct indentation

Шаг считается многострочным, если следующая(ые) строка(и) после первой строки имеет отступ относительно первой строки. Затем имя шага просто расширяется путем добавления дополнительных строк с символами новой строки. В приведенном выше примере имя заданного шага будет таким

'I have a step with:\nSome\nExtra\nLines'. Использоать это можно так:

from pytest_bdd import given, then, scenario, parsers


scenarios("multiline.feature")


@given(parsers.parse("I have a step with:\n{content}"), target_fixture="text")
def given_text(content):
    return content


@then("the text should be parsed with correct indentation")
def text_should_be_correct(text):
    assert text == "Some\nExtra\nLines"

Если есть относительно большой набор файлов функций, вручную привязывать сценарии к тестам с помощью декоратора сценариев сложно. Конечно, при ручном подходе вы получаете все возможности, чтобы иметь возможность дополнительно параметризовать тест, дать тестовой функции красивое имя, задокументировать ее и т. д., но в большинстве случаев вам это не нужно. Вместо этого вы хотите рекурсивно автоматически связать все сценарии, найденные в папках функций, с помощью помощника сценариев. Вы можете передать несколько путей, и эти пути могут быть либо файлами функций, либо папками функций.

from pytest_bdd import scenarios

# pass multiple paths/files
scenarios('features', 'other_features/some.feature', 'some_other_features')

Кроме того, сценарий можно привязать и декоратором

from pytest_bdd import scenario, scenarios

@scenario('features/some.feature', 'Test something')
def test_something():
    pass

# assume 'features' subfolder is in this file's directory
scenarios('features')

Сценарии могут быть параметризованы для охвата нескольких случаев. В Gherkin они называются Scenario Outlines, а шаблоны переменных записываются с использованием угловых скобок (например, <var_name>).

# content of scenario_outlines.feature

Feature: Scenario outlines
    Scenario Outline: Outlined given, when, then
        Given there are <start> cucumbers
        When I eat <eat> cucumbers
        Then I should have <left> cucumbers

        Examples:
        | start | eat | left |
        |  12   |  5  |  7   |
from pytest_bdd import scenarios, given, when, then, parsers


scenarios("scenario_outlines.feature")


@given(parsers.parse("there are {start:d} cucumbers"), target_fixture="cucumbers")
def given_cucumbers(start):
    return {"start": start, "eat": 0}


@when(parsers.parse("I eat {eat:d} cucumbers"))
def eat_cucumbers(cucumbers, eat):
    cucumbers["eat"] += eat


@then(parsers.parse("I should have {left:d} cucumbers"))
def should_have_left_cucumbers(cucumbers, left):
    assert cucumbers["start"] - cucumbers["eat"] == left

Организовать структуру тестов можно так:

features
│
├──frontend
│  │
│  └──auth
│     │
│     └──login.feature
└──backend
   │
   └──auth
      │
      └──login.feature

tests
│
└──functional
   │
   └──test_auth.py
      │
      └ """Authentication tests."""
        from pytest_bdd import scenario

        @scenario('frontend/auth/login.feature')
        def test_logging_in_frontend():
            pass

        @scenario('backend/auth/login.feature')
        def test_logging_in_backend():
            pass

Cucumber использует теги как способ классификации ваших функций и сценариев, которые поддерживает pytest-bdd.

@login @backend
Feature: Login

  @successful
  Scenario: Successful login

Теперь это можно запустить так: pytest -m "backend and login and successful"

Маркеры функций и сценариев не отличаются от стандартных маркеров pytest, а символ @ автоматически удаляется, чтобы разрешить выражения селектора тестов. Если вы хотите, чтобы теги, связанные с bdd, отличались от других тестовых маркеров, используйте префикс, например bdd. Обратите внимание: если вы используете pytest с параметром --strict, все теги bdd, упомянутые в файлах функций, также должны быть в настройке маркеров конфигурации pytest.ini. Также для тегов используйте имена переменных, совместимые с python, т. е. начинайте не с числа, используйте только символы подчеркивания или буквенно-цифровые символы и т. д. Таким образом, вы можете безопасно использовать теги для фильтрации тестов. Вы можете настроить преобразование тегов в метки pytest, внедрив хук pytest_bdd_apply_tag и вернув из него True:

def pytest_bdd_apply_tag(tag, function):
    if tag == 'todo':
        marker = pytest.mark.skip(reason="Not implemented yet")
        marker(function)
        return True
    else:
        # Fall back to the default behavior of pytest-bdd
        return None

Первоначальные установки теста осуществляется с помощью Given. Несмотря на то, что эти шаги выполняются в обязательном порядке для применения возможных побочных эффектов, pytest-bdd пытается извлечь выгоду из фикстур PyTest, которые основаны на внедрении зависимостей и делают настройку более декларативной.

Feature: The power of PyTest
    Scenario: Symbolic name across steps
        Given I have a beautiful article
        When I publish this article
        And my article is published
@given("I have a beautiful article", target_fixture="article")
def article():
    return Article(is_beautiful=True)

@when("I publish this article")
def publish_article(article):
    article.publish()

@given("my article is published")
def published_article(article):
    article.publish()
    return article

pytest-bdd не зависит от глобального контекста и все установки теста можно определять в given.

Часто бывает так, что для охвата определенной функции вам потребуется несколько сценариев. И логично, что установка для этих сценариев будет иметь некоторые общие части. pytest-bdd реализует бекграунды для функций.

Feature: Multiple site support

  Background:
    Given a global administrator named "Greg"
    And a blog named "Greg's anti-tax rants"
    And a customer named "Wilson"
    And a blog named "Expensive Therapy" owned by "Wilson"

  Scenario: Wilson posts to his own blog
    Given I am logged in as Wilson
    When I try to post to "Expensive Therapy"
    Then I should see "Your article was published."

  Scenario: Greg posts to a client's blog
    Given I am logged in as Greg
    When I try to post to "Expensive Therapy"
    Then I should see "Your article was published."

В разделе Background следует использовать только Given шаги. Шаги When и Then запрещены, потому что их цели связаны с действиями и потреблением результатов; что противоречит цели Background — подготовить систему к испытаниям или «привести систему в известное состояние», как это делает Given. Утверждение выше относится к строгому режиму Gherkin, который включен по умолчанию.

Steps и Given можно использовать повторно. Лучшее решение - определить реюзабельные фикстуры в conftest

# content of common_steps.feature

Scenario: All steps are declared in the conftest
    Given I have a bar
    Then bar should have value "bar"
# content of conftest.py
from pytest_bdd import given, then


@given("I have a bar", target_fixture="bar")
def bar():
    return "bar"


@then('bar should have value "bar"')
def bar_is_bar(bar):
    assert bar == "bar"


# content of test_common.py
@scenario("common_steps.feature", "All steps are declared in the conftest")
def test_conftest():
    pass

Иногда у вас есть определения шагов, которые было бы гораздо проще автоматизировать, чем писать их вручную снова и снова. Это распространено, например, при использовании таких библиотек, как pytest-factoryboy, которые автоматически создают фикстуры. Написание определений шагов для каждой модели может стать утомительной задачей. По этой причине pytest-bdd позволяет автоматически генерировать определения шагов. Хитрость заключается в том, чтобы передать параметр stacklevel в Given, When и т.д.. Это даст им указание внедрить фикстуры шага в соответствующий модуль, а не просто вставить их в кадр вызывающего объекта. Пример тут

Смотри еще: