JavaScript-Heavy UIs
WebTestCase only simulates a browser based on the returned HTML and never executes a single line of JavaScript, which means it simply tests nothing for Stimulus controllers, Symfony UX Live Components, or Turbo interactions. Symfony Panther closes this gap by driving a real Chrome or Firefox process through the WebDriver protocol. This article covers when the switch pays off, how waiting and screenshot debugging work, and what performance tradeoffs come with it.
Table of Contents
- 1. When WebTestCase without a real browser stops being enough
- 2. Installing Panther and your first PantherTestCase
- 3. How Panther drives a real Chrome or Firefox
- 4. waitFor() and timing issues with asynchronous JavaScript
- 5. Testing Symfony UX Live Components and Stimulus controllers
- 6. Screenshot debugging for failed tests
- 7. Panther in the CI pipeline: Docker, drivers and parallelization
- 8. Performance tradeoffs compared to classic functional tests
- 9. Practical recommendation and summary
- 10. Summary
- 11. FAQ
1. When WebTestCase without a real browser stops being enough
WebTestCase is built on the BrowserKit component, which simulates HTTP requests directly against the Symfony kernel and analyzes the returned HTML response through a DomCrawler, without ever starting a real browser or a JavaScript engine. For classic server-rendered pages this is entirely sufficient: fill out a form, submit it, check the redirect, verify the status code and the response content, all without the overhead of a real browser process and therefore very fast to run.
Once interactions are triggered exclusively on the client side, though, such as a Stimulus controller that dynamically loads content on a button click, a Symfony UX Live Component updating its state through an AJAX morph, or a Turbo Frame navigation without a full page reload, WebTestCase simply cannot observe these effects by design, because no JavaScript ever runs. In these cases the DomCrawler always sees only the initial, server-rendered state of the page, regardless of what a real browser would actually show after executing the JavaScript.
2. Installing Panther and your first PantherTestCase
Panther is installed as a dev dependency via composer require symfony/panther --dev and does not bundle a browser of its own, instead driving an already installed Chrome or Firefox through ChromeDriver or geckodriver and the W3C WebDriver protocol. For local development the package dbrekelmans/bdi is a good fit, since it automatically downloads the matching driver binaries, while CI environments usually rely on a prepared Docker image with Chrome and ChromeDriver already installed, avoiding a fresh installation on every pipeline run.
A test class extends PantherTestCase instead of WebTestCase and creates a client through static::createPantherClient(), which internally starts both a real HTTP server for the Symfony application and the browser process. The API deliberately stays close to BrowserKit's, so existing knowledge about $client->request(), $crawler->filter() and $crawler->selectLink() transfers largely unchanged, complemented by methods such as click(), which trigger an actual mouse click in the browser and thereby also activate handlers bound to JavaScript event listeners.
<?php
// tests/Application/OrderFilterPantherTest.php
declare(strict_types=1);
namespace App\Tests\Application;
use Symfony\Component\Panther\PantherTestCase;
final class OrderFilterPantherTest extends PantherTestCase
{
public function testStimulusFilterUpdatesResultsWithoutReload(): void
{
$client = static::createPantherClient();
$crawler = $client->request('GET', '/admin/orders');
$crawler->filter('[data-testid="status-filter"]')->selectOption('shipped');
$client->waitFor('[data-testid="order-row"]');
self::assertCount(4, $crawler->filter('[data-testid="order-row"]'));
}
}
3. How Panther drives a real Chrome or Firefox
Unlike BrowserKit, which only simulates HTTP requests, Panther starts an independent browser process and communicates with it through the standardized WebDriver protocol, the same technology Selenium is built on. Every action such as click(), submitForm() or waitFor() is sent to the browser as an actual WebDriver command, which performs the corresponding interaction exactly as a human would with a mouse and keyboard, including the full execution of any registered JavaScript event listeners.
This closeness to real user behavior is Panther's actual value: CSS transitions, dynamically appended DOM nodes, form validation through the browser's native HTML5 validation API, and asynchronous fetch() calls all behave exactly as they would for a real user. Standard Chrome can be run in headless mode through configuration, so CI environments do not need a visible browser window, while locally the environment variable PANTHER_NO_HEADLESS=1 opens the browser visibly, letting you follow tests interactively.
4. waitFor() and timing issues with asynchronous JavaScript
The most common mistake in first Panther tests is checking an assertion right after an interaction such as a click, without accounting for the fact that asynchronous JavaScript, for example a fetch() request from a Live Component, needs time to execute. A test that checks the number of visible rows immediately after clicking a filter button then fails sporadically, depending on how fast network and rendering happened to be in that particular run, which produces the classic flaky test.
Panther provides explicit waiting methods such as waitFor(), waitForVisibility(), waitForInvisibility() and waitForElementToContain(), which internally poll repeatedly to check whether a condition is met and only then continue, instead of letting a fixed delay pass. These methods are noticeably more robust than a hard sleep(), because in the best case they continue immediately once the condition is met and only wait up to a configurable timeout in the worst case, making tests both more reliable and, on average, faster.
5. Testing Symfony UX Live Components and Stimulus controllers
Symfony UX Live Components update their DOM state through AJAX requests that return freshly server-rendered HTML, which is inserted into the existing DOM on the client side via a morph algorithm, without reloading the whole page. A WebTestCase cannot observe this morph process, because it is triggered exclusively client-side through a Stimulus controller. A Panther test, on the other hand, performs a real interaction such as changing a select field, waits for the updated DOM fragment via waitFor(), and then checks the actually rendered state, exactly as a real user's browser would.
For plain Stimulus controllers without a Live Component backend, the same principle applies: a test clicks or types into a field, waits for the DOM change triggered by the controller, and checks the result. It matters here not to rely on implementation details such as CSS classes that might change with the next redesign, but to use stable data-testid attributes instead, which stay in place regardless of visual appearance and make tests less fragile.
6. Screenshot debugging for failed tests
When a Panther test fails in CI, a plain stack trace often helps little, because the actual cause usually lies in the page's visual state at the moment of failure, for example an element that has not loaded yet or an unexpected error message in the UI. The client method takeScreenshot() saves the current browser state as a PNG file and can be called conditionally in tearDown() whenever the current test is marked as failed, so every failing test run automatically leaves a screenshot behind as a CI artifact.
Alongside the screenshot, $crawler->html() provides the complete HTML actually present in the DOM at the moment of failure, which is especially helpful for checking whether an expected attribute or a specific text is genuinely missing or was simply searched for with the wrong selector. Combined, the screenshot and HTML dump almost always let you diagnose a failing test's cause directly from the CI artifacts without local reproduction.
7. Panther in the CI pipeline: Docker, drivers and parallelization
In the CI pipeline, a prepared Docker image with Chrome and a matching ChromeDriver already installed is noticeably more stable than installing the drivers on every pipeline run, because version mismatches between Chrome and ChromeDriver can otherwise cause hard-to-diagnose failures. The environment variable PANTHER_APP_ENV lets the Symfony application start with its own configuration for Panther tests, for example a separate test database, while PANTHER_EXTERNAL_BASE_URI lets Panther point at an already running server instead of its own built-in PHP web server, which fits well with Docker Compose setups.
Because every Panther test starts its own browser process, parallelizing across several CI jobs or a PHPUnit Paratest setup is worthwhile to offset the significantly longer overall runtime compared to a pure WebTestCase suite. It also makes sense to keep Panther tests in their own CI stage, separate from the fast unit and WebTestCase suite, so a failure in a slow browser test does not block the fast feedback of the rest of the tests.
8. Performance tradeoffs compared to classic functional tests
A single Panther test noticeably takes more time than an equivalent WebTestCase, because it additionally has to start a real browser process, establish a WebDriver connection, and perform actual network I/O between the test process, the browser and the Symfony application, instead of simulating everything inside the same PHP process. In practice individual Panther tests often run in the range of several seconds, while a comparable WebTestCase typically finishes in a fraction of that time, which adds up to a substantial total runtime across hundreds of tests.
This leads to a clear recommendation for test distribution: the large majority of test cases, especially all checks that do not require genuine client-side interaction, should still be covered by WebTestCase or even plain unit tests, while Panther is used sparingly and deliberately for cases where JavaScript actually drives the behavior under test. A good rule of thumb is to limit Panther tests to critical, JavaScript-driven user journeys instead of covering every single interaction twice, once with WebTestCase and once with Panther.
9. Practical recommendation and summary
As a rule of thumb: use WebTestCase for everything that is server-rendered and works without JavaScript, and reserve Panther exclusively for interactions whose behavior genuinely depends on client-side code, such as Live Component updates, Stimulus-driven form logic, or Turbo Frame navigation. This clear separation avoids both needlessly slow test suites and blind spots in test coverage for exactly the interactions most likely to break in production due to browser incompatibilities or JavaScript errors.
Panther is therefore not a replacement for WebTestCase, but a targeted complement for the growing share of client-side logic in modern Symfony applications built with UX bundles. Once the investment in setup, CI integration and waiting strategies has been made, teams gain a test layer that covers exactly the cases that were previously often verified only manually in a browser, and therefore regressed most often unnoticed.
| Aspect | WebTestCase | Panther | Recommendation |
|---|---|---|---|
| JavaScript execution | No | Yes, real browser | Use Panther only when JS drives the behavior |
| Execution speed | Very fast | Noticeably slower | Keep the Panther share deliberately small |
| Live Components / Stimulus | Not testable | Fully testable | Panther for critical JS-driven journeys |
| Setup effort | None | Driver and browser required | Docker image with Chrome preinstalled |
| Flakiness risk | Low | Higher without waitFor() | Always use explicit waiting methods |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
Panther Tests: The Essentials at a Glance
Core problem
WebTestCase never executes JavaScript and cannot observe Stimulus or Live Component interactions.
Solution
Panther drives a real Chrome or Firefox via WebDriver and executes every bit of client-side JS.
Debugging
takeScreenshot() and $crawler->html() diagnose failed CI runs without local reproduction.
Performance tradeoff
Panther tests run noticeably slower, so use them deliberately, only for genuine JS interactions.