To część 1 serii o przygotowaniu notACMS do wydania jako open-source. Seria obejmuje testy, przegląd kodu, bezpieczeństwo, wzorzec lokalnych nadpisań, pipeline budowania, system motywów, architekturę wyszukiwania i listę kontrolną wydania. Oryginalna seria o migracji z WordPressa opisuje samą migrację.


Treści to pliki Markdown. Szablony to Twig. Style to SCSS. Nie ma bazy danych, nie ma API, nie ma kont użytkowników. Co tu właściwie testować?

Sporo. Logika parsująca frontmatter, budująca drzewo treści, obliczająca mapy tłumaczeń, generująca responsywne srcsety i rozwiązująca zlokalizowane trasy to czyste PHP — i jest podatne na błędy jak każdy inny kod. Większość statycznych generatorów stron ma zero testów. notACMS ma 368.

Dlaczego testować statyczną stronę?

Pliki z treścią nie potrzebują testów. Potrzebuje ich pipeline, który je przetwarza: ContentTreeBuilder skanujący katalogi, MarkdownParser wyciągający frontmatter, ContentService cache'ujący wyniki, TranslationMapBuilder obliczający mapy locale→URL, LocalizedRouteLoader generujący trasy z _routes.yaml. Wszystko to czysta logika bez zależności od bazy danych — idealna do testów jednostkowych.

Piramida testów dla aplikacji opartej na treściach wygląda inaczej niż dla typowej aplikacji webowej:

  • Testy jednostkoweContentItem, ContentTree, Value Objects. Bez mocków, bez kernela. Tablice frontmattera wchodzą, oczekiwane właściwości wychodzą.
  • Testy jednostkowe ze stubamiSiteConfigService, TagTranslationService, SrcsetExtension. Stuby interfejsów dla zależności, katalogi tymczasowe dla operacji na plikach.
  • Testy integracyjne — kontrolery, komendy, serwisy z uruchomionym kernelem Symfony. Testy smoke HTTP dla kluczowych tras.

Liczby

368 testów, 490 asercji, ~80% pokrycia linii, ~82% pokrycia metod. Trzy fazy:

Faza 1: Czyste testy jednostkowe (191 testów)ContentItem z isDraft, isScheduled, isPinned, readingTime, excerpt i dziesiątkami przypadków parsowania frontmattera. ContentTree filtrujący po kategorii, tagu, miesiącu archiwum, paginacji. Value Objects: AdjacentPosts, ArchiveMonth, CategoryCount, ParsedMarkdown, ParsedVariant, RenderResult, SidebarData, TagCount. Bez mocków, bez kernela, bez systemu plików.

Faza 2: Testy jednostkowe ze stubami interfejsów (49 testów)SiteConfigService czytający YAML z katalogów tymczasowych, TagTranslationService tłumaczący tagi między locale, SrcsetExtension generujący atrybuty srcset. createStub() dla interfejsów które nie potrzebują expects(), TmpDirTrait do sprzątania plików.

Faza 3: Testy integracyjne (127 testów)BlogController zwracający 200 dla poprawnych stron i 404 dla nieistniejących, ContactController obsługujący formularz, BuildStaticSiteCommand kończący się bez błędów, LocalizedRouteLoader generujący poprawne trasy. Kernel Symfony startuje, drzewo treści się buduje, klient HTTP wykonuje requesty.

Co warto testować

ContentItem::isScheduled() poprawnie wykrywający przyszłe daty — włącznie z przypadkiem granicznym gdzie data to dokładnie teraz (przedział prawostronnie otwarty: post jest już opublikowany na granicy). ContentTree::getPaginatedPosts() poprawnie dzielący na strony. LocalizedRouteLoader generujący trasy dla każdego locale. BuildStaticSiteCommand kończący się bez błędów.

Czego NIE warto testować: ścieżki wysyłania emaili w ContactController (wymaga mockowania mailera, testowania infrastruktury której nie posiadam), operacji kopiowania plików w BuildStaticSiteCommand (wymaga prawdziwego systemu plików z obrazkami), ErrorController::__invoke (trudne do wywołania przez klienta HTTP). Wysoki koszt uruchomienia, niski zwrot.

Konwencje testowe, które mają znaczenie

ContentItemFactory dla wszystkich fixture'ów — Nigdy nie konstruuj ContentItem bezpośrednio w testach. Fabryka dostarcza sensowne wartości domyślne i nazwane konstruktory:

$post = ContentItemFactory::publishedPost([
    'title' => 'Test Post',
    'tags'  => ['testing', 'php'],
], 'test-post', '/blog/test-post/');

Asercje XPath, nie selektory CSSsymfony/css-selector nie jest zainstalowany, więc $crawler->filter('.class') rzuca LogicException. Używaj filterXpath():

$meta = $crawler->filterXpath('//meta[@name="robots"]/@content');
self::assertGreaterThan(0, $meta->count());
self::assertStringContainsString('noindex', $meta->text());

createStub() vs createMock() — PHPUnit 13 wyświetla ostrzeżenia dla mocków bez expects(). Używaj createStub() w setUp() dla interfejsów gdzie potrzebujesz tylko wartości zwrotnej. Twórz createMock() lokalnie tylko w testach które weryfikują liczbę wywołań:

// setUp() — bez expects, użyj createStub()
$this->config = $this->createStub(SiteConfigServiceInterface::class);
$this->config->method('getPostsPerPage')->willReturn(10);

// Pojedynczy test — ma expects, użyj createMock()
$mock = $this->createMock(MarkdownParserInterface::class);
$mock->expects(self::once())->method('parse')->willReturn($parsed);

declare(strict_types=1) w każdym pliku testowym — Tak jak w kodzie produkcyjnym. Warunki Yody w asercjach. Pusta linia przed return.

Integracja z CI

ddev test uruchamia pełny zestaw z outputem testdox. GitHub Actions uruchamia suite Unit przy każdym pushu:

- name: PHPUnit
  run: vendor/bin/phpunit --testsuite Unit

Testy integracyjne potrzebują pełnego kernela i plików z treścią — działają lokalnie przez DDEV, ale Unit-only w CI to właściwy kompromis. Suite Unit łapie regresje w rdzeniu logiki; testy integracyjne łapią problemy z konfiguracją które pojawiają się tylko z pełnym stosem.

Efekt

Kiedy celnie zepsułem ContentItem::isDraft(), suite testowy złamał to natychmiast — 12 testów padło z jasnymi komunikatami o tym, która właściwość była niepoprawna. Przed suite'em testowym, taka regresja wyszłaby dopiero gdy ktoś zauważyłby draft na żywej stronie.

368 zielonych testów to pewność przy refaktoryzacji. Wyciąganie RelatedPostsService z ContentTree, uczynienie drzewa niemutowalnym, przenoszenie stałych do interfejsów — wszystko to działo się z siatką bezpieczeństwa testów które wyłapałyby cokolwiek co by się zepsuło.

W następnym poście opisuję co stało się dalej: audyt AI znalazł 79 problemów w kodzie który przeszedł już PHPStan, CS Fixer i ręczny przegląd. Testy łapią tylko to, o czym pomyślisz. Audyt łapie to, o czym zapomniałeś pomyśleć.