Migrate from behave

This framework is a modern, type-safe alternative to Behave, designed to bring type hints, better IDE support, and static analysis to Behavior-Driven Development (BDD) in Python 3.

It improves upon Behave by eliminating dynamic attribute issues and enhancing maintainability.

The cherry on top is that you can run the debugger without pain.

Step 1 - Install tursu and configure it.

Installation:

uv add --group dev tursu

Add the Tursu Gherkin compiler to AST generate the tests suite:

  • Ensure the __init__.py file exists in the functionals tests suite.

touch tests/functionals/__init__.py
cat << 'EOF' > tests/functionals/conftest.py
from tursu.plugin import tursu_collect_file

tursu_collect_file()

EOF


## Step 2 - Replace context with fixtures in all decorators

Tursu simplifies BDD by replacing Behave’s dynamic context with pytest fixtures,
making it more maintainable and test-friendly.

Example:

### Behave

```python
from behave import given

@given("A user with username {username} and password {password}")
def step_create_user(context, username, password):
    context.user = {"username": username, "password": password}


@when("they log in")
def step_login(context):
    user = context.user
    context.logged_in = user["username"] == "john" and user["password"] == "secret"

@then("they should see a welcome message")
def step_welcome_message(context):
    assert context.logged_in, "Login failed!"

Tursu

from dataclasses import dataclass

import pytest
from tursu import given


@dataclass
class User:
    username: str
    password: str
    logged_in: bool = False


@pytest.fixture
def user() -> User:
    return User(username="", password="")


@given("a user with username {username} and password {password}")
def step_create_user(username: str, password: str, user: User):
    user.username = username
    user.password = password


@when("they log in")
def step_login(user: User):
    user.logged_in = user.username == "john" and user.password == "secret"


@then("they should see a welcome message")
def step_welcome_message(user: User):
    assert user.logged_in, "Login failed!"

Note

  • context.text has to be replaced by doc_string

  • context.table has to be replaced by data_table

Behave

@given('a set of specific users')
def step_impl(context):
    for row in context.table:
        context.model.add_user(name=row['name'], department=row['department'])

@then('I will see the account details')
def step_impl(context):
    assert context.text == ...

Tursu

@given('a set of specific users')
def step_impl(model: MyModelFixture, data_dable: list[dict[str, str]]):
    for row in data_dable:
        model.add_user(name=row['name'], department=row['department'])

@then('I will see the account details')
def step_impl(doc_string: text):
    assert doc_string == ...

Step 3 - Replace Behave Fixtures with Pytest autouse Fixtures

Behave allows using fixtures through before_scenario and after_scenario. In Tursu, we achieve the same behavior using pytest’s autouse fixtures.

Example:

Behave

from behave import fixture, use_fixture

from my_dummy_app.entrypoint import main

from .utils import wait_for_url


@fixture
def start_webapp(context, **kwargs):
    proc = Process(target=main, daemon=True)
    proc.start()
    wait_for_url("http://localhost:8888")
    yield
    proc.kill()

def before_scenario(context, scenario):
    use_fixture(start_webapp, context)

Tursu

import pytest

from my_dummy_app.entrypoint import main

from .utils import wait_for_url

@pytest.fixture(autouse=True, scope="function")  # adapt the scope for your needs
def start_webapp():
    proc = Process(target=main, daemon=True)
    proc.start()
    wait_for_url("http://localhost:8888")
    yield
    proc.kill()

Step 4 - Remove code that launch a browser, use pytest-playwright

Example:

Behave

@fixture
def browser(context: Any, **kwargs: Any) -> Iterator[None]:
    from playwright.sync_api import sync_playwright

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True, slow_mo=50)
        context.browser = browser.new_page(base_url="http://localhost:8888")
        yield
        browser.close()

Tursu


# you can configure the browser context in a fixture
# https://playwright.dev/python/docs/test-runners#use-custom-viewport-size

@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {
        **browser_context_args,
        "viewport": {
            "width": 1920,
            "height": 1080,
        }
    }

Step 5 - Declare your gherkin tag into your pytest marker.

This is highly recommended, to avoid warnings from pytest.

# pyproject.toml
[tool.pytest.ini_options]
markers = [
    "dev: experiment with some background context.",
    "openapi: open api tests.",
    "wip: work in progress.",
]

Step 6 - Run the test, enable tracing!

It’s hard to run a debugger in behave, remote debugging is mandatory, in pytest, you can use start a debugger in your test by a

uv run pytest \
  -m "wip" \
  --base-url http://localhost:8888 \
  --headed \
  --browser chromium \
  --trace \
  -sxv \
  tests/functionals/

-m “wip” : only run the @wip Gherkin tag

–base-url http://localhost:8888 : configure a base url, may be in the code

–headed : start a real browser to see what happen

–browser chromium : choose the browser you want

–trace : start the test by a breakpoint

-sv : donc capture stdout, and choose your vebosity level

$ uv run pytest \
  -m "wip" \
  --base-url http://localhost:8888 \
  --headed \
  --browser chromium \
  --trace \
  -sxv \
  tests/using_playwright
================================= test session starts =================================
baseurl: http://localhost:8888
configfile: pyproject.toml
plugins: cov-6.0.0, playwright-0.7.0, base-url-2.1.0
collected 1 item

tests/using_playwright/test_1_Basic_Test.py::test_2_Hello_world[chromium]
>>>>>>>>>>>>>> PDB runcall (IO-capturing turned off for fixture capsys) >>>>>>>>>>>>>>>
> ~/git/tursu/tests/using_playwright/test_1_Basic_Test.py(10)test_2_Hello_world()
-> with TursuRunner(request, capsys, tursu, ['📄 Document: 01_basic.feature', '🥒 Feature: Basic Test', '🎬 Scenario: Hello world']) as tursu_runner:

📄 Document: 01_basic.feature
🥒 Feature: Basic Test
🎬 Scenario: Hello world
> ~/git/tursu/tests/using_playwright/test_1_Basic_Test.py(11)test_2_Hello_world()
-> tursu_runner.run_step('given', 'anonymous user on /', page=page)
(Pdb) c

>>>>>>>>>>>>>>> PDB continue (IO-capturing resumed for fixture capsys) >>>>>>>>>>>>>>>>
✅ Given anonymous user on /
✅ Then I see the text "Hello, World!"
127.0.0.1 - - [14/Mar/2025 21:51:08] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [14/Mar/2025 21:51:08] "GET /favicon.ico HTTP/1.1" 200 -
                                                                           PASSED

================================== 1 passed in 6.52s ==================================