Introducing Symfony Panther: a Browser Testing and Web Scrapping Library for PHP

Logo of the Symfony Panther project

Since the very first version of Symfony 2, the framework provides a suite of convenient tools to create functional tests. They use the BrowserKit and DomCrawler components to simulate a web browser with a developer-friendly API.

The WebTestCase helper

Let's refresh our memories by creating a tiny news website, and the corresponding functional test suite:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# create the new project
$ composer create-project symfony/skeleton news-website
$ cd news-website/

# add some dependencies
$ composer require twig annotations
$ composer require --dev maker tests

# Run the PHP built-in web server
$ php -S 127.0.0.1:8000 -t public

We're ready to code. Start by adding a class to store and retrieve the news:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/Repository/NewsRepository.php
namespace App\Repository;

class NewsRepository
{
    private const NEWS = [
        'week-601' => [
            'slug' => 'week-601',
            'title' => 'A week of symfony #601 (2-8 July 2018)',
            'body' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.',
        ],
        'symfony-live-usa-2018' => [
            'slug' => 'symfony-live-usa-2018',
            'title' => 'Join us at SymfonyLive USA 2018!',
            'body' => 'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur.'
        ],
    ];

    public function findAll(): iterable
    {
        return array_values(self::NEWS);
    }

    public function findOneBySlug(string $slug): ?array
    {
        return self::NEWS[$slug] ?? null;
    }
}

This implementation isn't very dynamic, but it does the job. Then, we need a controller and the corresponding Twig template to display the latest news of the community. We'll use the Maker Bundle to generate them:

1
$ ./bin/console make:controller News

Edit the generated code to fit our requirements:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// src/Controller/NewsController.php
namespace App\Controller;

