Rozdział 18: Testy jednostkowe
Zaczynamy Część V — Jakość i wdrożenie. Dobry kod to przetestowany kod. Napiszemy testy jednostkowe w PHPUnit: logikę encji, repozytoria na bazie testowej oraz serwis z mockami.
Stan wejściowy: gotowy moduł bloga (Części I–IV). Tu dokładamy testy — nie zmieniamy kodu aplikacji.
Rodzaje i konfiguracja
Testy trzymamy w katalogu tests/, z podziałem odzwierciedlającym ich zasięg:
tests/
├── Unit/ # czysta logika + integracja z bazą testową (ten rozdział)
│ ├── Entity/
│ ├── Repository/
│ └── Service/
└── Functional/ # request → response (Rozdział 19)
Konfiguracja jest w phpunit.dist.xml. Ważne: każdy warning wywala test (pilnuje jakości), a rozszerzenie
DAMA\DoctrineTestBundle owija każdy test w transakcję cofaną po zakończeniu — baza zostaje czysta:
<phpunit bootstrap="tests/bootstrap.php" failOnDeprecation="true" failOnNotice="true" failOnWarning="true">
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
</phpunit>
Przed pierwszym uruchomieniem utwórz bazę testową:
make db-test
Testy logiki encji
Klasy bez zależności testujemy przez PHPUnit\Framework\TestCase — bez kontenera i bez bazy, więc
błyskawicznie. Sprawdźmy prostą logikę BlogArticle:
// tests/Unit/Entity/BlogArticleTest.php
namespace App\Tests\Unit\Entity;
use App\Entity\BlogArticle;
use PHPUnit\Framework\TestCase;
final class BlogArticleTest extends TestCase
{
public function testIncrementViewsRaisesCounter(): void
{
$article = new BlogArticle();
$this->assertSame(0, $article->getViewsCount());
$article->incrementViews()->incrementViews();
$this->assertSame(2, $article->getViewsCount());
}
public function testIsPublishedReflectsStatus(): void
{
$article = new BlogArticle();
$article->setStatus(BlogArticle::STATUS_DRAFT);
$this->assertFalse($article->isPublished());
$article->setStatus(BlogArticle::STATUS_PUBLISHED);
$this->assertTrue($article->isPublished());
}
}
Uruchom pojedynczy plik:
docker compose exec app php bin/phpunit tests/Unit/Entity/BlogArticleTest.php --testdox
Testy repozytorium
Repozytorium z prawdziwym zapytaniem SQL sprawdzamy na bazie testowej. Rozszerzamy KernelTestCase,
który uruchamia kontener Symfony; dzięki DAMA każdy test działa w transakcji cofanej na końcu:
// tests/Unit/Repository/BlogArticleRepositoryTest.php
namespace App\Tests\Unit\Repository;
use App\Entity\BlogArticle;
use App\Entity\User;
use App\Repository\BlogArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class BlogArticleRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $em;
private BlogArticleRepository $repository;
protected function setUp(): void
{
self::bootKernel();
$c = static::getContainer();
$this->em = $c->get(EntityManagerInterface::class);
$this->repository = $c->get(BlogArticleRepository::class);
}
public function testFindPublishedQueryExcludesDrafts(): void
{
$author = $this->makeUser();
$published = $this->makeArticle($author, BlogArticle::STATUS_PUBLISHED);
$draft = $this->makeArticle($author, BlogArticle::STATUS_DRAFT);
$this->em->flush();
$result = $this->repository->findPublishedQuery()->getResult();
$this->assertContains($published, $result);
$this->assertNotContains($draft, $result);
}
}
static::getContainer()— specjalny kontener testowy, w którym dostępne są nawet prywatne serwisy.- Nie sprzątamy bazy — transakcja DAMA cofa się po każdym teście.
- Metody
makeUser()/makeArticle()tworzą dane testowe (autor jest wymagany —authormanullable:false).
Testy serwisu z mockami
Serwis ma zależności (repozytoria). W teście jednostkowym nie chcemy prawdziwej bazy — podmieniamy je
mockami (createMock). Sprawdźmy BlogSlugResolver z Rozdziału 14 — czy poprawnie rozpoznaje oryginał
i tłumaczenie:
// tests/Unit/Service/BlogSlugResolverTest.php
namespace App\Tests\Unit\Service;
use App\Entity\BlogArticle;
use App\Repository\BlogArticleRepository;
use App\Repository\BlogArticleTranslationRepository;
use App\Service\BlogSlugResolver;
use PHPUnit\Framework\TestCase;
final class BlogSlugResolverTest extends TestCase
{
public function testResolveReturnsPolishOriginalWhenSlugMatchesArticle(): void
{
$article = new BlogArticle();
$articles = $this->createMock(BlogArticleRepository::class);
$articles->method('findOneBy')->willReturn($article);
$resolver = new BlogSlugResolver(
$articles,
$this->createMock(BlogArticleTranslationRepository::class),
/* … pozostałe repozytoria (mocki) … */
);
$result = $resolver->resolve('moj-artykul');
$this->assertSame($article, $result['article']);
$this->assertNull($result['translation']);
$this->assertSame('pl', $result['locale']);
}
public function testResolveReturnsNullForUnknownSlug(): void
{
$articles = $this->createMock(BlogArticleRepository::class);
$articles->method('findOneBy')->willReturn(null);
$translations = $this->createMock(BlogArticleTranslationRepository::class);
$translations->method('findOneBySlug')->willReturn(null);
$resolver = new BlogSlugResolver($articles, $translations, /* … */);
$this->assertNull($resolver->resolve('nie-istnieje'));
}
}
createMock(Klasa::class)— atrapa zwracającanull/puste wartości, dopóki nie ustawimy zachowania metodą->method(...)->willReturn(...).- Testujemy logikę serwisu, nie bazę — szybko i w izolacji.
Uruchamianie
make tests_unit # wszystkie testy (--testdox)
docker compose exec app php bin/phpunit tests/Unit # tylko jednostkowe
docker compose exec app php bin/phpunit --filter testIsPublishedReflectsStatus
Testy są częścią bramki make check (obok PHPStan, PHP-CS-Fixer i typecheck) — CI nie przepuści kodu
z czerwonymi testami.
Podsumowanie
Blog ma pierwszą siatkę bezpieczeństwa:
- ✅ struktura
tests/Uniti konfiguracjaphpunit.dist.xml+ DAMA (transakcja z rollbackiem),make db-test, - ✅ testy encji (
BlogArticle::incrementViews,isPublished) przezTestCase, - ✅ testy repozytorium (
findPublishedQueryodsiewa szkice) przezKernelTestCasena bazie testowej, - ✅ testy serwisu (
BlogSlugResolver) z mockami (createMock,willReturn), - ✅ działający stan:
make tests_unitprzechodzi na zielono, logika bloga jest zabezpieczona testami.
W Rozdziale 19 napiszemy testy funkcjonalne — WebTestCase, symulacja żądań HTTP, formularze
i autoryzacja (jak w istniejących testach modułu blogowego).