15

Dzień 15 z 18

Testy jednostkowe

Opublikowany

Dobry kod to przetestowany kod. Napiszemy pierwsze testy Jobeet w PHPUnit — od czystej logiki encji (TestCase), przez testy serwisu z mockami, aż po testy repozytorium na prawdziwej bazie testowej (KernelTestCase + DAMA). Nauczymy się je uruchamiać i czytać wynik.

Czego się nauczysz

  • Rodzaje testów: jednostkowe, integracyjne, funkcjonalne
  • Konfiguracja phpunit.dist.xml i baza testowa (make db-test)
  • Testowanie logiki encji przez TestCase i asercje PHPUnit
  • Data providery przez atrybut #[DataProvider]
  • Testowanie serwisu z mockami (createMock, willReturn, expects)
  • Testy repozytorium na bazie testowej (KernelTestCase + DAMA)
  • Uruchamianie testów: make tests_unit, --filter, --testdox

Co zmieniło się w Symfony 8 vs 4.2

  • PHPUnit 13 zamiast frameworka lime z symfony 1.x
  • Atrybuty #[DataProvider] zamiast adnotacji @dataProvider
  • KernelTestCase / WebTestCase zamiast sfTestFunctional
  • Izolacja bazy przez DAMA\\DoctrineTestBundle (transakcja z rollbackiem)

Dzień 15: Testy jednostkowe

Dobry kod to przetestowany kod. Dziś napiszemy pierwsze testy Jobeet w PHPUnit — od czystych testów logiki encji, przez testy serwisu z mockami, aż po testy repozytorium na prawdziwej bazie testowej. Nauczymy się je uruchamiać i czytać ich wynik.

