Unit testing in PHP projects is a topic most developers know in theory but keep putting off until “when there is time”. The time never comes. I show how to get started with PHPUnit, how to write tests that actually check something meaningful, and how to mock dependencies – with examples that look like real production code.
Installation and first test
composer require --dev phpunit/phpunit # Run tests vendor/bin/phpunit tests/ # With code coverage (requires Xdebug or PCOV) vendor/bin/phpunit --coverage-html coverage/ tests/
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
class PriceCalculatorTest extends TestCase
{
private PriceCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new PriceCalculator();
}
public function testCalculatesTotalWithTax(): void
{
$result = $this->calculator->calculateTotal(100.0, 0.23);
$this->assertEquals(123.0, $result);
}
public function testReturnsPriceWhenTaxIsZero(): void
{
$result = $this->calculator->calculateTotal(100.0, 0.0);
$this->assertEquals(100.0, $result);
}
public function testThrowsOnNegativePrice(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->calculator->calculateTotal(-10.0, 0.23);
}
}
Mocking dependencies
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
class OrderServiceTest extends TestCase
{
private MockObject $orderRepository;
private MockObject $emailNotifier;
private OrderService $service;
protected function setUp(): void
{
// createMock() generates a class that implements the interface
// but does nothing by default
$this->orderRepository = $this->createMock(OrderRepositoryInterface::class);
$this->emailNotifier = $this->createMock(EmailNotifierInterface::class);
$this->service = new OrderService(
$this->orderRepository,
$this->emailNotifier
);
}
public function testCancelsOrderAndSendsEmail(): void
{
$order = $this->createMock(OrderInterface::class);
$order->method('getId')->willReturn(42);
$order->method('getCustomerEmail')->willReturn('customer@example.com');
// Define what the mock should return
$this->orderRepository
->method('getById')
->with(42)
->willReturn($order);
// Assert the mock is called with specific arguments
$order->expects($this->once())
->method('setStatus')
->with('cancelled');
$this->orderRepository->expects($this->once())
->method('save')
->with($order);
$this->emailNotifier->expects($this->once())
->method('sendCancellationEmail')
->with('customer@example.com');
$this->service->cancelOrder(42);
}
}
Data providers – test multiple inputs
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
class PriceCalculatorTest extends TestCase
{
// Data provider - returns sets of [input, expected]
public static function priceProvider(): array
{
return [
'zero tax' => [100.0, 0.0, 100.0],
'23% VAT' => [100.0, 0.23, 123.0],
'8% reduced rate' => [100.0, 0.08, 108.0],
'small amount' => [0.01, 0.23, 0.0123],
];
}
#[DataProvider('priceProvider')]
public function testCalculatesCorrectly(float $price, float $tax, float $expected): void
{
$result = (new PriceCalculator())->calculateTotal($price, $tax);
$this->assertEqualsWithDelta($expected, $result, 0.001);
}
}
Testing in Magento 2 – integration tests
<?php
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
class ProductRepositoryTest extends TestCase
{
private \Magento\Catalog\Api\ProductRepositoryInterface $repository;
protected function setUp(): void
{
// Bootstrap Magento Object Manager in tests
$objectManager = Bootstrap::getObjectManager();
$this->repository = $objectManager->get(
\Magento\Catalog\Api\ProductRepositoryInterface::class
);
}
/**
* @magentoDataFixture Magento/Catalog/_files/product_simple.php
*/
public function testGetBySkuReturnsProduct(): void
{
$product = $this->repository->get('simple');
$this->assertEquals('simple', $product->getSku());
$this->assertEquals(1, $product->getStatus());
}
}
# Run Magento integration tests
cd dev/tests/integration
../../../vendor/bin/phpunit --config phpunit.xml \
testsuite/Magento/Catalog/Model/ProductRepositoryTest.php
phpunit.xml – configuration
<?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"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>
Summary
Unit tests pay off even in small projects – you catch regressions in minutes instead of after a client call. The key is to start simple: test pure functions and service classes that have a clear, mockable dependency interface. In Magento 2 the Service Contracts architecture with Repository interfaces makes this straightforward – mock the repository, test the service logic.
