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 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')
;
}
composer require zenstruck/browser --dev
Optionally, enable the provided extension in your phpunit.xml
:
- PHPUnit 8 or 9 :
<!-- phpunit.xml -->
<extensions>
<extension class="Zenstruck\Browser\Test\BrowserExtension" />
</extensions>
- PHPUnit 10+ :
<phpunit>
...
<extensions>
<bootstrap class="Zenstruck\Browser\Test\BrowserExtension" />
</extensions>
</phpunit>
This extension provides the following features:
- Intercepts test errors/failures and saves the browser's source (and screenshot/js console log if applicable) to the filesystem.
- After your test suite is finished, list of summary of all saved artifacts (source/screenshots/js console logs) in your console.
This library provides 2 different "browsers":
- KernelBrowser: makes requests using your Symfony Kernel (fast).
- 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
->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 "<project-root>/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
;
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) {
// ...
})
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
// If using zenstruck/foundry, you can pass a factory/proxy
->actingAs(UserFactory::new())
// fail if authenticated
->assertNotAuthenticated()
// fail if NOT authenticated
->assertAuthenticated()
// fails if NOT authenticated as "kbond"
->assertAuthenticated('kbond')
// \Symfony\Component\Security\Core\User\UserInterface or, if using
// zenstruck/foundry, you can pass a factory/proxy
->assertAuthenticated($user)
;
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:
- Before calling
->assertAuthenticated()
, visit a page you know initiates security (ieis_granted()
in a Twig template). - Call
->withProfiling()
before making the previous request. This enables the security data collector which performs security operations. - 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.
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())
;
Make assertions about json responses using JMESPath expressions See the JMESPath Tutorials 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 to see all the methods available onZenstruck\Browser\Json
.
The PantherBrowser
is experimental in 1.0 and may be subject to BC Breaks.
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 "<project-root>/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 "<project-root>/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()
;
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')
// ...
;
}
}
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) |
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 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();
})
;
See https://github.com/zenstruck/mailer-test#zenstruckbrowser-integration.
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();
});
If you find yourself creating a lot of http requests with the same options
(ie an X-Token
header) there are a couple ways to reduce this duplication:
-
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']]) ;
-
Use
->setDefaultHttpOptions()
in your test case's default 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']]) ; } }
-
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')) ;
-
Create a custom browser with your own request method (ie
->apiRequest()
).
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;
}
These are traits that can be added to a Custom Browser.
See https://github.com/zenstruck/mailer-test#zenstruckbrowser-integration.
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:
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('[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()
;
}