PHPackages                             zenstruck/browser - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Testing &amp; Quality](/categories/testing)
4. /
5. zenstruck/browser

ActiveLibrary[Testing &amp; Quality](/categories/testing)

zenstruck/browser
=================

A fluent interface for your Symfony functional tests.

v1.10.0(4mo ago)2272.2M↓21%25[34 issues](https://github.com/zenstruck/browser/issues)[7 PRs](https://github.com/zenstruck/browser/pulls)19MITPHPPHP &gt;=8.1CI passing

Since Dec 29Pushed 3mo ago5 watchersCompare

[ Source](https://github.com/zenstruck/browser)[ Packagist](https://packagist.org/packages/zenstruck/browser)[ Docs](https://github.com/zenstruck/browser)[ GitHub Sponsors](https://github.com/kbond)[ GitHub Sponsors](https://github.com/nikophil)[ RSS](/packages/zenstruck-browser/feed)WikiDiscussions 1.x Synced 1mo ago

READMEChangelog (10)Dependencies (17)Versions (32)Used By (19)

zenstruck/browser
=================

[](#zenstruckbrowser)

[![CI Status](https://github.com/zenstruck/browser/workflows/CI/badge.svg)](https://github.com/zenstruck/browser/actions?query=workflow%3ACI)[![Code Coverage](https://camo.githubusercontent.com/d67a77b6202b5369cc450ac8f5afa95fba867b8b60a7fcf6e5f13be671396f2c/68747470733a2f2f636f6465636f762e696f2f67682f7a656e73747275636b2f62726f777365722f6272616e63682f312e782f67726170682f62616467652e7376673f746f6b656e3d52374f48595947504b4d)](https://codecov.io/gh/zenstruck/browser)

Functional testing with Symfony can be verbose. This library provides an expressive, auto-completable, fluent wrapper around Symfony's native functional testing features:

```
public function testViewPostAndAddComment()
{
    // assumes a "Post" is in the database with an id of 3

    $this->browser()
        ->visit('/posts/3')
        ->assertSuccessful()
        ->assertSeeIn('title', 'My First Post')
        ->assertSeeIn('h1', 'My First Post')
        ->assertNotSeeElement('#comments')
        ->fillField('Comment', 'My First Comment')
        ->click('Submit')
        ->assertOn('/posts/3')
        ->assertSeeIn('#comments', 'My First Comment')
    ;
}
```

Combine this library with [zenstruck/foundry](https://github.com/zenstruck/foundry)to make your tests even more succinct and expressive:

```
public function testViewPostAndAddComment()
{
    $post = PostFactory::new()->create(['title' => 'My First Post']);

    $this->browser()
        ->visit("/posts/{$post->getId()}")
        ->assertSuccessful()
        ->assertSeeIn('title', 'My First Post')
        ->assertSeeIn('h1', 'My First Post')
        ->assertNotSeeElement('#comments')
        ->fillField('Comment', 'My First Comment')
        ->click('Submit')
        ->assertOn("/posts/{$post->getId()}")
        ->assertSeeIn('#comments', 'My First Comment')
    ;
}
```

Installation
------------

[](#installation)

```
composer require zenstruck/browser --dev

```

Optionally, enable the provided extension in your `phpunit.xml`:

- PHPUnit 8 or 9 :

```

```

- PHPUnit 10+ :

```

   ...

```

This extension provides the following features:

1. Intercepts test errors/failures and saves the browser's source (and screenshot/js console log if applicable) to the filesystem.
2. After your test suite is finished, list of summary of all saved artifacts (source/screenshots/js console logs) in your console.

Usage
-----

[](#usage)

This library provides 2 different "browsers":

1. [KernelBrowser](#kernelbrowser): makes requests using your Symfony Kernel *(fast)*.
2. [PantherBrowser](#pantherbrowser): makes requests to a webserver with a real browser using `symfony/panther` which allows testing javascript *(slow)*.

You can use these Browsers in your tests by having your test class use the `HasBrowser` trait:

```
namespace App\Tests;

use PHPUnit\Framework\TestCase;
use Zenstruck\Browser\Test\HasBrowser;

class MyTest extends TestCase
{
    use HasBrowser;

    /**
     * Requires this test extends Symfony\Bundle\FrameworkBundle\Test\KernelTestCase
     * or Symfony\Bundle\FrameworkBundle\Test\WebTestCase.
     */
    public function test_using_kernel_browser(): void
    {
        $this->browser()
            ->visit('/my/page')
            ->assertSeeIn('h1', 'Page Title')
        ;
    }

    /**
     * Requires this test extends Symfony\Component\Panther\PantherTestCase.
     */
    public function test_using_panther_browser(): void
    {
        $this->pantherBrowser()
            ->visit('/my/page')
            ->assertSeeIn('h1', 'Page Title')
        ;
    }
}
```

All browsers have the following methods:

```
/** @var \Zenstruck\Browser $browser **/

$browser
    // ACTIONS
    ->visit('/my/page')
    ->click('A link')
    ->fillField('Name', 'Kevin')
    ->checkField('Accept Terms')
    ->uncheckField('Accept Terms')
    ->selectField('Canada') // "radio" select
    ->selectField('Type', 'Employee') // "select" single option
    ->selectField('Notification', ['Email', 'SMS']) // "select" multiple options
    ->selectField('Notification', []) // "un-select" all multiple options
    ->attachFile('Photo', '/path/to/photo.jpg')
    ->attachFile('Photo', ['/path/to/photo1.jpg', '/path/to/photo2.jpg']) // attach multiple files (if field supports this)
    ->click('Submit')

    // ASSERTIONS
    ->assertOn('/my/page') // by default checks "path", "query" and "fragment"
    ->assertOn('/a/page', ['path']) // check just the "path"

    // these look in the entire response body (useful for non-html pages)
    ->assertContains('some text')
    ->assertNotContains('some text')

    // these look in the html only
    ->assertSee('some text')
    ->assertNotSee('some text')
    ->assertSeeIn('h1', 'some text')
    ->assertNotSeeIn('h1', 'some text')
    ->assertSeeElement('h1')
    ->assertNotSeeElement('h1')
    ->assertElementCount('ul li', 2)
    ->assertElementAttributeContains('head meta[name=description]', 'content', 'my description')
    ->assertElementAttributeNotContains('head meta[name=description]', 'content', 'my description')

    // form field assertions
    ->assertFieldEquals('Username', 'kevin')
    ->assertFieldNotEquals('Username', 'john')

    // form checkbox assertions
    ->assertChecked('Accept Terms')
    ->assertNotChecked('Accept Terms')

    // form select assertions
    ->assertSelected('Type', 'Employee')
    ->assertNotSelected('Type', 'Admin')

    // form multi-select assertions
    ->assertSelected('Roles', 'Content Editor')
    ->assertSelected('Roles', 'Human Resources')
    ->assertNotSelected('Roles', 'Owner')

    // CONVENIENCE METHODS
    ->use(function() {
        // do something without breaking
    })

    ->use(function(\Zenstruck\Browser $browser) {
        // access the current Browser instance
    })

    ->use(function(\Symfony\Component\BrowserKit\AbstractBrowser $browser) {
        // access the "inner" browser
    })

    ->use(function(\Symfony\Component\BrowserKit\CookieJar $cookieJar) {
        // access the cookie jar
        $cookieJar->expire('MOCKSESSID');
    })

    ->use(function(\Zenstruck\Browser $browser, \Symfony\Component\DomCrawler\Crawler $crawler) {
        // access the current Browser instance and the current crawler
    })

    ->crawler() // Symfony\Component\DomCrawler\Crawler instance for the current response

    ->content() // string - raw response body

    // save the raw source of the current page
    // by default, saves to "/var/browser/source"
    // configure with "BROWSER_SOURCE_DIR" env variable
    ->saveSource('source.txt')

    // the following use symfony/var-dumper's dump() function and continue
    ->dump() // raw response body
    ->dump('h1') // html element
    ->dump('foo') // if json response, array key
    ->dump('foo.*.baz') // if json response, JMESPath notation can be used

    // the following use symfony/var-dumper's dd() function ("dump & die")
    ->dd() // raw response body or array if json
    ->dd('h1') // html element
    ->dd('foo') // if json response, array key
    ->dd('foo.*.baz') // if json response, JMESPath notation can be used
;
```

### KernelBrowser

[](#kernelbrowser)

This browser has the following methods:

```
/** @var \Zenstruck\Browser\KernelBrowser $browser **/

$browser
    // response assertions
    ->assertStatus(200)
    ->assertSuccessful() // 2xx status code
    ->assertRedirected() // 3xx status code
    ->assertHeaderEquals('Content-Type', 'text/html; charset=UTF-8')
    ->assertHeaderContains('Content-Type', 'html')
    ->assertHeaderEquals('X-Not-Present-Header', null)

    // helpers for quickly checking the content type
    ->assertJson()
    ->assertXml()
    ->assertHtml()
    ->assertContentType('zip')

    // by default, exceptions are caught and converted to a response
    // use the BROWSER_CATCH_EXCEPTIONS environment variable to change default
    // this disables that behaviour allowing you to use TestCase::expectException()
    ->throwExceptions()

    // enable catching exceptions
    ->catchExceptions()

    // by default, the kernel is rebooted between requests
    // this disables this behaviour
    ->disableReboot()

    // re-enable rebooting between requests if previously disabled
    ->enableReboot()

    // enable the profiler for the next request (if not globally enabled)
    ->withProfiling()

    // by default, redirects are followed, this disables that behaviour
    // use the BROWSER_FOLLOW_REDIRECTS environment variable to change default
    ->interceptRedirects()

    // enable following redirects
    // if currently on a redirect response, follows
    ->followRedirects()

    // Follows a redirect if ->interceptRedirects() has been turned on
    ->followRedirect() // follows all redirects by default
    ->followRedirect(1) // just follow 1 redirect

    // combination of assertRedirected(), followRedirect(), assertOn()
    ->assertRedirectedTo('/some/page') // follows all redirects by default
    ->assertRedirectedTo('/some/page', 1) // just follow 1 redirect

    // combination of interceptRedirects(), withProfiling(), click()
    // useful for submitting forms and making assertions on the "redirect response"
    ->clickAndIntercept('button')

    // exception assertions for the "next request"
    ->expectException(MyException::class, 'the message')
    ->post('/url/that/throws/exception') // fails if above exception not thrown

    ->expectException(MyException::class, 'the message')
    ->click('link or button') // fails if above exception not thrown
;

// Access the Symfony Profiler for the last request
$queryCount = $browser
    // If profiling is not globally enabled for tests, ->withProfiling()
    // must be called before the request.
    ->profile()->getCollector('db')->getQueryCount()
;

// "use" a specific data collector
$browser->use(function(\Symfony\Component\HttpKernel\DataCollector\RequestDataCollector $collector) {
    // ...
})
```

#### Authentication

[](#authentication)

The *KernelBrowser* has helpers and assertions for authentication:

```
/** @var \Zenstruck\Browser\KernelBrowser $browser **/

$browser
    // authenticate a user for subsequent actions
    ->actingAs($user) // \Symfony\Component\Security\Core\User\UserInterface

    // fail if authenticated
    ->assertNotAuthenticated()

    // fail if NOT authenticated
    ->assertAuthenticated()

    // fails if NOT authenticated as "kbond"
    ->assertAuthenticated('kbond')

    // \Symfony\Component\Security\Core\User\UserInterface
    ->assertAuthenticated($user)
;
```

##### Troubleshooting Authentication

[](#troubleshooting-authentication)

> `LogicException: Cannot create the remember-me cookie; no master request available.`exception when calling `->assertAuthenticated()`

This is caused when the *token* is a `RememberMeToken`, `lazy: true` in your firewall, and the previous request didn't perform any security-related operations. Possible solutions:

1. Before calling `->assertAuthenticated()`, visit a page you know initiates security (ie `is_granted()` in a Twig template).
2. Call `->withProfiling()` before making the previous request. This enables the security data collector which performs security operations.
3. Set `framework.profiler.collect: true` in your test environment. This enables the profiler for all requests removing the need to ever call `->withProfiling()` but can slow down your tests.

#### HTTP Requests

[](#http-requests)

The *KernelBrowser* can be used for testing API endpoints. The following http methods are available:

```
use Zenstruck\Browser\HttpOptions;

/** @var \Zenstruck\Browser\KernelBrowser $browser **/

$browser
    // http methods
    ->get('/api/endpoint')
    ->put('/api/endpoint')
    ->post('/api/endpoint')
    ->delete('/api/endpoint')

    // second parameter can be an array of request options
    ->post('/api/endpoint', [
        // request headers
        'headers' => ['X-Token' => 'my-token'],

        // request body
        'body' => 'request body',
    ])
    ->post('/api/endpoint', [
        // json_encode request body and set Content-Type/Accept headers to application/json
        'json' => ['request' => 'body'],

        // simulates an AJAX request (sets the X-Requested-With to XMLHttpRequest)
        'ajax' => true,
    ])

    // optionally use the provided Zenstruck\Browser\HttpOptions object
    ->post('/api/endpoint',
        HttpOptions::create()->withHeader('X-Token', 'my-token')->withBody('request body')
    )

    // sets the Content-Type/Accept headers to application/json
    ->post('/api/endpoint', HttpOptions::json())

    // json encodes value and sets as body
    ->post('/api/endpoint', HttpOptions::json(['request' => 'body']))

    // simulates an AJAX request (sets the X-Requested-With to XMLHttpRequest)
    ->post('/api/endpoint', HttpOptions::ajax())

    // simulates a JSON AJAX request
    ->post('/api/endpoint', HttpOptions::jsonAjax())
;
```

#### Json Assertions

[](#json-assertions)

Make assertions about json responses using [JMESPath expressions](https://jmespath.org/)See the [JMESPath Tutorials](https://jmespath.org/tutorial.html) to learn more.

Note

`mtdowling/jmespath.php` is required: `composer require --dev mtdowling/jmespath.php`.

```
/** @var \Zenstruck\Browser\KernelBrowser $browser **/
$browser
    ->get('/api/endpoint')
    ->assertJson() // ensures the content-type is application/json
    ->assertJsonMatches('foo.bar.baz', 1) // automatically calls ->assertJson()
    ->assertJsonMatches('foo.*.baz', [1, 2, 3])
    ->assertJsonMatches('length(foo)', 3)
    ->assertJsonMatches('"@some:thing"', 6) // note: special characters like : and @ need to be wrapped in quotes
;

// access the json "crawler"
$json = $browser
    ->get('/api/endpoint')
    ->json()
;

$json->assertMatches('foo.bar.baz', 1);
$json->assertHas('foo.bar.baz');
$json->assertMissing('foo.bar.boo');
$json->search('foo.bar.baz'); // mixed (the found value at "JMESPath expression")
$json->decoded(); // the decoded json
(string) $json; // the json string pretty-printed

// "use" the json crawler
$json = $browser
    ->get('/api/endpoint')
    ->use(function(\Zenstruck\Browser\Json $json) {
        // Json acts like a proxy of zenstruck/assert Expectation class
        $json->hasCount(5);
        $json->contains('foo');
        // assert on children: the closure gets Json object contextualized on given selector
        // {"foo": "bar"}
        $json->assertThat('foo', fn(Json $json) => $json->equals('bar'))
        // assert on each element of an array
        // {"foo": [1, 2, 3]}
        $json->assertThatEach('foo', fn(Json $json) => $json->isGreaterThan(0));
        // assert json matches given json schema
        $json->assertMatchesSchema(file_get_contents('/path/to/json-schema.json'));
    })
;
```

Note

See the [full `zenstruck/assert` expectation API documentation](https://github.com/zenstruck/assert#expectation-api)to see all the methods available on `Zenstruck\Browser\Json`.

### PantherBrowser

[](#pantherbrowser)

Note

The `PantherBrowser` is experimental in 1.0 and may be subject to BC Breaks.

Tip

By default, Panther will not start a web server if it detects one already running with the Symfony CLI. This is likely running in your `dev` environment and will cause unexpected test failures. Set the env variable `BROWSER_ALWAYS_START_WEBSERVER=1`to always start a webserver configured for your current test env when running Panther tests.

This browser has the following extra methods:

```
/** @var \Zenstruck\Browser\PantherBrowser $browser **/

$browser
    // pauses the tests and enters "interactive mode" which
    // allows you to investigate the current state in the browser
    // (requires the env variable PANTHER_NO_HEADLESS=1)
    ->pause()

    // take a screenshot of the current browser state
    // by default, saves to "/var/browser/screenshots"
    // configure with "BROWSER_SCREENSHOT_DIR" env variable
    ->takeScreenshot('screenshot.png')

    // save the browser's javascript console error log
    // by default, saves to "/var/browser/console-log"
    // configure with "BROWSER_CONSOLE_LOG_DIR" env variable
    ->saveConsoleLog('console.log')

    // check if element is visible in the browser
    ->assertVisible('.selector')
    ->assertNotVisible('.selector')

    // wait x milliseconds
    ->wait(1000) // 1 second

    ->waitUntilVisible('.selector')
    ->waitUntilNotVisible('.selector')
    ->waitUntilSeeIn('.selector', 'some text')
    ->waitUntilNotSeeIn('.selector', 'some text')

    ->doubleClick('Link')
    ->rightClick('Link')

    // dump() the browser's console error log
    ->dumpConsoleLog()

    // dd() the browser's console error log
    ->ddConsoleLog()

    // dd() and take screenshot (default filename is "screenshot.png")
    ->ddScreenshot()
;
```

### Multiple Browser Instances

[](#multiple-browser-instances)

Within your test, you can call `->xBrowser()` methods multiple times to get different browser instances. This could be useful for testing an app with real-time capabilities (ie websockets):

```
namespace App\Tests;

use Symfony\Component\Panther\PantherTestCase;
use Zenstruck\Browser\Test\HasBrowser;

class MyTest extends PantherTestCase
{
    use HasBrowser;

    public function testDemo(): void
    {
        $browser1 = $this->pantherBrowser()
            ->visit('/my/page')
            // ...
        ;

        $browser2 = $this->pantherBrowser()
            ->visit('/my/page')
            // ...
        ;
    }
}
```

Configuration
-------------

[](#configuration)

There are several environment variables available to configure:

VariableDescriptionDefault`BROWSER_SOURCE_DIR`Directory to save source files to.`./var/browser/source``BROWSER_SCREENSHOT_DIR`Directory to save screenshots to (only applies to `PantherBrowser`).`./var/browser/screenshots``BROWSER_CONSOLE_LOG_DIR`Directory to save javascript console logs to (only applies to `PantherBrowser`).`./var/browser/console-logs``BROWSER_FOLLOW_REDIRECTS`Whether to follow redirects by default (only applies to `KernelBrowser`).`1` *(true)*`BROWSER_CATCH_EXCEPTIONS`Whether to catch exceptions by default (only applies to `KernelBrowser`).`1` *(true)*`BROWSER_SOURCE_DEBUG`Whether to add request metadata to written source files (only applies to `KernelBrowser`).`0` *(false)*`KERNEL_BROWSER_CLASS``KernelBrowser` class to use.`Zenstruck\Browser\KernelBrowser``PANTHER_BROWSER_CLASS``PantherBrowser` class to use.`Zenstruck\Browser\PantherBrowser``PANTHER_NO_HEADLESS`Disable headless-mode and allow usage of `PantherBrowser::pause()`.`0` *(false)*`BROWSER_ALWAYS_START_WEBSERVER`Always start a webserver configured for your current test env before running tests (only applies to `PantherBrowser`).`0` *(false)*Extending
---------

[](#extending)

### Test Browser Configuration

[](#test-browser-configuration)

You can configure default options or a starting state for your browser in your tests by overriding the `xBrowser()` method from the `HasBrowser` trait:

```
namespace App\Tests;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Browser\KernelBrowser;
use Zenstruck\Browser\Test\HasBrowser;

class MyTest extends KernelTestCase
{
    use HasBrowser {
        browser as baseKernelBrowser;
    }

    public function testDemo(): void
    {
        $this->browser()
            ->assertOn('/') // browser always starts on the homepage (as defined below)
        ;
    }

    protected function browser(): KernelBrowser
    {
        return $this->baseKernelBrowser()
            ->interceptRedirects() // always intercept redirects
            ->throwExceptions() // always throw exceptions
            ->visit('/') // always start on the homepage
        ;
    }
}
```

### Components

[](#components)

Components are objects that wrap common tasks into a *component* object. These extend `Zenstruck\Browser\Component` and can be injected into a browser's `->use()`callable:

```
/** @var \Zenstruck\Browser $browser **/

$browser
    ->use(function(MyComponent $component) {
        $component->method();
    })
;
```

#### Mailer Component

[](#mailer-component)

See .

#### Custom Components

[](#custom-components)

You may have pages or page parts that have specific actions/assertions you use quite regularly in your tests. You can wrap these up into a *Component*. Let's create a `CommentComponent` as an example to demonstrate this feature:

```
namespace App\Tests;

use Zenstruck\Browser\Component;
use Zenstruck\Browser\KernelBrowser;

/**
 * If only using this component with a specific browser, this type hint can help your IDE.
 *
 * @method KernelBrowser browser()
 */
class CommentComponent extends Component
{
    public function assertHasNoComments(): self
    {
        $this->browser()->assertElementCount('#comments li', 0);

        return $this; // optionally make methods fluent
    }

    public function assertHasComment(string $body, string $author): self
    {
        $this->browser()
            ->assertSeeIn('#comments li span.body', $body)
            ->assertSeeIn('#comments li span.author', $author)
        ;

        return $this;
    }

    public function addComment(string $body, string $author): self
    {
        $this->browser()
            ->fillField('Name', $author)
            ->fillField('Comment', $body)
            ->click('Add Comment')
        ;

        return $this;
    }

    protected function preAssertions(): void
    {
        // this is called as soon as the component is loaded
        $this->browser()->assertSeeElement('#comments');
    }

    protected function preActions(): void
    {
        // this is called when the component is loaded but before
        // preAssertions(). Useful for page components where you
        // need to navigate to the page:
        // $this->browser()->visit('/contact');
    }
}
```

Access and use this new component in your tests:

```
/** @var \Zenstruck\Browser $browser **/

$browser
    ->visit('/post/1')
    ->use(function(CommentComponent $component) {
        // the function typehint triggers the component to be loaded,
        // preActions() run and preAssertions() run

        $component
            ->assertHasNoComments()
            ->addComment('comment body', 'Kevin')
            ->assertHasComment('comment body')
        ;
    })
;

// you can optionally inject multiple components into the ->use() callback
$browser->use(function(Component1 $component1, Component2 $component2) {
    $component1->doSomething();
    $component2->doSomethingElse();
});
```

### Custom HttpOptions

[](#custom-httpoptions)

If you find yourself creating a lot of [http requests](#http-requests) with the same options (ie an `X-Token` header) there are a couple ways to reduce this duplication:

1. Use `->setDefaultHttpOptions()` for the current browser:

    ```
    /** @var \Zenstruck\Browser\KernelBrowser $browser **/

    $browser
        ->setDefaultHttpOptions(['headers' => ['X-Token' => 'my-token']])

        // now all http requests will have the X-Token header
        ->get('/endpoint')

        // "per-request" options will be merged with the default
        ->get('/endpoint', ['headers' => ['Another' => 'Header']])
    ;
    ```
2. Use `->setDefaultHttpOptions()` in your test case's [default browser configuration](#test-browser-configuration):

    ```
    namespace App\Tests;

    use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
    use Zenstruck\Browser\KernelBrowser;
    use Zenstruck\Browser\Test\HasBrowser;

    class MyTest extends KernelTestCase
    {
        use HasBrowser {
            browser as baseKernelBrowser;
        }

        public function testDemo(): void
        {
            $this->browser()
                // all http requests in this test class will have the X-Token header
                ->get('/endpoint')

                // "per-request" options will be merged with the default
                ->get('/endpoint', ['headers' => ['Another' => 'Header']])
            ;
        }

        protected function browser(): KernelBrowser
        {
            return $this->baseKernelBrowser()
                ->setDefaultHttpOptions(['headers' => ['X-Token' => 'my-token']])
            ;
        }
    }
    ```
3. Create a custom `HttpOptions` object:

    ```
    namespace App\Tests;

    use Zenstruck\Browser\HttpOptions;

    class AppHttpOptions extends HttpOptions
    {
        public static function api(string $token, $json = null): self
        {
            return self::json($json)
                ->withHeader('X-Token', $token)
            ;
        }
    }
    ```

    Then, in your tests:

    ```
    use Zenstruck\Browser\HttpOptions;

    /** @var \Zenstruck\Browser\KernelBrowser $browser **/

    $browser
        // instead of
        ->post('/api/endpoint', HttpOptions::json()->withHeader('X-Token', 'my-token'))

        // use your ApiHttpOptions object
        ->post('/api/endpoint', AppHttpOptions::api('my-token'))
    ;
    ```
4. Create a [custom browser](#custom-browser) with your own request method (ie `->apiRequest()`).

### Custom Browser

[](#custom-browser)

It is likely you will want to add your own actions and assertions. You can do this by creating your own *Browser* that extends one of the implementations. You can then add your own actions/assertions by using the base browser methods.

```
namespace App\Tests;

use Zenstruck\Browser\KernelBrowser;

class AppBrowser extends KernelBrowser
{
    public function assertHasToolbar(): self
    {
        return $this->assertSeeElement('#toolbar');
    }
}
```

Then, depending on the implementation you extended from, set the appropriate env variable:

- `KernelBrowser`: `KERNEL_BROWSER_CLASS`
- `PantherBrowser`: `PANTHER_BROWSER_CLASS`

For the example above, you would set `KERNEL_BROWSER_CLASS=App\Tests\AppBrowser`.

Tip

Create a base functional test case so all your tests can use your custom browser and use the `@method` annotation to ensure your tests can autocomplete your custom methods:

```
namespace App\Tests;

use App\Tests\AppBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Browser\Test\HasBrowser;

/**
 * @method AppBrowser browser()
 */
abstract class MyTest extends WebTestCase
{
    use HasBrowser;
}
```

### Extensions

[](#extensions)

These are traits that can be added to a [Custom Browser](#custom-browser).

#### Mailer Extension

[](#mailer-extension)

See .

#### Custom Extension

[](#custom-extension)

You can create your own extensions for repetitive tasks. The example below is for an `AuthenticationExtension` to login/logout users and make assertions about a users authenticated status:

```
namespace App\Tests\Browser;

trait AuthenticationExtension
{
    public function loginAs(string $username, string $password): self
    {
        return $this
            ->visit('/login')
            ->fillField('email', $username)
            ->fillField('password', $password)
            ->click('Login')
        ;
    }

    public function logout(): self
    {
        return $this->visit('/logout');
    }

    public function assertLoggedIn(): self
    {
        $this->assertSee('Logout');

        return $this;
    }

    public function assertLoggedInAs(string $user): self
    {
        $this->assertSee($user);

        return $this;
    }

    public function assertNotLoggedIn(): self
    {
        $this->assertSee('Login');

        return $this;
    }
}
```

Add to your [Custom Browser](#custom-browser):

```
namespace App\Tests;

use App\Tests\Browser\AuthenticationExtension;
use Zenstruck\Browser\KernelBrowser;

class AppBrowser extends KernelBrowser
{
    use AuthenticationExtension;
}
```

Use in your tests:

```
public function testDemo(): void
{
    $this->browser()
        // goes to the /login page, fills email/password fields,
        // and presses the Login button
        ->loginAs('kevin@example.com', 'password')

        // asserts text "Logout" exists (assumes you have a logout link when users are logged in)
        ->assertLoggedIn()

        // asserts email exists as text (assumes you display the user's email when they are logged in)
        ->assertLoggedInAs('kevin@example.com')

        // goes to the /logout page
        ->logout()

        // asserts text "Login" exists (assumes you have a login link when users not logged in)
        ->assertNotLoggedIn()
    ;
}
```

###  Health Score

65

—

FairBetter than 99% of packages

Maintenance77

Regular maintenance activity

Popularity60

Solid adoption and visibility

Community36

Small or concentrated contributor base

Maturity73

Established project with proven stability

 Bus Factor1

Top contributor holds 89.7% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~60 days

Recently: every ~121 days

Total

32

Last Release

92d ago

Major Versions

v0.11.0 → v1.0.02022-04-08

v1.10.0 → 2.x-dev2026-02-12

PHP version history (4 changes)v0.1.0PHP &gt;=7.4

v1.3.0PHP &gt;=8.0

v1.10.0PHP &gt;=8.1

2.x-devPHP &gt;=8.3

### Community

Maintainers

![](https://www.gravatar.com/avatar/707369cc916e0ea1aacbf077dcba464f611cef879f024d8944311a54a15224b3?d=identicon)[kbond](/maintainers/kbond)

---

Top Contributors

[![kbond](https://avatars.githubusercontent.com/u/127811?v=4)](https://github.com/kbond "kbond (306 commits)")[![nikophil](https://avatars.githubusercontent.com/u/10139766?v=4)](https://github.com/nikophil "nikophil (7 commits)")[![flohw](https://avatars.githubusercontent.com/u/457663?v=4)](https://github.com/flohw "flohw (4 commits)")[![Chris53897](https://avatars.githubusercontent.com/u/7104259?v=4)](https://github.com/Chris53897 "Chris53897 (4 commits)")[![norkunas](https://avatars.githubusercontent.com/u/2722872?v=4)](https://github.com/norkunas "norkunas (3 commits)")[![Nyholm](https://avatars.githubusercontent.com/u/1275206?v=4)](https://github.com/Nyholm "Nyholm (2 commits)")[![wouterj](https://avatars.githubusercontent.com/u/749025?v=4)](https://github.com/wouterj "wouterj (2 commits)")[![welcoMattic](https://avatars.githubusercontent.com/u/773875?v=4)](https://github.com/welcoMattic "welcoMattic (2 commits)")[![OskarStark](https://avatars.githubusercontent.com/u/995707?v=4)](https://github.com/OskarStark "OskarStark (2 commits)")[![nitneuk](https://avatars.githubusercontent.com/u/52654545?v=4)](https://github.com/nitneuk "nitneuk (1 commits)")[![deluxetom](https://avatars.githubusercontent.com/u/6439307?v=4)](https://github.com/deluxetom "deluxetom (1 commits)")[![jwage](https://avatars.githubusercontent.com/u/97422?v=4)](https://github.com/jwage "jwage (1 commits)")[![KDederichs](https://avatars.githubusercontent.com/u/24696606?v=4)](https://github.com/KDederichs "KDederichs (1 commits)")[![nathan-de-pachtere](https://avatars.githubusercontent.com/u/6233770?v=4)](https://github.com/nathan-de-pachtere "nathan-de-pachtere (1 commits)")[![benr77](https://avatars.githubusercontent.com/u/2156742?v=4)](https://github.com/benr77 "benr77 (1 commits)")[![raneomik](https://avatars.githubusercontent.com/u/19893310?v=4)](https://github.com/raneomik "raneomik (1 commits)")[![staabm](https://avatars.githubusercontent.com/u/120441?v=4)](https://github.com/staabm "staabm (1 commits)")[![t-richard](https://avatars.githubusercontent.com/u/22999032?v=4)](https://github.com/t-richard "t-richard (1 commits)")

---

Tags

symfonytestsymfonytestdev

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/zenstruck-browser/health.svg)

```
[![Health](https://phpackages.com/badges/zenstruck-browser/health.svg)](https://phpackages.com/packages/zenstruck-browser)
```

###  Alternatives

[zenstruck/foundry

A model factory library for creating expressive, auto-completable, on-demand dev/test fixtures with Symfony and Doctrine.

78611.9M97](/packages/zenstruck-foundry)[zenstruck/messenger-test

Assertions and helpers for testing your symfony/messenger queues.

2774.8M13](/packages/zenstruck-messenger-test)[zenstruck/mailer-test

Alternative, opinionated helpers for testing emails sent with symfony/mailer.

46671.1k3](/packages/zenstruck-mailer-test)[drupal/core-dev

require-dev dependencies from drupal/drupal; use in addition to drupal/core-recommended to run tests from drupal/core.

2021.0M277](/packages/drupal-core-dev)[drupal/drupal-extension

Drupal extension for Behat

22215.1M147](/packages/drupal-drupal-extension)[zenstruck/console-test

Alternative, opinionated helper for testing Symfony console commands.

58635.0k20](/packages/zenstruck-console-test)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
