18

Rozdział 18 z 21

Testy jednostkowe

Opublikowany

Zabezpieczamy blog testami jednostkowymi w PHPUnit. Przetestujemy logikę encji, repozytoria na bazie testowej (KernelTestCase + DAMA, transakcja z rollbackiem) oraz serwisy z mockami.

Czego się nauczysz

  • Konfiguracja phpunit.dist.xml i baza testowa
  • Testy logiki encji (TestCase)
  • Testy repozytoriów (KernelTestCase + DAMA)
  • Testy serwisów z mockami (createMock)

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 — author ma nullable: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ąca null/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/Unit i konfiguracja phpunit.dist.xml + DAMA (transakcja z rollbackiem), make db-test,
  • ✅ testy encji (BlogArticle::incrementViews, isPublished) przez TestCase,
  • ✅ testy repozytorium (findPublishedQuery odsiewa szkice) przez KernelTestCase na bazie testowej,
  • ✅ testy serwisu (BlogSlugResolver) z mockami (createMock, willReturn),
  • ✅ działający stan: make tests_unit przechodzi 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).

Narzędzia / paczki

PHPUnit 13 KernelTestCase DAMA\DoctrineTestBundle

Spis treści