use App\Repository\NewsRepository;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class NewsController extends Controller
{
    private $newsRepository;

    public function __construct(NewsRepository $newsRepository)
    {
        $this->newsRepository = $newsRepository;
    }

    /**
     * @Route("/", name="news_index")
     */
    public function index(): Response
    {
        return $this->render('news/index.html.twig', [
            'collection' => $this->newsRepository->findAll(),
        ]);
    }

    /**
     * @Route("/news/{slug}", name="news_item")
     */
    public function item(string $slug): Response
    {
        if (null === $news = $this->newsRepository->findOneBySlug($slug)) {
            throw $this->createNotFoundException();
        }

        return $this->render('news/item.html.twig', ['item' => $news]);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{# templates/news/index.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}News{% endblock %}

{% block body %}
{% for item in collection %}
    <article id="{{ item.slug }}">
        <h1><a href="{{ path('news_item', {slug: item.slug}) }}">{{ item.title }}</a></h1>
        {{ item.body }}
    </article>
{% endfor %}
{% endblock %}
1
2
3
4
5
6
7
8
{% extends 'base.html.twig' %}

{% block title %}{{ item.title }}{% endblock %}

{% block body %}
    <h1>{{ item.title }}</h1>
    {{ item.body }}
{% endblock %}

Thanks to the WebTestCase helper, adding some functional tests for this website is easy. First, generate a functional test skeleton:

1
$ ./bin/console make:functional-test NewsControllerTest

And add assertions to check that our controller works properly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// tests/NewsControllerTest.php
namespace App\Tests;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class NewsControllerTest extends WebTestCase
{
    public function testNews()
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/');

        $this->assertCount(2, $crawler->filter('h1'));
        $this->assertSame(['week-601', 'symfony-live-usa-2018'], $crawler->filter('article')->extract('id'));

        $link = $crawler->selectLink('Join us at SymfonyLive USA 2018!')->link();
        $crawler = $client->click($link);

        $this->assertSame('Join us at SymfonyLive USA 2018!', $crawler->filter('h1')->text());
    }
}

And now, run the tests:

1
$ ./bin/phpunit

All green! Symfony provides a very convenient API to navigate the website, check that links work and assert that the expected content is displayed. It's easy to setup, and it's super fast!

Using Panther to Run the Scenario in a Browser

However, WebTestCase doesn't use a real web browser. It simulates one with pure PHP components. It doesn't even use the HTTP protocol: it creates instances of HttpFoundation's Request objects, pass them to the Symfony kernel, and allows to assert on the HttpFoundation Response instance returned by the app. Now, what if a problem preventing the webpage to load occurs in the browser? Such issues can be as diverse as a link hidden by a faulty CSS rule, a default form behavior prevented by a buggy JavaScript file, or, worst, the detection by the browser of a security vulnerability in your code.

Well, Panther allows to run this exact same scenario in real browsers! It also implements the BrowserKit and DomCrawler APIs, but under the hood it uses the Facebook PHP WebDriver library. It means that you can choose to execute the same browsing scenario in a lightning-fast pure PHP implementation (WebTestCase) or in any modern web browser, through the WebDriver browser automation protocol which became an official W3C recommendation in June.

What's even better, to use Panther, you only need a local Chrome installation. There is nothing more to install: no Selenium (but Panther supports it too), no obscure browser driver or extension... Actually, because Panther is now a dependency of the symfony/test-pack metapackage, you've already installed Panther without knowing it when you've typed composer req --dev tests earlier. You could also install Panther directly in any PHP project by running composer require symfony/panther.

Let's tweak some lines of our existing test case:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// tests/NewsControllerTest.php
namespace App\Tests;

-use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\Panther\PantherTestCase;

-class NewsControllerTest extends WebTestCase
+class NewsControllerTest extends PantherTestCase
{
    public function testNews()
    {
-        $client = static::createClient(); // Still work, if needed
+        $client = static::createPantherClient();

Run the tests, again:

1
$ ./bin/phpunit

All green, again! But this time, we're sure that our news website works properly in Google Chrome.

Under the hood Panther has:

  • started your project with the built-in PHP webserver on localhost:9000
  • started the version of Chromedriver shipped with the library to automate your local Chrome
  • executed the browsing scenario defined in the test with Chrome in headless mode

If you only believe what you see, try running the following:

1
$ PANTHER_NO_HEADLESS=1 ./bin/phpunit

Watch the Screencast

As you may have noticed in the recording, I've added some calls to sleep() to highlight how it works. Having access to the browser's window (and to the Dev Tools) is also very useful to debug a failing scenario.

Because both tools implement the same API, Panther can also execute web scraping scenarios written for the popular Goutte library. In test cases, Panther lets you choose if the scenario must be executed using the Symfony kernel (when available, static::createClient()), using Goutte (send real HTTP queries but no JavaScript and CSS support, static::createGoutteClient()) or using real web browsers (static::createPantherClient()).

Even if Chrome is the default choice, Panther can control any browser supporting the WebDriver protocol. It also supports remote browser testing services such as Selenium Grid (open source), SauceLabs and Browserstack.

There is also an experimental branch that uses Geckodriver to automatically start and drive a local installation of Firefox instead of Chrome.

Testing Client-side Generated HTML

Our news website looks good, and we've just proved that it works in Chrome. But now, we want to hear some feedback from the community about our frequent publications. Let's add a comment system to our website.

To do so, we'll leverage the capabilities of Symfony 4 and of the modern web platform: we'll manage the comments through a web API, and we'll render them using Web Components and Vue.js. Using JavaScript for this feature allows to improve the overall performance and user experience: each time we post a new comment, it will be displayed in the existing page without requiring a full reload.

Symfony provides an official integration with API Platform, probably the easiest way out there to create modern web APIs (hypermedia and/or GraphQL). Install it:

1
$ composer require api

Then, use the Maker Bundle again to create a Comment entity class, and expose it through a read and write API endpoint:

1
$ ./bin/console make:entity --api-resource Comment

This command is interactive, and allows to specify the fields to create. We need only two: news (the slug of the news) and body (the comment's content). news is of type string (maximum length of 255 chars), while body is of type text. Both aren't nullable.

Here is the full transcript of the interactions with the command:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
New property name (press <return> to stop adding fields):
> news

Field type (enter ? to see all types) [string]:
>

Field length [255]:
>

Can this field be null in the database (nullable) (yes/no) [no]:
>

updated: src/Entity/Comment.php

Add another property? Enter the property name (or press <return> to stop adding fields):
> body

Field type (enter ? to see all types) [string]:
> text

Can this field be null in the database (nullable) (yes/no) [no]:
>

updated: src/Entity/Comment.php

Update the .env file to set the value of DATABASE_URL to the address of your RDBMS and run the following command to create the table corresponding to our entity:

1
$ ./bin/console doctrine:schema:create

If you open http://localhost:8000/api, you can see that the API is already working and documented 😁.

We'll make some minor modification to the generated Comment class. Currently, the API allows GET, POST, PUT and DELETE operations. This is too open. As we don't have any authentication mechanism for now, we only want our users to be able to create and read comments:

1
2
3
4
5
6
/**
- * @ApiResource()
+ * @ApiResource(
+ *     collectionOperations={"post", "get"},
+ *     itemOperations={"get"}
+ * )

Then, we want to be able to retrieve the comments posted on a specific news article. We'll use a filter in order to do that:

1
2
3
4
5
6
7
8
9
+ use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiResource;
+ use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;

    /**
     * @ORM\Column(type="string", length=255)
+      * @ApiFilter(SearchFilter::class)
     */
    private $news;

Finally, add some validation constraints to be sure the submitted comments are OK:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/**
 * @ORM\Column(type="string", length=255)
+  * @Assert\Choice(choices={"week-601", "symfony-live-usa-2018"})
 * @ApiFilter(SearchFilter::class)
 */
private $news;

/**
 * @ORM\Column(type="text")
+  * @Assert\NotBlank()
 */
private $body;

Reload http://localhost:8000/api, the changes are automatically taken into account.

Creating a custom validation constraint instead of hardcoding the list of available slugs in the choice assertion is left as an exercise to the reader.

That's all for the PHP part! Easy, isn't it? Let's consume our API with Vue.js! To do so, we'll use the Vue.js integration provided by Symfony Webpack Encore.

Install Encore and its Vue.js integration:

1
2
3
4
$ composer require encore
# If you don't have the Yarn package manager yet, install it from https://yarnpkg.com/en/
$ yarn install
$ yarn add --dev vue vue-loader@^14 vue-template-compiler

Update Encore's config to enable the Vue loader:

1
2
3
4
5
6
// webpack.config.js

Encore
    // ...
+   .addEntry('js/comments', './assets/comments/index.js')
+   .enableVueLoader()

We're ready to create some cool frontend! Let's start with a Vue component rendering the list of comments and a form to post a new one:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!-- assets/comments/CommentSystem.vue -->
<template>
    <div>
        <ol reversed v-if="comments.length">
            <li v-for="comment in comments" :key="comment['@id']">{{ comment.body }}</li>
        </ol>
        <p v-else>No comments yet 🙁</p>

        <form id="post-comment" @submit.prevent="onSubmit">
            <textarea name="new-comment" v-model="newComment"
                      placeholder="Your opinion matters! Send us your comment."></textarea>

            <input type="submit" :disabled="!newComment">
        </form>
    </div>
</template>

<script>
    export default {
        props: {
            news: {type: String, required: true}
        },
        methods: {
            fetchComments() {
                fetch(`/api/comments?news=${encodeURIComponent(this.news)}`)
                    .then((response) => response.json())
                    .then((data) => this.comments = data['hydra:member'])
                ;
            },
            onSubmit() {
                fetch('/api/comments', {
                    method: 'POST',
                    headers: {
                        'Accept': 'application/ld+json',
                        'Content-Type': 'application/ld+json'
                    },
                    body: JSON.stringify({news: this.news, body: this.newComment})
                })
                    .then(({ok, statusText}) => {
                        if (!ok) {
                            alert(statusText);
                            return;
                        }

                        this.newComment = '';
                        this.fetchComments();
                    })
                ;
            }
        },
        data() {
            return {
                comments: [],
                newComment: '',
            };
        },
        created() {
            this.fetchComments();
        }
    }
</script>

It wasn't that hard, was it?

Then, create the entrypoint for our comment app:

1
2
3
4
5
6
7
8
// assets/comments/index.js
import Vue from 'vue';
import CommentSystem from './CommentSystem';

new Vue({
    el: '#comments',
    components: {CommentSystem}
});

Finally, reference the JavaScript file and initialize the <comment-system> web component with the current slug in the item.html.twig template:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{% block body %}
<h1>{{ item.title }}</h1>

{{ item.body }}

+ <div id="comments">
+     <comment-system news="{{ item.slug }}"></comment-system>
+ </div>
{% endblock %}

+ {% block javascripts %}
+     <script src="{{ asset('build/js/comments.js') }}"></script>
+ {% endblock %}

Build the transpiled and minified JS file (you may want to use Hot Module Reloading in dev):

1
$ yarn encore production

Wow! Thanks to Symfony 4, we have created a web API and a rich Vue.js webapp in just a few lines of code. Ok, let's add some tests for our comment system!

Wait... The comments are fetched using AJAX, and rendered client-side, in JavaScript. And the new comments are also added asynchronously using JS. Unfortunately, it will not be possible to use WebTestCase nor Goutte to test our new feature: they are written in PHP, and don't support JavaScript or AJAX 😱.

Don't worry, Panther is able to test such applications. Remember: under the hood it uses a real web browser!

Let's test our comment system:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
namespace App\Tests;

use Symfony\Component\Panther\PantherTestCase;

class CommentsTest extends PantherTestCase
{
    public function testComments()
    {
        $client = static::createPantherClient();
        $crawler = $client->request('GET', '/news/symfony-live-usa-2018');

        $client->waitFor('#post-comment'); // Wait for the form to appear, it may take some time because it's done in JS
        $form = $crawler->filter('#post-comment')->form(['new-comment' => 'Symfony is so cool!']);
        $client->submit($form);

        $client->waitFor('#comments ol'); // Wait for the comments to appear

        $this->assertSame(self::$baseUri.'/news/symfony-live-usa-2018', $client->getCurrentURL()); // Assert we're still on the same page
        $this->assertSame('Symfony is so cool!', $crawler->filter('#comments ol li:first-child')->text());
    }
}

Be careful, in test mode, environment variables must be defined by phpunit.xml.dist. Be sure to update DATABASE_URL to reference a clean database populated with the required tables. When the database is ready, run the tests:

1
$ ./bin/phpunit

Watch the Screencast

Thanks to Panther, you can take advantage of both your existing Symfony skills and the nice BrowserKit API to test modern JavaScript apps.

Extra Capabilities (Screenshots, Injecting JS)

But there's more, Panther leverages the fact that it uses real web browsers to provide features that are not supported by the BrowserKit component: it is able to take screenshots, to wait for elements to appear and to execute custom JavaScript in the execution context of the page. Actually, in addition to the BrowserKit API, Panther implements the Facebook\WebDriver\WebDriver interface, giving access to all features of PHP Webdriver.

Let's try it. Update the previous test scenario to take a screenshot of the rendered page:

1
2
$this->assertSame('Symfony is so cool!', $crawler->filter('#comments ol li:first-child')->text());
+ $client->takeScreenshot('screen.png');

Panther is also designed to run in Continuous Integration systems: it supports Travis and AppVeyor out of the box, and is compatible with Docker!

To read the full documentation, or give a star to the project, go to the GitHub repository.

Thank You, Open Source

Panther is built on top of several FOSS libraries, and has been inspired by Nightwatch.js, a WebDriver-based testing tool for JavaScript that I've been using for years now.

To create this library, I used a few dependencies, and had to fix some issues in them:

Open Source is a virtuous ecosystem: you benefit from existing powerful libraries to create higher level tools, and at the same time you can give back by improving them.

I'm also contributing the low level bricks that have been designed during the Panther development directly to PHP WebDriver, they are:

These bricks will then directly benefit to the whole community including the Panther alternatives also built on top of PHP Webdriver (Laravel Dusk, Codeception, ...).

I want to thank all the contributors of these projects, and especially Ondřej Machulda, the current maintainer of PHP Webdriver who has taken the time to review and merge my patches. A special thanks to George S. Baugh too, who added support for the W3C WebDriver protocol to his Perl WebDriver implementation. When I stumbled upon it, it helped a lot to understand how the protocol differs from the legacy Selenium protocol.

Panther is still in its early stage. To report (or fix) a bug, to add a new feature or... to give it a star, rendezvous at https://github.com/symfony/panther

If you're in the London area in September, come to the SymfonyLive conference to dive deeper into Panther.

Comments

"symfony/panther" was not installed during installation "symfony/test-pack".
You have a typo in the Headline :) "Scraping", not "Scrapping" (i guess)
Great addition to the symfony stack. I do have some concerns about the name though. "Panther" says nothing about the package's goals/contents. I personally think symfony/browser-testing would be more descriptive. There's already symfony/encore, which is already a name that doesn't add any context to what it contains. When such a path is taken it can easily make the symfony toolchain become harder to discover. The names also don't indicate the boundaries making them more likely to be crossed.
@Adam: it will be the case when https://github.com/symfony/test-pack/pull/4 will be merged, we forgot about that before publishing, sorry :(
Neat work ! I tried it on a project with intensive browser testing and worked like a charm. Except for some tests that changes services in the container (mock stuff or simulate a down API), it does not work out of the box because of the isolated webserver.

Also, there is just a small mistake : this post says to use "NO_HEADLESS=1", but the readme of the project says "PANTHER_NO_HEADLESS=1" and the screencast shows "PANTHERE_NO_HEADLESS=1"
Very good news to see Panther becoming an official component of the Symfony ecosystem :)
@Tristan we've fixed the name of the PANTHER_NO_HEADLESS env var.

@Delf we know :( Sadly we can't fix it because the slug would change and all the links from Twitter, Medium, etc. would break. I'm really sorry about this typo.
A gigantic thank you to Kévin for contributing this awesome tool to all PHP users and to Symfony. I love the name also because we can adopt it, and it doesn't restrict the use cases people will use it for (scrapping, testing, etc. who knows). Also because it's not a component you build on top of, but it's a tool you *use* (kinda like phpunit et al.)

KUDOS!!!
It should be indicated that it doesn't work with Symfony3.

Problem 1
- symfony/browser-kit v4.1.0 conflicts with symfony/panther[v0.1.0].
- Installation request for symfony/panther ^0.1.0 -> satisfiable by symfony/panther[v0.1.0].
- Conclusion: remove symfony/symfony v3.4.15
Thank you very much Kevin for your tool, that's awesome work. And thank you for this great step by step article, which makes things very clear.

I just have one question but maybe it's a bit off-topic: is it possible to install Chrome without X? I used to like very much PhantomJS for this purpose, as I could install it in a Docker container without all the graphical dependencies.
@Michaël you can use Chrome in headless mode. Example with Docker: https://github.com/symfony/panther#docker-integration
Great! I used to play with testcafe from devextrem but I feel like panther could be a cool alternative
Awesome work Dunglas! Thank you for all things that are you doing for symfony and php community
Trying this library getting this error on windows "Symfony\Component\Process\Exception\RuntimeException: The provided cwd does not exist."
Can anyone help me out in it?
I need to set up a different port than 9000 because phpunit hangs without any message.
@Kévin Dunglas

Maybe you could add the line "{# templates/news/item.html.twig #}" in the second template.
Login with SensioLabsConnect to post a comment