Working with pytest fixtures

Using Turşu is using pytest, with tests structured in the Gherkin style.

Any step definition can utilize a pytest fixture, and a fixture scoped to function remains the same instance for a scenario.

And, a module scope fixture, persists across an entire Gherkin feature.

Every step definition works in the same way at the runtime, they just serve different purpose.

In gherkin, a @given is made to provide context for a test, which could be a fixtures in pytest, but this is not the case and it will never be for the reason of simplicity and tracability of the tests.

But, another approach is possible, a context can be created by having a fixture parameter which act as a factory and the @given method will consume the fixture to setup the context.

So imagine we have this dummy application, below that don’t use any database but it could, and we just have a user database and a login method.

The application

class DummyApp:
    """Represent a tested application."""

    def __init__(self):
        self.users = {}
        self.connected_user: str | None = None

    def login(self, username: str, password: str) -> None:
        if username in self.users and self.users[username] == password:
            self.connected_user = username

A testing scenario with hardcoded value.

So, we can create a simple gherkin scenario for the login:

Feature: User signs in with the right password

  Scenario: User Bob can login
    Given a user Bob signs in with password dumbsecret
    When Bob signs in with password dumbsecret
    Then the user is connected with username Bob

Step definitions for hardcoded values

import pytest
from tursu import given, then, when


class DummyApp:
    """Represent a tested application."""

    def __init__(self):
        self.users = {}
        self.connected_user: str | None = None

    def login(self, username: str, password: str) -> None:
        if username in self.users and self.users[username] == password:
            self.connected_user = username


@pytest.fixture()
def app() -> DummyApp:
    return DummyApp()


@given("a user {username} signs in with password {password}")
def setup_user(app: DummyApp, username: str, password: str):
    app.users[username] = password


@when("{username} signs in with password {password}")
def login(app: DummyApp, username: str, password: str):
    app.login(username, password)


@then("the user is connected with username {username}")
def assert_connected(app: DummyApp, username: str):
    assert app.connected_user == username

All step definition works in the same way but they serve different purpose,

the Given will setup the application, the When will perform the tested action,

and the Then will ensure that the action behave properly.

And, everything happen in the fixture app that represent the context of the application.

Note

In normal situation, the DummyApp does not live in a step definition module, it is imported from the codebase of the project that is going to be tested.

A testing scenario with faked values

Now lets move on and add a scenario where the username is not predictable.

Here is my scenario:

Feature: User signs in with the right password

  Scenario: Random user can login
    Given a user
    When user login
    Then the user is connected

And I am going to reuse the same step definition for this scenario.

import pytest
from faker import Faker

from tursu import given, then, when

faker = Faker()


class DummyApp:
    """Represent a tested application."""

    def __init__(self):
        self.users = {}
        self.connected_user: str | None = None

    def login(self, username: str, password: str) -> None:
        if username in self.users and self.users[username] == password:
            self.connected_user = username


@pytest.fixture()
def app() -> DummyApp:
    return DummyApp()


@pytest.fixture()
def username():
    return faker.user_name


@pytest.fixture()
def password():
    return faker.random_letters(24)


@given("a user")
@given("a user {username} signs in with password {password}")
def setup_user(app: DummyApp, username: str, password: str):
    app.users[username] = password


@when("user login")
@when("{username} signs in with password {password}")
def login(app: DummyApp, username: str, password: str):
    app.login(username, password)


@then("the user is connected")
@then("the user is connected with username {username}")
def assert_connected(app: DummyApp, username: str):
    assert app.connected_user == username


@then("the user is not connected")
def assert_not_connected(app: DummyApp):
    assert app.connected_user is None

As you can see, the gherkin pattern matcher will not have username and password parametrized, but the function still have them, they have to be provision by a fixture.

Ant that’s all. Turşu will use the fixture to fill the username and the password. Ant it will be the same set of data for the tests. If another test is added, it will be new values.

Note that the data extracted by the pattern matcher from the Gherkin step will always take precedence over the pytest fixture.

Important

The pytest.fixture() can be created in a conftest.py file or in a step definition module.

At the moment, there is a limitation with fixtures created in step definition files, they cannot have duplicate names.

You can’t have two fixtures names username in two step definition files, they will overlap in the Turşu fixture registry.

You may use two gherkin step that match two step definitions in two distinct modules, and at the end, pytest test function can only have one fixture for both if they have the same name.

Everything about the pytest fixtures usage has been written here.

conclusion

  • pytest function scope is gherkin scenario scope.

  • Every step definition can received pytest fixtures, in the same way.

  • In case of conflict, matched value for the gherkin scenario will always take precedence.