Skip to content

pytest-bdd⚓︎

Talika does not replace pytest-bdd. It parses the datatable that pytest-bdd already passes to a step function.

That keeps the integration simple:

  1. the feature file owns the authored Gherkin table
  2. pytest-bdd passes that table to the step as datatable
  3. Talika parses the datatable through your schema
  4. the rest of the test uses parsed records or output objects
A pytest-bdd scenario with a datatable
Feature: User setup

  Scenario: Create users from a table
    Given the users exist:
      | username | age |
      | alice    | 34  |
    Then alice can sign in

Parse at the boundary

Parse the datatable in the step that receives it. After that point, pass parsed users, content records, or domain objects through fixtures and test helpers.

Define the Schema Outside the Step⚓︎

Keep the schema as ordinary Python code. The schema should be importable by pytest, by helper modules, and by tooling that wants to inspect or validate feature tables.

users_schema.py
from talika import RowTable, field, integer


class UserTable(RowTable):
    username = field("username", required=True)
    age = field("age", parser=integer())
    role = field("role", default="viewer")

This schema is not tied to pytest-bdd. It can parse any compatible datatable, including the one received by a BDD step.

The raw datatable shape
datatable = [
    ["username", "age"],
    ["alice", "34"],
]

users = UserTable.parse(datatable)

The same schema can be used in unit tests, fixture setup, static checking, or plain helper functions. pytest-bdd is only one place where the datatable originates.

Do not put schema declarations inside step functions

Step functions should describe test flow. Schema classes describe table contracts. Keeping them separate makes the schema reusable and easier to inspect.

Parse Directly in a Step⚓︎

The simplest integration is to call UserTable.parse(datatable) in the step that receives the table.

Direct schema parsing in a step
from pytest_bdd import given, scenario, then


@scenario("users.feature", "Create users from a table")
def test_create_users():
    pass


@given("the users exist:", target_fixture="users")
def users(datatable):
    return UserTable.parse(datatable)


@then("alice can sign in")
def alice_can_sign_in(users):
    assert users[0].username == "alice"
    assert users[0].age == 34
    assert users[0].role == "viewer"

The target_fixture="users" argument makes the parsed result available to later steps as the users fixture.

Parsed users fixture
>> users[0]
UserTable(username='alice', age=34, role='viewer')

>> users[0].as_dict()
{'username': 'alice', 'age': 34, 'role': 'viewer'}

This style is direct and explicit. It is often the clearest choice when the step uses one schema and there is no need for an additional parser facade.

Keep raw datatables short-lived

A raw datatable is just nested strings. Parse it before handing data to the rest of the test so later code works with meaningful records.

Use the talika Fixture⚓︎

Talika registers a talika pytest fixture through the package's pytest plugin. The fixture is a small facade around schema methods.

Parsing through the talika fixture
@given("the users exist:", target_fixture="users")
def users(datatable, talika):
    return talika.parse(datatable, schema=UserTable)

This does the same parsing work as:

The equivalent direct call
UserTable.parse(datatable)

The fixture does not create another schema registry or another parsing lifecycle. It simply delegates to the schema passed with schema=....

During a pytest-bdd step, the plugin binds the feature's absolute filename and Gherkin cell coordinates to the exact raw datatable object. Calling talika.parse(...), talika.parse_as(...), or talika.validate(...) upgrades that object to source-aware TableData, then clears the binding after the step succeeds or fails. Each fixture instance keeps its own binding, so parallel tests do not share provenance.

Direct schema calls cannot infer a feature filename

UserTable.parse(datatable) still parses correctly, but a raw nested list carries no pytest-bdd metadata. Use the talika fixture when diagnostics should automatically include the feature URI and absolute Gherkin cell coordinates. Scenario-outline runtime locations point to pytest-bdd's rendered template cell.

Use the fixture for dependency-injection style

Some teams prefer step functions where parsing always flows through a fixture argument. Use talika.parse(...) when that style makes your BDD steps more consistent.

Pass Parse Context from Steps⚓︎

Step functions are a natural place to pass scenario-specific project data into Talika.

Passing context through the fixture
def parse_username(value, context):
    return context.user_data["prefix"] + value


class ContextUserTable(RowTable):
    username = field("username", parser=parse_username)


@given("the imported users exist:", target_fixture="users")
def imported_users(datatable, talika):
    return talika.parse(
        datatable,
        schema=ContextUserTable,
        context={"prefix": "QA-"},
    )

The context mapping becomes context.user_data for parsers, default factories, record validators, table validators, and output builders.

Context-aware parsed result
>> users[0]
ContextUserTable(username='QA-alice')

>> users[0].as_dict()
{'username': 'QA-alice'}

Use this for values that belong to the current scenario or test setup: configured domains, allowed roles, generated prefixes, service handles, or fixture-provided data.

Context is copied into read-only user_data

Parser and validation code can read context.user_data, but should not mutate it. Treat it as parse-time configuration.

Shared Parser Context Fixtures⚓︎

Instead of defining parse-time context inside every step function, use ordinary pytest fixtures for shared configuration.

This is a good fit for data that belongs to the test environment rather than to one table: prefixes, allowed roles, fake service handles, validation thresholds, or project lookup data. The step still decides when parsing happens, but the fixture owns how the shared context is assembled.

