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 klassfTestFunctional. 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:
limenie miał prawdziwych asercji obiektowych — pisało się$t->is(...). PHPUnit daje bogaty zestaw:assertTrue,assertSame,assertGreaterThanOrEqualitd., 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ą
@dataProviderw 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 zwracanull/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 poleceniephpunit(a wygodniemake tests_unit), z filtrowaniem po nazwie i raportem--testdox.
Podsumowanie
Jobeet ma pierwszą siatkę bezpieczeństwa:
- ✅ struktura
tests/Unit(izolacja) itests/Functional(Dzień 16), konfiguracjaphpunit.dist.xml, - ✅ testy logiki encji przez
TestCasei 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.