Soft Assertions

Collect multiple assertion failures and report them all at once, instead of stopping at the first one.

This is one of the most valuable features of behave-kit: when a scenario checks several conditions, you see every failure, not just the first.

How it works

Soft assertions use contextvars to track an active SoftAssertCollector. Each call to assert_soft() records a failure (if the condition is falsy) without raising. At the end of the scenario, raise_if_failed() raises an AssertionError with a formatted report of every failure.

Activating soft asserts

Automatic (via `setup()`):

from behave_kit import setup

def before_all(context):
    setup(context, env="staging")

Soft asserts are activated automatically and reset per scenario.

Manual:

from behave_kit import use_soft_asserts

def before_scenario(context, scenario):
    use_soft_asserts(context)

Context manager (for unit tests):

from behave_kit import soft_asserts

with soft_asserts() as collector:
    assert_soft(1 == 2, "one should equal two")
    assert_soft(3 == 3, "three should equal three")
# AssertionError raised here with all failures

API reference

behave_kit.assertions.soft.assert_soft(condition: bool, msg: str = '') None[source]

Record a soft assertion failure if condition is falsy.

behave_kit.assertions.soft.assert_soft_equals(actual: object, expected: object, msg: str = '') None[source]

Record a soft assertion failure if actual != expected.

behave_kit.assertions.soft.assert_soft_true(condition: object, msg: str = '') None[source]

Record a soft assertion failure if condition is not truthy.

behave_kit.assertions.soft.assert_soft_is_none(value: object, msg: str = '') None[source]

Record a soft assertion failure if value is not None.

behave_kit.assertions.soft.assert_soft_raises(expected_exception: type[BaseException] | tuple[type[BaseException], ...], func: Callable[[], object], msg: str = '') None[source]

Record a soft assertion failure if func does not raise expected_exception.

behave_kit.assertions.soft.use_soft_asserts(context: Context) SoftAssertCollector[source]

Activate soft assertions for the given Behave context.

Attaches a fresh SoftAssertCollector to context._behave_kit_soft and makes it the active collector for assert_soft and friends.

behave_kit.assertions.soft.soft_asserts() Iterator[SoftAssertCollector][source]

Context manager for soft assertions outside of Behave (e.g. unit tests).

Raises an AssertionError summarizing every failure when the block exits without an exception of its own.

class behave_kit.assertions.soft.SoftAssertCollector[source]

Bases: object

Accumulates assertion failures without raising immediately.

assert_soft(condition: bool, msg: str = '') None[source]

Record a failure if condition is falsy.

Parameters:
  • condition – Boolean (or bool-coercible) value to check.

  • msg – Optional message describing the failure.

assert_soft_equals(actual: object, expected: object, msg: str = '') None[source]

Record a failure if actual is not equal to expected.

Parameters:
  • actual – The value produced by the code under test.

  • expected – The value actual is expected to match.

  • msg – Optional override message for the failure.

assert_soft_is_none(value: object, msg: str = '') None[source]

Record a failure if value is not None.

Parameters:
  • value – The value to check.

  • msg – Optional override message for the failure.

assert_soft_raises(expected_exception: type[BaseException] | tuple[type[BaseException], ...], func: Callable[[], object], msg: str = '') None[source]

Record a failure if func does not raise expected_exception.

Parameters:
  • expected_exception – Exception type (or tuple of types) that func is expected to raise.

  • func – Zero-argument callable to invoke.

  • msg – Optional override message for the failure.

assert_soft_true(condition: object, msg: str = '') None[source]

Record a failure if condition is not truthy.

Parameters:
  • condition – Value evaluated with scalar-bool coercion.

  • msg – Optional override message for the failure.

clear() None[source]

Remove every recorded failure.

property failures: list[SoftFailure]

Copy of the recorded soft assertion failures.

raise_if_failed() None[source]

Raise AssertionError if any failure has been recorded.

report() SoftAssertReport[source]

Return an immutable report of all recorded failures.

class behave_kit.assertions.reporter.SoftAssertReport(failures: list[SoftFailure] = <factory>)[source]

Bases: object

Aggregated report of every soft assertion failure in a scenario.

property failure_count: int

Number of recorded soft assertion failures.

failures: list[SoftFailure]
property has_failures: bool

Whether any soft assertion failure has been recorded.

class behave_kit.assertions.reporter.SoftFailure(message: str, expected: object = <object object>, actual: object = <object object>)[source]

Bases: object

A single soft assertion failure.

actual: object = <object object>
expected: object = <object object>
property has_values: bool

Whether at least one of expected or actual was supplied.

message: str

Examples

Basic usage

from behave_kit import assert_soft

@then("the API response should be valid")
def step(context):
    response = context.response
    assert_soft(response.status_code == 200, "status code should be 200")
    assert_soft("error" not in response.body, "body should not contain 'error'")
    assert_soft(response.body["count"] > 0, "count should be positive")
    # All failures collected, reported together at teardown

With equality checks

from behave_kit import assert_soft_equals

