Browser Testing

PhpSpec includes a lightweight HTTP client for testing web endpoints. Use visit(), get(), post(), put(), patch(), or delete() to make requests and assert on the response.

Configuration

Add base_url to your phpspec.json:

{
    "base_url": "http://localhost:8080"
}

Quick Example

<?php

describe('User API', function () {
    it('returns a user by id', function () {
        $response = get('/api/users/1');

        expect($response)->toBeOk();
        expect($response)->toHaveStatus(200);
        expect($response->json['name'])->toBe('Chuck Norris');
        expect($response)->toHavePath('data.user.name', 'Chuck Norris');
    });

    it('creates a user', function () {
        $response = post('/api/users', json: ['name' => 'Chuck']);

        expect($response)->toHaveStatus(201);
        expect($response)->toHavePath('name', 'Chuck');
    });

    it('updates a user', function () {
        $response = put('/api/users/1', json: ['name' => 'Updated']);

        expect($response)->toBeOk();
    });

    it('patches a user', function () {
        $response = patch('/api/users/1', json: ['name' => 'Patched']);

        expect($response)->toBeOk();
    });

    it('deletes a user', function () {
        $response = delete('/api/users/1');

        expect($response)->toHaveStatus(204);
    });
});

Making Requests

FunctionDescription
visit(string $path)GET request (alias for get())
get(string $path)GET request
post(string $path, array $options = [])POST request
put(string $path, array $options = [])PUT request
patch(string $path, array $options = [])PATCH request
delete(string $path, array $options = [])DELETE request

A redirect is not followed: the response is the one the server sent, its 3xx status and Location header included, so toRedirectTo() can check it. Visit the Location to check the page it leads to.

Request Options

The $options array supports:

KeyTypeDescription
jsonarrayJSON-encoded as the request body; sets Content-Type: application/json
bodystringRaw request body
headersarrayAdditional HTTP headers (name => value)
callbackcallableTold each exchange: ($method, $url, $status, $body, $headers). Overrides the default one, which attaches the request and response to the report

Response Object

All functions return a Response with the following properties:

PropertyTypeDescription
$response->statusintHTTP status code
$response->bodystringRaw response body
$response->jsonarrayBody decoded as JSON (lazy, json_decode(..., true))
$response->headersarrayResponse headers (name => value), named as the server sent them; toHaveHeader() finds a name in any case

Response Matchers

toBeOk()

Asserts the response status is 200.

expect($response)->toBeOk();

toBeBad()

Asserts the response status is 400.

expect($response)->toBeBad();

toHaveStatus(int $code)

Asserts the response status matches the given code.

expect($response)->toHaveStatus(201);
expect($response)->toHaveStatus(404);

toHavePath(string $path, mixed $expected)

Asserts a value at a dot-notation path in the JSON body.

expect($response)->toHavePath('data.user.name', 'Chuck Norris');
expect($response)->toHavePath('meta.total', 42);

toHaveHeader(string $name, ?string $value = null)

Asserts the response has a header, its name in any case, as HTTP names are. Optionally checks its value.

expect($response)->toHaveHeader('Content-Type');
expect($response)->toHaveHeader('Content-Type', 'application/json');
expect($response)->not()->toHaveHeader('X-Debug');

toRedirectTo(string $url)

Asserts the response is a 3xx redirect with a matching Location header.

expect($response)->toRedirectTo('/dashboard');
expect($response)->toRedirectTo('https://example.com/login');

Existing Matchers That Work on Response Properties

You don't need special matchers for everything. Use the ones you already know:

// Body
expect($response->body)->toContain('hello');
expect($response->body)->toMatch('/id":\s*\d+/');

// JSON
expect($response->json)->toHaveKey('name');
expect($response->json)->toContain('Chuck Norris');
expect($response->json['items'])->toHaveCount(3);

// Headers, by name in any case
expect($response)->toHaveHeader('Content-Type', 'application/json');

// Direct value access
expect($response->json['name'])->toBe('Chuck Norris');
expect($response->status)->toBeGreaterThan(199);

PSR-7 Support

The response matchers (toBeOk, toBeBad, toHaveStatus, toHavePath) also work with any PSR-7 ResponseInterface. No hard dependency is required: install psr/http-message and the matchers detect it automatically:

$ composer require psr/http-message

This means Symfony and Laravel responses work out of the box via their PSR-7 bridges:

// Symfony: use symfony/psr-http-message-bridge
$psr7Response = $psrFactory->createResponse($symfonyResponse);
expect($psr7Response)->toBeOk();
expect($psr7Response)->toHavePath('data.name', 'Chuck');

// Laravel: use nyholm/psr7
$psr7Response = app('Psr\Http\Message\ResponseInterface');
expect($psr7Response)->toHaveStatus(201);

Replacing the Browser

The bundled client is the default implementation of one contract:

namespace PhpSpec\Browser;

interface Browser
{
    public function request(string $method, string $url, array $options = []): Response;
}

An extension can put anything behind visit() (BrowserKit, a headless browser) by implementing it and mapping what it drives onto the canonical Response, which is what keeps every response matcher working unchanged:

extensions:
  browser: Acme\BrowserKitBrowser

The key is singular because exactly one browser drives the DSL. A package can also auto-register via extra.phpspec (see Extensions); the config's choice wins, and two auto-discovered browsers with no config choice are refused by name.