Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Behat Api Extension Laravel Package

imbo/behat-api-extension

A Behat 3 extension that provides an easy way to test JSON-based APIs with readable Gherkin steps. Inspired by behat/web-api-extension and originally written to test the Imbo API, it simplifies HTTP request and response assertions in your test suite.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Check the PHP floor before anything else. v6.x requires PHP 8.3+ (v5.0 needed 8.1, and v5 also changed internal interfaces). If your Laravel images are on 8.1/8.2, pin an older major or upgrade first — this is a hard gate, not a warning.

2. Install Behat and the extension. The extension is not a Laravel package and ships no Laravel integration; it is a Behat 3 extension that registers Gherkin steps plus a Guzzle-backed client.

composer require --dev behat/behat imbo/behat-api-extension
vendor/bin/behat --init          # creates features/bootstrap/FeatureContext.php

3. Wire it up in behat.yml. The two things you must get right are the extension config key (apiClient → base_uri) and registering ApiContext in the suite:

default:
    suites:
        api:
            paths: [ '%paths.base%/features/api' ]
            contexts:
                - Imbo\BehatApiExtension\Context\ApiContext
                - FeatureContext
    extensions:
        Imbo\BehatApiExtension:
            apiClient:
                base_uri: 'http://127.0.0.1:8080'

4. Look first at the step definitions, not the README. The README is deliberately thin and points at Read the Docs. The authoritative vocabulary lives in your vendor dir — dump it before writing a single scenario:

vendor/bin/behat --definitions=l      # full step list
vendor/bin/behat --definitions=l | grep -i json

5. Boot a real app instance. This extension is black-box: it makes HTTP calls over a socket. Nothing works in-process.

php artisan serve --port=8080        # terminal 1
vendor/bin/behat --format=pretty     # terminal 2

Point base_uri at a dedicated testing environment with its own database. Do not aim it at your dev database.

6. First use case: one GET, one assertion. Resist starting with the hardest endpoint.

Feature: Health and version

  @smoke
  Scenario: The API reports itself healthy
    Given I am GETting to /api/health
    When I send the request
    Then the response status code is 200
    And the response body contains JSON:
    """
    {"status": "ok"}
    """

Confirm it fails when you break the app. That's your pilot gate — one endpoint, end to end, before you commit the suite.

Implementation Patterns

Keep the harness thin and your own steps thinner

Extend ApiContext rather than inventing a parallel context, so you inherit the request/response plumbing and the step vocabulary:

<?php declare(strict_types=1);

use Imbo\BehatApiExtension\Context\ApiContext;

final class FeatureContext extends ApiContext
{
    // add only steps that are genuinely domain-specific
}

If you extend it, do not also list Imbo\BehatApiExtension\Context\ApiContext in behat.yml — you'll get duplicate step definitions.

Treat scenarios as the contract, and assert a subset

The package distinguishes between whole-body equality and "contains". Default to contains so adding a field to a response doesn't fail thirty scenarios:

Then the response body contains JSON:
"""
{"data": {"id": 1, "type": "order"}}
"""

Then assert individual invariants with the JSON node steps — existence, type, and value separately:

And the JSON node "data.id" exists
And the JSON node "data.created_at" is a string
And the JSON node "meta.total" is an integer

This is the single highest-leverage habit: timestamps, IDs, and generated tokens are never asserted literally, only by type/existence.

Authenticate in a hook, not in every scenario

Token flows belong in a @BeforeScenario hook that logs in through the API, then sets the header for subsequent steps. Use the extension's header steps so the credential path stays declarative:

Given the "Authorization" request header is "Bearer <token>"

For per-role suites, use separate tags (@admin, @guest) and resolve the token per tag in the hook. Keep the token acquisition in your FeatureContext — the extension has no opinion on Sanctum/Passport.

Compose request bodies with "contains" rather than rewriting them

Build a baseline payload in a Given step (or in Background), then layer overrides per scenario. Repeating a 40-line JSON docstring in every scenario is the fastest route to a rotten suite.

Seed data through the API where possible

There is no RefreshDatabase here — the HTTP boundary means no transaction rollback. Two workable patterns:

  • API-as-fixture: create prerequisites with real POST calls in Given steps. Slower, but exercises your API and needs no DB coupling.
  • Bootstrapped seeding: run migrate:fresh --seed or truncate-and-seed in a @BeforeSuite/@BeforeScenario hook using Eloquent from the Behat bootstrap. Faster, but Behat's process must be able to reach the DB, and you now own cleanup ordering.