@then("the user profile should match")
def step(context):
    assert_soft_equals(context.user["name"], "Alice", "name mismatch")
    assert_soft_equals(context.user["email"], "alice@example.com", "email mismatch")
    assert_soft_equals(context.user["age"], 30, "age mismatch")

Checking for None

from behave_kit import assert_soft_is_none

@then("no error should be present")
def step(context):
    assert_soft_is_none(context.error, "error should be None")
    assert_soft_is_none(context.warning, "warning should be None")

Checking for expected exceptions

from behave_kit import assert_soft_raises

@then("the parser should reject invalid input")
def step(context):
    assert_soft_raises(ValueError, lambda: int("not a number"))
    assert_soft_raises(KeyError, lambda: context.data["missing"])
    # Failures are collected and reported together at teardown

assert_soft_raises also accepts a tuple of exception types:

assert_soft_raises((ValueError, KeyError), lambda: parse_risky_input())

Inspecting the report

from behave_kit import use_soft_asserts, assert_soft

@given("I have a soft assert collector active")
def step(context):
    collector = use_soft_asserts(context)
    context.collector = collector

@then("the soft assert collector should have {count:d} failures")
def step(context, count):
    assert len(context.collector.failures) == count

Clearing failures

@then("I clear the soft assert failures")
def step(context):
    context._behave_kit_soft.clear()

Diff-based assertions

In addition to soft asserts, behave-kit provides diff-based comparison assertions that show exactly which fields differ instead of a bare AssertionError.

assert_json_equals

Deep-compare two objects and report every difference:

from behave_kit import assert_json_equals, CompareOptions

@then("the response body should match the expected JSON")
def step(context):
    expected = {"name": "Alice", "age": 30, "roles": ["admin", "user"]}
    assert_json_equals(context.response.body, expected)

With options:

from behave_kit import assert_json_equals, CompareOptions

@then("the response should match ignoring timestamps")
def step(context):
    opts = CompareOptions(ignore_keys=frozenset({"created_at", "updated_at"}))
    assert_json_equals(context.response.body, context.expected, options=opts)

Ignoring order in lists:

opts = CompareOptions(ignore_order=True)
assert_json_equals(actual_list, expected_list, options=opts)

assert_dict_contains

Assert that a dict contains every key/value pair from a subset:

from behave_kit import assert_dict_contains

@then("the response should contain the expected fields")
def step(context):
    assert_dict_contains(
        context.response.body,
        {"status": "ok", "count": 5},
    )

assert_list_ordered

Assert that a list is sorted:

from behave_kit import assert_list_ordered

@then("the results should be sorted by name")
def step(context):
    assert_list_ordered(context.results, key=lambda item: item["name"])

assert_table_equals

Compare two Behave data tables:

from behave_kit import assert_table_equals

@then("the data table should match")
def step(context):
    assert_table_equals(context.table, context.expected_table)

Deep comparison engine

behave_kit.assertions._matchers.deep_compare(actual: object, expected: object, options: CompareOptions | None = None) DiffResult[source]

Recursively compare actual to expected and report all differences.

class behave_kit.assertions._matchers.CompareOptions(ignore_keys: frozenset[str] = frozenset({}), float_tolerance: float = 1e-09, ignore_order: bool = False, datetime_tolerance: ~datetime.timedelta | None = None, custom_matchers: dict[type, ~collections.abc.Callable[[~typing.Any, ~typing.Any], bool]] = <factory>)[source]

Bases: object

Tunable behavior for deep_compare.

custom_matchers: dict[type, Callable[[Any, Any], bool]]
datetime_tolerance: timedelta | None = None
float_tolerance: float = 1e-09
ignore_keys: frozenset[str] = frozenset({})
ignore_order: bool = False
class behave_kit.assertions._matchers.DiffResult(equal: bool, diffs: list[Diff] = <factory>)[source]

Bases: object

Outcome of a deep_compare call.

diffs: list[Diff]
equal: bool
class behave_kit.assertions._matchers.Diff(path: str, expected: object, actual: object, message: str)[source]

Bases: object

A single difference found while comparing actual to expected.

actual: object
expected: object
message: str
path: str

CompareOptions supports:

  • ignore_keys — skip specific keys during comparison

  • float_tolerance — tolerance for floating-point comparisons (default: 1e-9)

  • ignore_order — treat lists as unordered sets

  • datetime_tolerance — tolerance for datetime comparisons

  • custom_matchers — per-type custom comparison functions

from behave_kit import CompareOptions, deep_compare
from datetime import timedelta

opts = CompareOptions(
    ignore_keys=frozenset({"id", "timestamp"}),
    float_tolerance=0.01,
    datetime_tolerance=timedelta(seconds=5),
    custom_matchers={
        MyModel: lambda actual, expected: actual.id == expected.id,
    },
)
result = deep_compare(actual_data, expected_data, opts)
if not result.equal:
    for diff in result.diffs:
        print(f"  at {diff.path}: {diff.message}")