Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/zenstruck/browser

A fluent interface for your Symfony functional tests.
https://github.com/zenstruck/browser

symfony test

Last synced: 3 days ago
JSON representation

A fluent interface for your Symfony functional tests.

Awesome Lists containing this project

README

        

# zenstruck/browser

[![CI Status](https://github.com/zenstruck/browser/workflows/CI/badge.svg)](https://github.com/zenstruck/browser/actions?query=workflow%3ACI)
[![Code Coverage](https://codecov.io/gh/zenstruck/browser/branch/1.x/graph/badge.svg?token=R7OHYYGPKM)](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:

```php
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:

```php
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

```
composer require zenstruck/browser --dev
```

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

- PHPUnit 8 or 9 :
```xml

```

- PHPUnit 10+ :

```xml

...


```

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

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:

```php
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:

```php
/** @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

This browser has the following methods:

```php
/** @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

The _KernelBrowser_ has helpers and assertions for authentication:

```php
/** @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

> `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

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

```php
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

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`.

```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

> [!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:

```php
/** @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

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):

```php
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

There are several environment variables available to configure:

| Variable | Description | Default |
|----------------------------------|------------------------------------------------------------------------------------------------------------------------|------------------------------------|
| `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

### 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:

```php
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 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:

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

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

#### Mailer Component

See https://github.com/zenstruck/mailer-test#zenstruckbrowser-integration.

#### 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:

```php
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:

```php
/** @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

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:
```php
/** @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):
```php
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:
```php
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:

```php
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

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.

```php
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:

```php
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

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

#### Mailer Extension

See https://github.com/zenstruck/mailer-test#zenstruckbrowser-integration.

#### 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:

```php
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):

```php
namespace App\Tests;

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

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

Use in your tests:

```php
public function testDemo(): void
{
$this->browser()
// goes to the /login page, fills email/password fields,
// and presses the Login button
->loginAs('[email protected]', '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('[email protected]')

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

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