Testy jednostkowe w projektach PHP to temat który większość developerów zna teoretycznie, ale w praktyce odkłada na „kiedy będzie czas”. A czas nigdy nie przychodzi. Pokazuję jak zacząć z PHPUnit, jak pisać testy które rzeczywiście coś sprawdzają i jak mockować zależności – na przykładach z kodu który realnie wygląda jak kod produkcyjny.
Instalacja i pierwszy test
composer require --dev phpunit/phpunit ^10 vendor/bin/phpunit --version
Konfiguracja w phpunit.xml:
<?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">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
Testowana klasa – kalkulator rabatów
Najpierw klasa którą będziemy testować:
<?php
declare(strict_types=1);
namespace App\Pricing;
class DiscountCalculator
{
public function calculate(float $price, float $discountPercent): float
{
if ($price < 0) {
throw new \InvalidArgumentException('Price cannot be negative');
}
if ($discountPercent < 0 || $discountPercent > 100) {
throw new \InvalidArgumentException('Discount must be between 0 and 100');
}
return round($price * (1 - $discountPercent / 100), 2);
}
public function calculateBulk(array $items, float $discountPercent): array
{
return array_map(
fn(float $price): float => $this->calculate($price, $discountPercent),
$items
);
}
}
Podstawowe testy – asercje i przypadki brzegowe
<?php
declare(strict_types=1);
namespace Tests\Unit\Pricing;
use App\Pricing\DiscountCalculator;
use PHPUnit\Framework\TestCase;
class DiscountCalculatorTest extends TestCase
{
private DiscountCalculator $calculator;
protected function setUp(): void
{
// setUp() uruchamia się przed każdym testem
$this->calculator = new DiscountCalculator();
}
public function testCalculatesDiscountCorrectly(): void
{
$result = $this->calculator->calculate(100.0, 20.0);
$this->assertSame(80.0, $result);
}
public function testZeroDiscountReturnsOriginalPrice(): void
{
$result = $this->calculator->calculate(99.99, 0.0);
$this->assertSame(99.99, $result);
}
public function testHundredPercentDiscountReturnsZero(): void
{
$result = $this->calculator->calculate(50.0, 100.0);
$this->assertSame(0.0, $result);
}
public function testRoundsToTwoDecimalPlaces(): void
{
$result = $this->calculator->calculate(10.0, 33.33);
$this->assertSame(6.67, $result);
}
public function testThrowsExceptionForNegativePrice(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Price cannot be negative');
$this->calculator->calculate(-10.0, 20.0);
}
public function testThrowsExceptionForDiscountAbove100(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->calculator->calculate(100.0, 101.0);
}
public function testCalculatesBulkDiscount(): void
{
$result = $this->calculator->calculateBulk([100.0, 50.0, 25.0], 10.0);
$this->assertSame([90.0, 45.0, 22.5], $result);
}
}
Data Providers – jeden test, wiele przypadków
Zamiast kopiować test dla każdej kombinacji danych, używaj Data Providers:
<?php
class DiscountCalculatorTest extends TestCase
{
// ...
/**
* @dataProvider discountProvider
*/
public function testVariousDiscounts(
float $price,
float $discount,
float $expected
): void {
$result = $this->calculator->calculate($price, $discount);
$this->assertSame($expected, $result);
}
public static function discountProvider(): array
{
return [
'zero discount' => [100.0, 0.0, 100.0],
'10% discount' => [100.0, 10.0, 90.0],
'50% discount' => [200.0, 50.0, 100.0],
'full discount' => [99.99, 100.0, 0.0],
'fractional price' => [19.99, 15.0, 16.99],
];
}
}
Mocki – testowanie z zależnościami
Klasa z zależnościami zewnętrznymi (repository, logger, API) wymaga mocków – symulowanych obiektów które zastępują realne zależności w teście:
<?php
declare(strict_types=1);
namespace App\Order;
use App\Pricing\DiscountCalculator;
use App\Repository\OrderRepositoryInterface;
use Psr\Log\LoggerInterface;
class OrderDiscountService
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private DiscountCalculator $calculator,
private LoggerInterface $logger
) {}
public function applyDiscount(int $orderId, float $discountPercent): float
{
$order = $this->orderRepository->getById($orderId);
$originalTotal = $order->getGrandTotal();
$newTotal = $this->calculator->calculate($originalTotal, $discountPercent);
$this->logger->info('Discount applied', [
'order_id' => $orderId,
'original' => $originalTotal,
'new' => $newTotal,
]);
return $newTotal;
}
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Order;
use App\Order\OrderDiscountService;
use App\Pricing\DiscountCalculator;
use App\Repository\OrderRepositoryInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class OrderDiscountServiceTest extends TestCase
{
public function testAppliesDiscountToOrderTotal(): void
{
// Tworzymy mock zamówienia
$order = $this->createMock(\App\Model\OrderInterface::class);
$order->method('getGrandTotal')->willReturn(200.0);
// Mock repozytorium - getById(42) zwraca nasz mock zamówienia
$repository = $this->createMock(OrderRepositoryInterface::class);
$repository
->expects($this->once()) // sprawdzamy że metoda wywołana dokładnie raz
->method('getById')
->with(42) // sprawdzamy argument
->willReturn($order);
// Mock loggera - sprawdzamy że loguje z poprawnymi danymi
$logger = $this->createMock(LoggerInterface::class);
$logger
->expects($this->once())
->method('info')
->with(
'Discount applied',
$this->arrayHasKey('order_id')
);
$service = new OrderDiscountService($repository, new DiscountCalculator(), $logger);
$result = $service->applyDiscount(42, 25.0);
$this->assertSame(150.0, $result);
}
public function testPropagatesRepositoryException(): void
{
$repository = $this->createMock(OrderRepositoryInterface::class);
$repository
->method('getById')
->willThrowException(new \RuntimeException('Order not found'));
$service = new OrderDiscountService(
$repository,
new DiscountCalculator(),
$this->createMock(LoggerInterface::class)
);
$this->expectException(\RuntimeException::class);
$service->applyDiscount(999, 10.0);
}
}
Uruchamianie testów
# Wszystkie testy vendor/bin/phpunit # Z raportem pokrycia kodu (wymaga Xdebug lub pcov) vendor/bin/phpunit --coverage-html coverage/ # Tylko jeden plik vendor/bin/phpunit tests/Unit/Pricing/DiscountCalculatorTest.php # Filtrowanie po nazwie testu vendor/bin/phpunit --filter testCalculatesDiscountCorrectly
Testy w Magento 2
Magento 2 ma własną infrastrukturę testową opartą na PHPUnit, podzieloną na trzy typy: Unit (izolowane, szybkie, bez bootstrapu Magento), Integration (z kontenerem DI Magento, wolniejsze) i Functional (pełny stack). Testy jednostkowe modułów Magento umieszczasz w Test/Unit/ wewnątrz modułu i uruchamiasz przez:
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/Vendor/Module/Test/Unit/
Podsumowanie
Dobre testy jednostkowe to testy które są szybkie, izolowane i testują jedną rzecz. Mock to nie zło – to narzędzie które pozwala testować klasę bez uruchamiania całej infrastruktury. Data Providers eliminują kopiowanie kodu testowego. Zacznij od prostych klas bez zależności, dorzuć mocki gdy już masz nawyk pisania testów – nie odwrotnie.
