zenstruck / browser
为您的 Symfony 功能测试提供流畅的接口。
Requires
- php: >=8.0
- behat/mink: ^1.8
- symfony/browser-kit: ^5.4|^6.0|^7.0
- symfony/css-selector: ^5.4|^6.0|^7.0
- symfony/dom-crawler: ^5.4|^6.0|^7.0
- symfony/framework-bundle: ^5.4|^6.0|^7.0
- zenstruck/assert: ^1.1
- zenstruck/callback: ^1.4.2
Requires (Dev)
- dbrekelmans/bdi: ^1.0
- justinrainbow/json-schema: ^5.2
- mtdowling/jmespath.php: ^2.6
- phpstan/phpstan: ^1.4
- phpunit/phpunit: ^9.5|^10.4
- symfony/mime: ^5.4|^6.0|^7.0
- symfony/panther: ^1.1|^2.0.1
- symfony/phpunit-bridge: ^6.0|^7.0
- symfony/security-bundle: ^5.4|^6.0|^7.0
- zenstruck/foundry: ^1.30
Suggests
- justinrainbow/json-schema: Json schema validator. Needed to use Json::assertMatchesSchema().
- mtdowling/jmespath.php: PHP implementation for JMESPath. Needed to use Json assertions.
README
Symfony 的功能测试可能会很冗长。此库提供了一种表达性、可自动补全的流畅包装器,围绕 Symfony 的原生功能测试功能
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') ; }
将此库与 zenstruck/foundry 结合使用,可以使您的测试更加简洁和具有表达性
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
可选,在您的 phpunit.xml
中启用提供的扩展
- PHPUnit 8 或 9
<!-- phpunit.xml --> <extensions> <extension class="Zenstruck\Browser\Test\BrowserExtension" /> </extensions>
- PHPUnit 10+
<phpunit> ... <extensions> <bootstrap class="Zenstruck\Browser\Test\BrowserExtension" /> </extensions> </phpunit>
此扩展提供以下功能
- 拦截测试错误/失败,并将浏览器的源代码(以及适用时的截图/js控制台日志)保存到文件系统。
- 在您的测试套件完成后,在控制台中列出所有已保存的工件(源代码/截图/js控制台日志)的摘要。
用法
此库提供 2 种不同的 "浏览器":
- KernelBrowser:使用您的 Symfony Kernel 进行请求 (快速)。
- PantherBrowser:使用
symfony/panther
向具有真实浏览器的 web 服务器发送请求,这允许测试 JavaScript (慢)。
您可以通过使您的测试类使用 HasBrowser
特性在您的测试中使用这些浏览器
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') ; } }
所有浏览器都有以下方法
/** @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 "<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 ;
KernelBrowser
此浏览器有以下方法
/** @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) { // ... })
身份验证
KernelBrowser 有身份验证辅助程序和断言
/** @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) ;
身份验证故障排除
在调用
->assertAuthenticated()
时抛出LogicException: Cannot create the remember-me cookie; no master request available.
异常
这是当 token 是您的防火墙中 RememberMeToken
,lazy: true
时发生的,并且之前的请求没有执行任何安全相关操作。可能的解决方案
- 在调用
->assertAuthenticated()
之前,访问您知道会启动安全性的页面(例如,在 Twig 模板中的is_granted()
)。 - 在发出上一个请求之前调用
->withProfiling()
。这启用了安全数据收集器,该收集器执行安全操作。 - 在您的测试环境中设置
framework.profiler.collect: true
。这为所有请求启用分析器,从而消除了调用->withProfiling()
的需要,但可能会减慢您的测试速度。
HTTP 请求
KernelBrowser 可用于测试 API 端点。以下 http 方法可用
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 断言
使用 JMESPath 表达式 对 json 响本进行断言。有关更多信息,请参阅 JMESPath 教程。
注意 需要
mtdowling/jmespath.php
: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')); }) ;
注意 请参阅 完整的
zenstruck/assert
预期 API 文档,以查看Zenstruck\Browser\Json
上所有可用的方法。
PantherBrowser
在 1.0 中,PantherBrowser
是实验性的,并且可能会受到 BC 断裂的影响。
此浏览器有以下额外的方法
/** @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() ;
多个浏览器实例
在您的测试中,您可以多次调用->xBrowser()
方法以获取不同的浏览器实例。这对于测试具有实时功能的应用(例如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') // ... ; } }
配置
有多个环境变量可供配置。
扩展
测试浏览器配置
您可以通过重写HasBrowser
特性中的xBrowser()
方法,在测试中配置浏览器默认选项或起始状态。
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 ; } }
组件
组件是封装常见任务的组件对象。这些扩展Zenstruck\Browser\Component
,并可以注入到浏览器的->use()
可调用中。
/** @var \Zenstruck\Browser $browser **/ $browser ->use(function(MyComponent $component) { $component->method(); }) ;
邮件组件
请参阅https://github.com/zenstruck/mailer-test#zenstruckbrowser-integration。
自定义组件
您可能有一些页面或页面部分,在测试中经常使用特定的操作/断言。您可以将这些封装成组件。以下是一个创建CommentComponent
的例子,以展示这一功能。
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'); } }
在测试中访问和使用这个新组件。
/** @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(); });
自定义HttpOptions
如果您发现自己创建了大量的http请求具有相同的选项(例如X-Token
头),有几种方法可以减少这种重复。
-
使用
->setDefaultHttpOptions()
为当前浏览器设置默认选项。/** @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']]) ;
-
在测试用例的默认浏览器配置中使用
->setDefaultHttpOptions()
。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']]) ; } }
-
创建一个自定义的
HttpOptions
对象。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) ; } }
然后,在您的测试中
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')) ;
-
创建一个带有您自己的请求方法(例如
->apiRequest()
)的自定义浏览器。
自定义浏览器
您可能希望添加自己的操作和断言。您可以通过创建自己的扩展Browser来实现这一点,该浏览器扩展了某个实现。然后,您可以通过使用基础浏览器方法添加自己的操作/断言。
namespace App\Tests; use Zenstruck\Browser\KernelBrowser; class AppBrowser extends KernelBrowser { public function assertHasToolbar(): self { return $this->assertSeeElement('#toolbar'); } }
然后,根据您扩展的实现,设置适当的env变量
KernelBrowser
:KERNEL_BROWSER_CLASS
PantherBrowser
:PANTHER_BROWSER_CLASS
对于上面的例子,您需要设置KERNEL_BROWSER_CLASS=App\Tests\AppBrowser
。
提示:创建一个基础功能测试用例,以便所有测试都可以使用您的自定义浏览器,并使用@method
注解来确保测试可以自动完成您的自定义方法。
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; }
扩展
这些是可以添加到自定义浏览器的特型。
邮件扩展
请参阅https://github.com/zenstruck/mailer-test#zenstruckbrowser-integration。
自定义扩展
您可以为重复性任务创建自己的扩展。以下是一个创建用于登录/登出用户并对用户认证状态进行断言的AuthenticationExtension
的例子。
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; } }
添加到您的自定义浏览器中。
namespace App\Tests; use App\Tests\Browser\AuthenticationExtension; use Zenstruck\Browser\KernelBrowser; class AppBrowser extends KernelBrowser { use AuthenticationExtension; }
在测试中使用
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() ; }