Sharing parse context via fixtures
import pytest

@pytest.fixture
def parser_context():
    return {"prefix": "DEV-"}


@given("the dev users exist:", target_fixture="users")
def dev_users(datatable, talika, parser_context):
    return talika.parse(
        datatable,
        schema=ContextUserTable,
        context=parser_context,
    )

This pattern keeps step definitions focused on the authored table. It also prevents small configuration dictionaries from being copied across many steps, where they become hard to update consistently.

Fixture context is still parse context

The fixture does not create a separate Talika mechanism. It simply returns a mapping that is passed as context=..., then read through context.user_data by parsers and validators.

Choose parse or parse_as⚓︎

Both schema methods and the talika fixture expose two clear forms.

Use parse(...) when the step should receive Talika schema records.

Use parse_as(...) when the step should receive a dataclass, Pydantic model, or custom output object. Pass a callable explicitly, or omit it to use a configured output_model or build_output().

Schema records through the fixture
@given("the user records exist:", target_fixture="user_records")
def user_records(datatable, talika):
    return talika.parse(datatable, schema=UserTable)

The difference matters when a schema uses output conversion:

Output model conversion
from dataclasses import dataclass


@dataclass(frozen=True)
class User:
    username: str
    age: int


class UserOutputTable(RowTable):
    output_model = User

    username = field("username", required=True)
    age = field("age", parser=integer())


records = UserOutputTable.parse(datatable)
public_users = UserOutputTable.parse_as(datatable)

assert public_users == [User(username="alice", age=34)]
assert isinstance(records[0], UserOutputTable)
parse vs parse_as
>> public_users
[User(username='alice', age=34)]

>> records[0]
UserOutputTable(username='alice', age=34)

Use records when you need source metadata

Output objects are often plain dataclasses or domain models. They may not carry Talika metadata. Use parse(...) when a step needs table_source, source_for(...), or intermediate schema fields.

Validate Without Raising⚓︎

Use the fixture's validate(...) method when a step or helper needs structured diagnostics instead of an exception:

result = talika.validate(datatable, schema=UserTable)
assert result.valid, result.errors

Validation returns schema records only when the complete table is valid and always skips output conversion. It preserves the same automatic pytest-bdd source URI and cell coordinates as the fixture's parsing methods.

Use the Functional API When Preferred⚓︎

Some codebases prefer functions over schema classmethod calls. Talika provides functional helpers for that style.

Functional parsing helpers
from talika import parse_table, parse_table_as


records = parse_table(UserTable, datatable)
values = parse_table_as(UserTable, datatable, dict)

assert records[0].username == "alice"
assert values[0]["username"] == "alice"
assert records[0].as_dict() == {
    "username": "alice",
    "age": 34,
    "role": "viewer",
}

These helpers delegate to the same schema lifecycle:

  • parse_table(UserTable, datatable) is equivalent to UserTable.parse(datatable)
  • parse_table_as(UserTable, datatable, User) is equivalent to UserTable.parse_as(datatable, User)
  • validate_table(UserTable, datatable) is equivalent to UserTable.validate(datatable)

They forward context=... and error_mode=... just like the schema methods.

One lifecycle

Direct schema parsing, the talika fixture, and the functional API all call the same schema parser. Choose the calling style that makes the test easiest to read.

Collect Multiple Table Errors⚓︎

In normal mode, parsing stops at the first error. In error_mode="collect", Talika can report multiple independent table errors from the same parsing phase.

Collect mode from a pytest-bdd step
class StrictUserTable(RowTable):
    username = field("username", required=True)
    age = field("age", parser=integer())


@given("the strict users exist:", target_fixture="users")
def strict_users(datatable, talika):
    return talika.parse(
        datatable,
        schema=StrictUserTable,
        error_mode="collect",
    )

The same behavior is available through direct schema parsing:

A datatable with two cell errors
StrictUserTable.parse(
    [
        ["username", "age"],
        ["", "old"],
    ],
    error_mode="collect",
)
Collected diagnostics
Table contains 2 errors:
  1. Required field has an empty value 
  (code=empty_required, schema=StrictUserTable, field='username', 
  row=2, column=1, value=''). 
  Hint: Fill the cell, or remove required=True if an explicit empty value should be valid.
  2. Field parser failed: invalid literal for int() with base 10: 'old' 
  (code=parser_failed, schema=StrictUserTable, field='age', 
  row=2, column=2, value='old'). 
  Hint: Check the cell value or adjust the field parser for this syntax.

Collect mode is useful in tooling-like tests or CI checks where you want to see several table problems at once. For ordinary test setup, fail-fast parsing is often simpler.

Collect mode still respects lifecycle boundaries

Talika collects compatible errors from the same phase. It does not keep running dependent validation layers after structural parsing has already made the records unreliable.

Keep Step Functions Focused⚓︎

A good pytest-bdd step should usually do one of these things:

  • receive a datatable and parse it into a fixture
  • pass scenario context into parsing
  • assert behavior using parsed records or output objects
  • call application setup using parsed data

Avoid repeating table parsing logic across many steps. Once a schema exists, let it own labels, parsers, defaults, validation, output conversion, and source diagnostics.

Keep feature text and setup code close

The feature file should stay readable to the test author. The step should make the boundary clear: authored table in, dependable test objects out.