Zmiana vs Symfony 4.2: oryginał używał frameworka lime (własnego dla symfony 1.x) i klas sfTestFunctional. Dziś standardem jest PHPUnit (tu wersja 13) z atrybutami (#[Test], #[DataProvider]) zamiast adnotacji, oraz bazowe klasy Symfony (KernelTestCase, WebTestCase). Izolację testów bazodanowych daje DAMA\DoctrineTestBundle (każdy test w transakcji z rollbackiem).


Rodzaje testów

W tym projekcie trzymamy testy w katalogu tests/, z podziałem odzwierciedlającym ich zasięg:

tests/
├── Unit/          # czysta logika PHP — bez kontenera, bez bazy (szybkie)
│   ├── Entity/
│   └── Service/
└── Functional/    # request → response, prawdziwa aplikacja (Dzień 16)
    └── Controller/
  • Test jednostkowy sprawdza jedną klasę w izolacji. Zależności podmieniamy mockami, nie dotykamy bazy — dzięki temu jest błyskawiczny.
  • Test integracyjny uruchamia część kontenera Symfony (KernelTestCase) i może korzystać z prawdziwej bazy testowej — np. do testów repozytoriów.
  • Test funkcjonalny (Dzień 16) symuluje żądanie HTTP przez WebTestCase.

Konfiguracja

Pakiet testowy dostajemy z symfony/test-pack. Konfiguracja jest w phpunit.dist.xml:

<phpunit bootstrap="tests/bootstrap.php" cacheDirectory=".phpunit.cache"
         failOnDeprecation="true" failOnNotice="true" failOnWarning="true">
    <php>
        <server name="APP_ENV" value="test" force="true" />
    </php>
    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <extensions>
        <bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
    </extensions>
</phpunit>

failOnDeprecation/Notice/Warning sprawiają, że każde ostrzeżenie wywala test — to celowe, pilnuje jakości. Rozszerzenie DAMA owija każdy test w transakcję bazodanową i cofa ją po zakończeniu.

Przed pierwszym uruchomieniem utwórz i zmigruj bazę testową:

make db-test

Testowanie encji (TestCase)

Zacznijmy od najprostszego — czystej logiki. W Dniu 8 w szablonie mieliśmy inline'owe wyrażenie job.expiresAt < date(). Logika biznesowa nie powinna mieszkać w szablonie — przenieśmy ją do encji Job, gdzie da się ją przetestować:

// src/Entity/Job.php
public function isExpired(): bool
{
    return $this->expiresAt < new \DateTimeImmutable();
}

public function getDaysBeforeExpires(): int
{
    return (int) (new \DateTimeImmutable())->diff($this->expiresAt)->format('%r%a');
}

public function isExpiringSoon(int $days = 5): bool
{
    return !$this->isExpired() && $this->getDaysBeforeExpires() < $days;
}

Teraz test. Klasy bez zależności testujemy przez PHPUnit\Framework\TestCase — tests/Unit/Entity/JobTest.php:

<?php

namespace App\Tests\Unit\Entity;

use App\Entity\Job;
use PHPUnit\Framework\TestCase;

final class JobTest extends TestCase
{
    public function testJobExpiringInFutureIsNotExpired(): void
    {
        $job = new Job();
        $job->setExpiresAt(new \DateTimeImmutable('+10 days'));

        $this->assertFalse($job->isExpired());
    }

    public function testJobExpiringInPastIsExpired(): void
    {
        $job = new Job();
        $job->setExpiresAt(new \DateTimeImmutable('-1 day'));

        $this->assertTrue($job->isExpired());
    }

    public function testDaysBeforeExpiresIsPositiveForFutureDate(): void
    {
        $job = new Job();
        $job->setExpiresAt(new \DateTimeImmutable('+7 days'));

        // dopuszczamy 6 lub 7 zależnie od pory dnia
        $this->assertGreaterThanOrEqual(6, $job->getDaysBeforeExpires());
    }
}

Uruchom pojedynczy plik:

docker compose exec app php bin/phpunit tests/Unit/Entity/JobTest.php --testdox

Zmiana vs Symfony 4.2: lime nie miał prawdziwych asercji obiektowych — pisało się $t->is(...). PHPUnit daje bogaty zestaw: assertTrue, assertSame, assertGreaterThanOrEqual itd., z czytelnymi komunikatami błędów.


Data providers — jeden test, wiele przypadków

Zamiast pisać osobną metodę dla każdej daty, użyjemy data providera. W PHPUnit 13 wskazujemy go atrybutem #[DataProvider]:

use PHPUnit\Framework\Attributes\DataProvider;

#[DataProvider('expiryProvider')]
public function testIsExpired(string $modifier, bool $expected): void
{
    $job = new Job();
    $job->setExpiresAt(new \DateTimeImmutable($modifier));

    $this->assertSame($expected, $job->isExpired());
}

/** @return iterable<string, array{string, bool}> */
public static function expiryProvider(): iterable
{
    yield 'wygasa jutro'     => ['+1 day', false];
    yield 'wygasa za miesiąc' => ['+30 days', false];
    yield 'wygasła wczoraj'  => ['-1 day', true];
    yield 'wygasła dawno'    => ['-1 year', true];
}

Każdy yield to osobny przypadek testowy z własną nazwą — w raporcie --testdox zobaczysz je oddzielnie.

Zmiana vs Symfony 4.2: data providery w starym PHPUnit deklarowało się adnotacją @dataProvider w docblocku. Od PHPUnit 10 używa się atrybutu #[DataProvider('nazwaMetody')], a metoda dostarczająca dane musi być static.


Testowanie serwisu z mockami

Serwis ma zależności (repozytorium, mailer, generator URL). W teście jednostkowym nie chcemy prawdziwej bazy ani wysyłać maili — podmieniamy je mockami (createMock). Załóżmy prosty serwis aktywujący partnera z Dnia 12:

// src/Service/AffiliateActivator.php
final readonly class AffiliateActivator
{
    public function __construct(
        private AffiliateRepository $affiliates,
        private EntityManagerInterface $em,
    ) {}

    public function activate(string $token): bool
    {
        $affiliate = $this->affiliates->findOneByToken($token);
        if (null === $affiliate) {
            return false;
        }

        $affiliate->setActive(true);
        $this->em->flush();

        return true;
    }
}

Test — tests/Unit/Service/AffiliateActivatorTest.php:

<?php

namespace App\Tests\Unit\Service;

use App\Entity\Affiliate;
use App\Repository\AffiliateRepository;
use App\Service\AffiliateActivator;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;

final class AffiliateActivatorTest extends TestCase
{
    public function testActivateReturnsFalseForUnknownToken(): void
    {
        $repo = $this->createMock(AffiliateRepository::class);
        $repo->method('findOneByToken')->willReturn(null);

        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects($this->never())->method('flush');

        $activator = new AffiliateActivator($repo, $em);

        $this->assertFalse($activator->activate('nieznany-token'));
    }

    public function testActivateSetsActiveAndFlushes(): void
    {
        $affiliate = new Affiliate();
        $affiliate->setEmail('partner@example.com');

        $repo = $this->createMock(AffiliateRepository::class);
        $repo->method('findOneByToken')->willReturn($affiliate);

        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects($this->once())->method('flush');

        $activator = new AffiliateActivator($repo, $em);

        $this->assertTrue($activator->activate('poprawny-token'));
        $this->assertTrue($affiliate->isActive());
    }
}

Kluczowe idee:

  • createMock(Klasa::class) tworzy atrapę, która zwraca null/puste wartości, dopóki nie ustawimy zachowania metodą ->method(...)->willReturn(...).
  • ->expects($this->once()) / ->never() weryfikują, że metoda została (lub nie) wywołana — to test zachowania, nie tylko wyniku.

Testy integracyjne z bazą (KernelTestCase)

Repozytorium z prawdziwym zapytaniem SQL najlepiej sprawdzić na prawdziwej bazie testowej. Rozszerzamy KernelTestCase, który uruchamia kontener Symfony i daje dostęp do serwisów. Dzięki DAMA każdy test działa w transakcji cofanej na końcu — baza zostaje czysta.

tests/Unit/Repository/JobRepositoryTest.php:

<?php

namespace App\Tests\Unit\Repository;

use App\Entity\Category;
use App\Entity\Job;
use App\Repository\JobRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class JobRepositoryTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private JobRepository $repository;

    protected function setUp(): void
    {
        self::bootKernel();
        $container = static::getContainer();

        $this->em = $container->get(EntityManagerInterface::class);
        $this->repository = $container->get(JobRepository::class);
    }

    public function testFindActiveJobsExcludesExpiredOnes(): void
    {
        $category = new Category();
        $category->setName('Testowa ' . uniqid());

        $active = $this->makeJob($category, new \DateTimeImmutable('+30 days'));
        $expired = $this->makeJob($category, new \DateTimeImmutable('-1 day'));

        $this->em->persist($category);
        $this->em->flush();

        $results = $this->repository->findActiveJobs();

        $this->assertContains($active, $results);
        $this->assertNotContains($expired, $results);
    }

    private function makeJob(Category $category, \DateTimeImmutable $expiresAt): Job
    {
        $job = new Job();
        $job->setCategory($category)
            ->setCompany('ACME')
            ->setPosition('Programista PHP')
            ->setLocation('Wrocław')
            ->setDescription('Opis')
            ->setEmail('praca@example.com')
            ->setActivated(true)
            ->setExpiresAt($expiresAt);

        $this->em->persist($job);

        return $job;
    }
}

