Testing in PHP: Unit, Integration, E2E at an Advanced Level
In modern PHP web development, code quality is no longer an option — it is a mandatory condition for project survival. Basic unit tests are familiar to every developer, but once a project outgrows the "Hello World" framework, the need arises for more complex approaches: integration tests, checking component interaction, and end-to-end testing of user scenarios (E2E).
This article is intended for PHP developers at the Middle level and above who are already familiar with PHPUnit and want to deepen their knowledge. We will cover advanced techniques: testing with external dependencies, working with test containers, infrastructure isolation, and building a comprehensive testing strategy.
1. Advanced Unit Testing: Mock Objects and Stubs
At an advanced level, unit tests are no longer just about checking mathematical functions. They test business logic isolated from the outside world. The key tools are Mock and Stub.
A Stub returns predefined data. A Mock verifies exactly how methods were called (call count, argument order).
Consider an example: a payment service that sends a request to a payment gateway.
<?phpclass PaymentService { public function __construct( private PaymentGatewayInterface $gateway, private LoggerInterface $logger ) {}
public function processPayment(Order $order): bool { $result = $this->gateway->charge( $order->getAmount(), $order->getCurrency() );
if ($result->isSuccess()) { $this->logger->info('Payment processed', ['order_id' => $order->getId()]); return true; }
$this->logger->warning('Payment failed', ['error' => $result->getError()]); return false; }}
// Test using PHPUnitclass PaymentServiceTest extends TestCase { public function testSuccessfulPaymentLogsInfo(): void { // Create a Stub for the gateway $gatewayStub = $this->createStub(PaymentGatewayInterface::class); $gatewayStub->method('charge') ->willReturn(new PaymentResult(true));
// Create a Mock for the logger (verify the call) $loggerMock = $this->createMock(LoggerInterface::class); $loggerMock->expects($this->once()) ->method('info') ->with('Payment processed', $this->arrayHasKey('order_id'));
$service = new PaymentService($gatewayStub, $loggerMock); $result = $service->processPayment(new Order(100, 'USD'));
$this->assertTrue($result); }}Advanced technique: use willReturnCallback() for dynamic responses and withConsecutive() for verifying call sequences.
2. Integration Testing: Database and External APIs
Integration tests verify how your code interacts with real components: database, file system, cache. You cannot rely on mocks here — real infrastructure is needed.
Problem: tests become slow and fragile. Solution: use test containers (Testcontainers) or embedded SQLite databases.
Example of an integration test for a user repository with Doctrine:
<?phpclass UserRepositoryIntegrationTest extends KernelTestCase { private EntityManagerInterface $entityManager; private UserRepository $repository;
protected function setUp(): void { self::bootKernel(); $this->entityManager = self::$kernel->getContainer() ->get('doctrine