{"id":4723,"date":"2023-09-18T04:01:48","date_gmt":"2023-09-18T04:01:48","guid":{"rendered":"https:\/\/rengga.dev\/blog\/?p=4723"},"modified":"2023-09-18T04:05:12","modified_gmt":"2023-09-18T04:05:12","slug":"symfonys-domcrawler-with-laravel-http-tests","status":"publish","type":"post","link":"https:\/\/rengga.dev\/blog\/symfonys-domcrawler-with-laravel-http-tests\/","title":{"rendered":"Symfony&#8217;s DomCrawler with Laravel HTTP Tests"},"content":{"rendered":"<p>Have you ever needed to assert part of an HTML response from within an\u00a0<a href=\"https:\/\/laravel.com\/docs\/10.x\/http-tests\">HTTP test<\/a>\u00a0in Laravel? I recently needed to validate parts of a response to verify an important piece of content was rendered. For example, let&#8217;s say you have a critical JavaScript file that you want to ensure is contained within the DOM?<\/p>\n<p>Here&#8217;s the testing API we are going to build:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">$node = $response-&gt;get('\/')\r\n    -&gt;crawl()\r\n    -&gt;filter('a')\r\n    -&gt;reduce(function (Crawler $node): bool {\r\n        return $node-&gt;attr('href') === 'https:\/\/rengga.dev\/blog\/';\r\n    });\r\n \r\n$this-&gt;assertCount(1, $node);<\/pre>\n<p>There are a few options for parsing and traversing the DOM within PHP, such as PHP&#8217;s\u00a0<a href=\"https:\/\/www.php.net\/manual\/en\/class.domdocument.php\">DOMDocument<\/a>, PHPUnit&#8217;s DOM assertions (which are deprecated), Symfony&#8217;s DOMCrawler component, and various others. I happen to prefer Symfony&#8217;s DOMCrawler component, which has really powerful filtering, traversal and more.<\/p>\n<p>Let&#8217;s see how we can quickly incorporate the DOMCrawler component in our Laravel HTTP tests!<\/p>\n<h3>Example<\/h3>\n<p>The gist of using the DOMCrawler is creating a new\u00a0<code>Crawler<\/code>\u00a0instance with the HTML content passed to the constructor:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">use Symfony\\Component\\DomCrawler\\Crawler;\r\n \r\n$crawler = new Crawler($response-&gt;getContent());\r\n \r\nforeach ($crawler as $domElement) {\r\n    var_dump($domElement-&gt;nodeName);\r\n}<\/pre>\n<p>Using the Crawler instance directly works but is repetitive. Given Laravel&#8217;s powerful macro feature, we can quickly define a macro to crawl a\u00a0<code>TestResponse<\/code>.<\/p>\n<p>Here&#8217;s what the usage of my macro looks like:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">$response = $this-&gt;get('\/');\r\n \r\n$crawler = $response-&gt;crawl();\r\n \r\n\/\/ DOM traversal\r\n\/\/ Assertions<\/pre>\n<p>If you want to try it out, define the following macro in a service provider&#8217;s\u00a0<code>boot()<\/code>\u00a0method:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">use Illuminate\\Support\\ServiceProvider;\r\nuse Illuminate\\Testing\\TestResponse;\r\nuse Symfony\\Component\\DomCrawler\\Crawler;\r\nuse PHPUnit\\Framework\\Assert as PHPUnit;\r\n \r\n\/\/ ...\r\n \r\npublic function boot(): void\r\n{\r\n    TestResponse::macro('crawl', function(?callable $callback = null): Crawler {\r\n        if (empty($content = $this-&gt;getContent())) {\r\n            PHPUnit::fail('The HTTP response is empty.');\r\n        }\r\n \r\n        $callback ??= fn ($c): Crawler =&gt; $c;\r\n \r\n        return call_user_func($callback, new Crawler($content));\r\n    });\r\n}<\/pre>\n<p>Our macro does the following:<\/p>\n<ol>\n<li>Get the response content and fail the test immediately if it&#8217;s empty<\/li>\n<li>Create a pass-through callback if the user didn&#8217;t provide one using the null coalescing assignment operator<\/li>\n<li>Pass a new\u00a0<code>Crawler<\/code>\u00a0instance through the callback, which should return a\u00a0<code>Crawler<\/code>\u00a0instance in the end<\/li>\n<\/ol>\n<p>Using the null coalescing assignment operator, we avoid any\u00a0<code>if<\/code>\u00a0checks by providing a default pass-through callback if the user doesn&#8217;t pass one.<\/p>\n<p>The idea of the\u00a0<code>callable<\/code>\u00a0is that the user could traverse the DOM, do some assertions, and ultimately return a subset of the DOM via\u00a0<code>crawl()<\/code>\u00a0if only a part of the DOM is needed:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">\/\/ Return the full content\r\n$crawler = $response-&gt;crawl();\r\n \r\n\/\/ Filter and return the filtered crawler instance\r\n$card = $response-&gt;crawl(function (Crawler $c) {\r\n    return $c-&gt;filter('a')\r\n        -&gt;reduce(function (Crawler $node) {\r\n            return $node-&gt;attr('href') === 'https:\/\/rengga.dev\/blog\/';\r\n        });\r\n});<\/pre>\n<p>Maybe this example is a bit overkill, but let&#8217;s validate the Laravel News HTML included in the\u00a0<code>welcome.blade.php<\/code>\u00a0within a default Laravel installation:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">use Symfony\\Component\\DomCrawler\\Crawler;\r\n \r\n\/\/ ...\r\n \r\n\/**\r\n * A basic test example.\r\n *\/\r\npublic function test_the_application_returns_a_successful_response(): void\r\n{\r\n    $response = $this-&gt;get('\/');\r\n \r\n    $response-&gt;assertStatus(200);\r\n \r\n    \/\/ Find the Laravel News node\r\n    $card = $response-&gt;crawl()\r\n        -&gt;filter('a')\r\n        -&gt;reduce(function (Crawler $node) {\r\n            return $node-&gt;attr('href') === 'https:\/\/rengga.dev\/blog\/';\r\n        });\r\n \r\n    $this-&gt;assertNotEmpty($card, 'The Laravel News homepage card was not found!');\r\n \r\n    \/\/ Validate that the $card node has an H2 with `Laravel News`\r\n    $this-&gt;assertEquals(\r\n        'Laravel News',\r\n        $card-&gt;filter('h2')-&gt;first()-&gt;text()\r\n    );\r\n \r\n    \/\/ Validate that the page shows the blurb\r\n    $blurb = $card\r\n        -&gt;filter('p')\r\n        -&gt;reduce(function (Crawler $n) {\r\n            return str($n-&gt;text())-&gt;startsWith('Laravel News is a community driven portal');\r\n        });\r\n \r\n    $this-&gt;assertCount(1, $blurb);\r\n}<\/pre>\n<h3>Bonus<\/h3>\n<p>You may want to write more convenience helpers to validate DOM elements in your tests. For example, here&#8217;s a simple assertion that validates a node exists:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">$response\r\n    -&gt;assertStatus(200)\r\n    -&gt;assertNodeExists('a[href=\"https:\/\/rengga.dev\/blog\/\"]');<\/pre>\n<p>And here&#8217;s the macro I&#8217;ve defined, which also allows chaining with the\u00a0<code>TestResponse<\/code>\u00a0instance:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">TestResponse::macro('assertNodeExists', function(string $selector): static {\r\n    $node = $this-&gt;crawl()-&gt;filter($selector);\r\n    $message = \"Failed asserting the node exists with selector \\\"{$selector}\\\".\";\r\n \r\n    PHPUnit::assertGreaterThan(0, $node-&gt;count(), $message);\r\n \r\n    return $this;\r\n});<\/pre>\n<p>If the node doesn&#8217;t exist, here&#8217;s an example of what our macro will look like in our test output:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">1) Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response\r\n   Failed asserting the node exists with selector \"a[href=\"https:\/\/rengga.dev\/blog\/\"]\".\r\n   Failed asserting that 0 is greater than 0.<\/pre>\n<p>I&#8217;d recommend checking out the capabilities of the Symphony\u00a0<a href=\"https:\/\/symfony.com\/doc\/current\/components\/dom_crawler.html\">The DomCrawler Component<\/a>. It includes powerful XPath and CSS selectors, node traversal, and more!<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Have you ever needed to assert part of an HTML response from <a class=\"read-more\" href=\"https:\/\/rengga.dev\/blog\/symfonys-domcrawler-with-laravel-http-tests\/\" title=\"Symfony&#8217;s DomCrawler with Laravel HTTP Tests\" itemprop=\"url\"><\/a><\/p>\n","protected":false},"author":1,"featured_media":4608,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[12],"tags":[711,712,708,713],"newstopic":[597,574],"class_list":{"0":"post-4723","1":"post","2":"type-post","3":"status-publish","4":"format-standard","5":"has-post-thumbnail","7":"category-web-development","8":"tag-admin-dashboard","9":"tag-http","10":"tag-laravel","11":"tag-symfonys-domcrawler","12":"newstopic-laravel","13":"newstopic-php"},"_links":{"self":[{"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/posts\/4723","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/comments?post=4723"}],"version-history":[{"count":2,"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/posts\/4723\/revisions"}],"predecessor-version":[{"id":4725,"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/posts\/4723\/revisions\/4725"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/media\/4608"}],"wp:attachment":[{"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/media?parent=4723"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/categories?post=4723"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/tags?post=4723"},{"taxonomy":"newstopic","embeddable":true,"href":"https:\/\/rengga.dev\/blog\/wp-json\/wp\/v2\/newstopic?post=4723"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}