Uwaga: dzięki DAMA nie musimy sami czyścić bazy — transakcja jest cofana po każdym teście. Do pobierania serwisów w teście używamy static::getContainer() (specjalny kontener testowy, w którym nawet prywatne serwisy są dostępne).


Uruchamianie testów

# wszystkie testy z czytelnym raportem
make tests_unit                                   # = php bin/phpunit --testdox

# pojedynczy plik
docker compose exec app php bin/phpunit tests/Unit/Entity/JobTest.php

# pojedynczy test po nazwie
docker compose exec app php bin/phpunit --filter testJobExpiringInPastIsExpired

Testy są też częścią bramki jakości make check (obok PHPStan, PHP-CS-Fixer i typecheck) — CI nie przepuści kodu z czerwonymi testami.

Zmiana vs Symfony 4.2: w symfony 1.x testy uruchamiał php symfony test:unit / test:functional. Dziś to jedno polecenie phpunit (a wygodnie make tests_unit), z filtrowaniem po nazwie i raportem --testdox.


Podsumowanie

Jobeet ma pierwszą siatkę bezpieczeństwa:

  • ✅ struktura tests/Unit (izolacja) i tests/Functional (Dzień 16), konfiguracja phpunit.dist.xml,
  • ✅ testy logiki encji przez TestCase i asercje PHPUnit,
  • ✅ data providery przez atrybut #[DataProvider] (jeden test, wiele przypadków),
  • ✅ testy serwisu z mockami (createMock, willReturn, expects),
  • ✅ testy repozytorium na bazie testowej przez KernelTestCase + DAMA (transakcja z rollbackiem),
  • ✅ uruchamianie: make tests_unit, --filter, --testdox.

W Dniu 16 napiszemy testy funkcjonalne — WebTestCase, symulacja żądań HTTP, klikanie i wypełnianie formularzy oraz testowanie odpowiedzi API.

Narzędzia / paczki

PHPUnit 13 symfony/test-pack DAMA\DoctrineTestBundle

Spis treści