PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

PHPUnit – testy jednostkowe, mocki, data providers, testy w Magento 2

by Henryk Tews / wtorek, 12 stycznia 2021 / Opublikowano w PHP

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.

About Henryk Tews

Co możesz przeczytać następne

PHP 8.1 w praktyce – enumy po miesiącach, serializacja do bazy, Money value object
PHP Enumeracje zaawansowane – backed enums, metody, interfejsy, Magento 2
PHP 7.2 – object type hint, sodium zamiast mcrypt, deprecacje
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}