Advanced PHP Testing: Mastering Unit, Integration, and E2E with PHPUnit #
In the rapidly evolving landscape of 2025, writing code is only half the job. The other half—perhaps the more critical half—is proving that it works and ensuring it keeps working as your application scales.
For many mid-level developers, testing often stops at simple assertions. You check if 2 + 2 equals 4. But in a production environment processing thousands of transactions, you need more. You need to verify that your Dependency Injection container is wiring services correctly, that your database migrations don’t break strict schema rules, and that the user checkout flow actually results in a charge.
This is where the distinction between Unit, Integration, and End-to-End (E2E) testing becomes vital.
In this deep dive, we aren’t just looking at syntax. We are looking at architecture. We will build a robust testing suite for a hypothetical e-commerce payment system using modern PHP 8.3+ features and PHPUnit 11+.
The Testing Philosophy: The Pyramid #
Before we touch the code, we must align on strategy. The “Testing Pyramid” is a concept that has stood the test of time, but in 2025, the lines have blurred slightly due to the speed of containerized databases (Docker).
However, the core principle remains: Fast feedback is king.
- Unit Tests: The foundation. Fast, isolated, abundant.
- Integration Tests: The middle layer. verifying components talk to each other (DB, Cache, APIs).
- E2E Tests: The capstone. Slow, expensive, but they simulate the real user.
Here is how a modern CI pipeline prioritizes these tests:
Prerequisites and Environment Setup #
To follow this guide, you should have a professional PHP development environment ready. We are assuming you are running a Linux-based environment (or WSL2 on Windows/macOS).
Requirements:
- PHP: 8.3 or 8.4
- Composer: 2.7+
- Docker & Docker Compose: For spinning up test databases.
- Xdebug or PCOV: For code coverage analysis (PCOV is recommended for speed in CI).
Project Initialization #
Let’s create a directory structure that separates our test suites. This is a hallmark of a senior developer’s setup—don’t dump everything into one folder.
mkdir php-advanced-testing
cd php-advanced-testing
composer init --name="phpdevpro/advanced-testing" --require="php:^8.3" -nNow, install the necessary dependencies. We need PHPUnit and symfony/panther for E2E testing.
composer require --dev phpunit/phpunit ^11.0 symfony/panther dbrekelmans/b-2020Note: We included dbrekelmans/b-2020 just as a placeholder for browser drivers, but Panther usually handles this.
Configuration: phpunit.xml
#
Stop using the default generated config. Create a phpunit.xml that explicitly defines your test suites. This allows you to run composer test:unit separately from composer test:e2e.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
executionOrder="random"
failOnWarning="true"
failOnRisky="true"
beStrictAboutChangesToGlobalState="true"
beStrictAboutOutputDuringTests="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
<testsuite name="E2E">
<directory>tests/E2E</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>Key Takeaway: Setting executionOrder="random" helps identify flaky tests that rely on specific run orders (a common pitfall involving shared state).
Part 1: Advanced Unit Testing #
Unit tests should test logic, not connections. If your unit test hits a database, it’s an integration test. If it makes an HTTP call, it’s an integration test.
Let’s build a PaymentProcessor. It depends on a PaymentGatewayInterface. We want to test the logic of processing a payment without actually charging a credit card.
The Source Code #
Create src/Payment/PaymentGatewayInterface.php:
<?php
namespace App\Payment;
interface PaymentGatewayInterface
{
public function charge(string $token, float $amount): bool;
}Create src/Payment/PaymentProcessor.php:
<?php
namespace App\Payment;
use InvalidArgumentException;
use RuntimeException;
readonly class PaymentProcessor
{
public function __construct(
private PaymentGatewayInterface $gateway
) {}
public function process(string $token, float $amount): string
{
if ($amount <= 0) {
throw new InvalidArgumentException("Amount must be greater than zero.");
}
if (empty($token)) {
throw new InvalidArgumentException("Invalid payment token.");
}
try {
$success = $this->gateway->charge($token, $amount);
} catch (\Exception $e) {
// Log error here in a real app
throw new RuntimeException("Gateway communication failed.");
}
if (!$success) {
return "declined";
}
return "approved";
}
}The Test: Mocks and Attributes #
In PHPUnit 10/11, we prefer Attributes (#[Test]) over PHPDoc annotations (/** @test */).
We need to mock the PaymentGatewayInterface. Mocking allows us to simulate the behavior of dependencies.
Create tests/Unit/PaymentProcessorTest.php:
<?php
namespace Tests\Unit;
use App\Payment\PaymentGatewayInterface;
use App\Payment\PaymentProcessor;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
class PaymentProcessorTest extends TestCase
{
#[Test]
public function it_returns_approved_when_gateway_is_successful(): void
{
// 1. Create the Mock
$gatewayMock = $this->createMock(PaymentGatewayInterface::class);
// 2. Configure the Mock
// We expect 'charge' to be called ONCE with specific arguments
$gatewayMock->expects($this->once())
->method('charge')
->with('tok_visa', 100.00)
->willReturn(true);
// 3. Inject Mock into System Under Test (SUT)
$processor = new PaymentProcessor($gatewayMock);
// 4. Assert
$result = $processor->process('tok_visa', 100.00);
$this->assertEquals('approved', $result);
}
#[Test]
#[DataProvider('invalidAmountProvider')]
public function it_throws_exception_for_invalid_amounts(float $amount): void
{
$gatewayMock = $this->createMock(PaymentGatewayInterface::class);
$processor = new PaymentProcessor($gatewayMock);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("Amount must be greater than zero.");
$processor->process('tok_test', $amount);
}
public static function invalidAmountProvider(): array
{
return [
'zero amount' => [0.0],
'negative amount' => [-10.50],
];
}
}Why this is “Advanced”: #
- Strict Expectations: We used
expects($this->once()). If the code changes and calls the gateway twice (charging the user double), this test fails. - Data Providers: instead of writing three separate tests for 0, -1, and -50, we use a provider. This reduces code duplication significantly.
- Strict Typing: Return types (
: void) and typed properties are used throughout.
Part 2: Integration Testing #
Integration tests verify that your code works with the “dirty” outside world (Databases, Filesystems, APIs).
For this example, let’s assume we are saving the transaction to a database.
The Challenge: Integration tests are slow because of I/O. The Solution: Use Docker or SQLite in-memory databases for CI, but Docker is preferred for parity with production (MySQL/PostgreSQL).
Setting up the Database Interaction #
Let’s imagine a TransactionRepository that saves data via PDO.
<?php
namespace App\Repository;
use PDO;
class TransactionRepository
{
public function __construct(private PDO $pdo) {}
public function save(string $orderId, string $status): void
{
$stmt = $this->pdo->prepare("INSERT INTO transactions (order_id, status, created_at) VALUES (:oid, :status, NOW())");
$stmt->execute(['oid' => $orderId, 'status' => $status]);
}
public function find(string $orderId): ?array
{
$stmt = $this->pdo->prepare("SELECT * FROM transactions WHERE order_id = :oid");
$stmt->execute(['oid' => $orderId]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
}The Integration Test #
Here, we don’t mock the PDO connection. We want a real connection. However, we must ensure Test Isolation. One test should not leave data that breaks the next test.
Strategy: Wrap each test in a database transaction and roll it back at the end.
Create tests/Integration/TransactionRepositoryTest.php:
<?php
namespace Tests\Integration;
use App\Repository\TransactionRepository;
use PDO;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
class TransactionRepositoryTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
// In a real app, load connection details from env vars
// For this demo, we use SQLite in-memory which behaves similar to SQL
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Seed Schema (Migration)
$this->pdo->exec("CREATE TABLE transactions (order_id TEXT, status TEXT, created_at DATETIME)");
}
#[Test]
public function it_can_save_and_retrieve_a_transaction(): void
{
$repo = new TransactionRepository($this->pdo);
$orderId = 'ORD-12345';
// Act
$repo->save($orderId, 'approved');
// Assert
$result = $repo->find($orderId);
$this->assertIsArray($result);
$this->assertEquals('approved', $result['status']);
}
}Pro Tip for Real Databases:
If you are using MySQL in Docker, use the tearDown() method to truncate tables, or better yet, begin a transaction in setUp() and call $this->pdo->rollBack() in tearDown(). This is exponentially faster than dropping and recreating tables.
Part 3: End-to-End (E2E) Testing with Panther #
Unit tests pass. Database works. But does the “Buy Now” button actually work in the browser?
Symfony Panther is a standalone library that wraps the WebDriver protocol (Selenium) and works seamlessly with PHPUnit. It can run a real Chrome/Firefox browser (headless or visible).
The Scenario #
We want to load the homepage, find the search bar, type “PHP Plushie”, and verify the results page loads.
The E2E Test #
Create tests/E2E/SearchTest.php. Note that we extend PantherTestCase.
<?php
namespace Tests\E2E;
use Symfony\Component\Panther\PantherTestCase;
use PHPUnit\Framework\Attributes\Test;
class SearchTest extends PantherTestCase
{
#[Test]
public function it_allows_users_to_search_for_products(): void
{
// 1. Create the client (starts a web server and a browser)
// Ensure you have a local server running or configure Panther to point to it
$client = static::createPantherClient(['external_base_uri' => 'http://localhost:8000']);
// 2. Visit a page
$crawler = $client->request('GET', '/');
// 3. Interaction
// Wait for element to be visible (crucial for JS-heavy apps)
$client->waitFor('.search-input');
// Type into the search bar
$client->submitForm('Search', [
'q' => 'PHP Plushie',
]);
// 4. Assertions
$this->assertStringContainsString('Search Results', $client->getTitle());
// Assert that at least one product is found
$this->assertSelectorTextContains('.product-list', 'PHP Plushie');
// Take a screenshot for debugging if needed
$client->takeScreenshot('screen-evidence/search_result.png');
}
}Why Panther? Unlike older tools, Panther doesn’t require a separate Selenium server setup. It uses the PHP built-in web server and ChromeDriver directly. It brings the power of JavaScript execution (Vue/React/Alpine) testing to PHP.
Comparison: Choosing the Right Tool #
Understanding the trade-offs between these testing types is crucial for architectural decisions.
| Feature | Unit Tests | Integration Tests | E2E Tests (Panther) |
|---|---|---|---|
| Execution Speed | Extremely Fast (< 10ms) | Moderate (100ms - 2s) | Slow (5s - 30s+) |
| Scope | Single Class / Method | Module / Database / API | Full Application flow |
| Confidence | Low (Logic only) | Medium (Connections work) | High (System works) |
| Debugging Difficulty | Easy | Moderate | Hard (Flaky, async issues) |
| Cost to Maintain | Low | Medium | High (UI changes break tests) |
Best Practices & Common Pitfalls #
1. Avoid Global State #
Tests must be independent. If Test A modifies a static variable that Test B relies on, you will enter “flaky test hell.”
- Fix: Use dependency injection. Reset statics in
tearDown().
2. Don’t Test Private Methods #
Beginners often try to use Reflection to test private methods.
- Rule: If a private method logic is complex enough to need its own test, it probably belongs in a separate class as a public method. Test the public interface, not the implementation details.
3. CI/CD Integration #
Don’t just run tests locally. Here is a snippet for a .github/workflows/tests.yml:
name: PHP Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, intl, pdo_sqlite
coverage: pcov
- name: Install Dependencies
run: composer install --prefer-dist --no-progress
- name: Run Unit Tests
run: vendor/bin/phpunit --testsuite Unit
- name: Run Integration Tests
run: vendor/bin/phpunit --testsuite Integration4. Code Coverage vs. Quality #
100% code coverage is a vanity metric. You can have 100% coverage with zero assertions. Focus on Path Coverage (testing different if/else branches) rather than just line coverage.
Conclusion #
Testing in PHP has matured significantly. We moved from simple assertion scripts to sophisticated suites capable of driving Chrome browsers and managing database transactions.
By implementing the Pyramid Strategy—a solid base of Unit tests using strict Mocks, a supporting layer of Database Integration tests, and a few critical E2E flows—you ensure that your application is robust, deployable, and maintainable.
As we look toward 2026, the integration of AI-assisted test generation will rise, but the fundamental understanding of what to test and how to isolate dependencies remains the hallmark of a Senior PHP Developer.
Ready to refactor? Start by adding phpunit.xml configuration to segregate your suites today, and write that first E2E test for your login page. Your future self (at 3 AM on a Saturday) will thank you.