Pick one and be consistent. Mixed approaches produce order-dependent flakes.

Use Scenario Outlines for response-shape matrices

Validation and error-code tables are exactly what Examples tables are for:

Scenario Outline: Rejects invalid payloads
  Given I am POSTing to /api/orders
  And the request body is:
  """
  {"quantity": <quantity>}
  """
  When I send the request
  Then the response status code is 422
  And the JSON node "errors.quantity" exists

  Examples:
    | quantity |
    | 0        |
    | -1       |
    | null     |

Split the test tiers explicitly

Agree the boundary up front, in writing:

  • Pest/PHPUnit — unit and in-process feature tests. Http::fake(), assertJsonStructure(), factories, RefreshDatabase. Fast, runs on save.
  • Behat + this extension — a small tagged set of critical-path, stakeholder-readable scenarios run against a deployed environment. @smoke on every deploy, @regression nightly.

If the same endpoint is covered in both, that's fine once; twice is rot. The predictable failure mode for this package is a second suite that drifts and then gets deleted.

CI shape

php artisan migrate:fresh --seed
php artisan serve --port=8080 &
until curl -sf http://127.0.0.1:8080/api/health; do sleep 1; done
vendor/bin/behat --tags=@smoke --format=pretty --format=junit --out=build/behat

Behat has no native parallelism — partition by tag across CI jobs rather than reaching for a plugin.

Use the comparator for fuzzy matching

ArrayContainsComparator supports registering custom comparison functions. If your API returns volatile values (relative URLs, hashed IDs, signed payloads), a custom comparator function is a cleaner extension point than one-off steps in your context. Keep those functions small and generic so they survive an upgrade or a fork.

Gotchas and Tips

Empty arrays vs. empty objects. PHP's json_encode([]) produces [], not {}. A Laravel resource returning [] for an empty relation will fail a {"data": {}} expectation. Either fix the app ((object) [], JSON_FORCE_OBJECT) or assert is an array. This burns an afternoon the first time.

204 No Content breaks JSON steps. An empty body is not valid JSON. Assert the response body is empty and the status code — don't reach for JSON steps.

Type coercion in comparisons. 1 and "1" are not interchangeable, and strict comparisons are the norm here. The 1.0.x line already patched null-value comparison and scalar-only restrictions, so pin a modern version rather than reading old bug reports as current behaviour.

Multipart plus a request body. You cannot send a JSON body alongside multipart/form-data; this was explicitly fixed in v1.0.2. Use the form-data steps for the fields and a separate step to attach the file, and don't mix in a body docstring.

Redirects are followed by default. If you're asserting a 302 from an auth endpoint, you'll see the landing page instead. Disable redirects via the Guzzle handler options under apiClient in behat.yml for that profile.

Non-2xx responses do not throw. You get the response back and assert on it — which is what you want for 422 validation tests, but it also means a 500 looks like a normal response until your status assertion catches it. Always assert the status code explicitly; a scenario that only checks JSON content will happily pass on an error page that happens to echo the right key.

Trailing slashes in base_uri. Guzzle resolves relative URIs against base_uri; a missing or doubled slash silently produces /api/api/... or a root-relative path. Hardcode the full path in the step and keep base_uri as scheme + host + port only.

Debug with the built-in print steps and -vvv. Dump the last request/response with the extension's print steps rather than a var_dump in a hook. vendor/bin/behat -vvv on a single failing scenario is the fastest loop:

vendor/bin/behat features/api/orders.feature:14 -vvv --format=pretty

Data pollution is the #1 flake source. Two scenarios that both POST /api/users with [email protected] will collide on the unique index — and the second failure message will point at the assertion, not the setup. Use per-scenario unique values, or truncate deterministically in a @BeforeScenario hook.

Watch the version pins and the interfaces. v5 → v6 changed internal interfaces and bumped PHP to 8.3; v6.0.1 and v6.1.x are maintenance releases. Pin the major in composer.json, and keep custom contexts confined to the public step vocabulary plus FeatureContext methods — that's what makes the eventual fork cheap. With ~110 stars, effectively one upstream org, and zero recorded dependents, assume you may own this code one day.

Don't ask it to be a contract validator. It asserts values and structure, not OpenAPI conformance. If schema-level contract testing is the requirement, add Schemathesis or a Spectral lint in CI and treat this as the behavioural/regression layer beneath it.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator
manelgavalda/enrollment_mobile
unabomber/timezone
delatbabel/contacts
astritzeqiri/g-recaptcha
kevinongko/laravel-genode
developeruz/laravel-analytics