368 Tests for a Static Site Generator — Why Bother?
testing,phpunit,php,symfony,static-siteThis is part 1 of a series on preparing notACMS for open-source release. The series covers testing, code review, security, the local override pattern, the build pipeline, theme system, search architecture, and the release checklist. The original WordPress to Symfony series covers the migration itself.
The content is Markdown files. The templates are Twig. The styles are SCSS. There is no database, no API, no user accounts. What exactly is there to test?
Quite a lot, as it turns out. The logic that parses frontmatter, builds the content tree, computes translation maps, generates responsive srcsets, and resolves localized routes is pure PHP — and it's as bug-prone as any other codebase. Most static site generators have zero tests. notACMS has 368.
Why Test a Static Site?
The content files don't need testing. The pipeline that processes them does: ContentTreeBuilder scanning directories, MarkdownParser extracting frontmatter, ContentService caching results, TranslationMapBuilder computing locale-to-URL maps, LocalizedRouteLoader generating routes from _routes.yaml. All of this is pure logic with no database dependency — ideal for unit tests.
The test pyramid for a content-driven application looks different from a typical web app:
- Unit tests —
ContentItem,ContentTree, Value Objects. No mocks, no kernel. Frontmatter arrays go in, expected properties come out. - Unit tests with stubs —
SiteConfigService,TagTranslationService,SrcsetExtension. Interface stubs for dependencies, temp directories for filesystem operations. - Integration tests — controllers, commands, services with booted Symfony kernel. HTTP smoke tests for key routes.
The Numbers
368 tests, 490 assertions, ~80% line coverage, ~82% method coverage. Three phases:
Phase 1: Pure unit tests (191 tests) — ContentItem with its isDraft, isScheduled, isPinned, readingTime, excerpt, and dozens of frontmatter parsing cases. ContentTree filtering by category, tag, archive month, pagination. Value Objects: AdjacentPosts, ArchiveMonth, CategoryCount, ParsedMarkdown, ParsedVariant, RenderResult, SidebarData, TagCount. No mocks, no kernel, no filesystem.
Phase 2: Unit tests with interface stubs (49 tests) — SiteConfigService reading YAML from temp directories, TagTranslationService translating tags between locales, SrcsetExtension generating srcset attributes. createStub() for interfaces that don't need expects(), TmpDirTrait for filesystem cleanup.
Phase 3: Integration tests (127 tests) — BlogController returning 200 for valid pages and 404 for nonexistent ones, ContactController handling form submissions, BuildStaticSiteCommand completing without errors, LocalizedRouteLoader generating the right routes. The Symfony kernel boots, the content tree builds, the HTTP client makes requests.
What's Worth Testing
ContentItem::isScheduled() detecting future dates correctly — including the boundary case where the date is exactly now (right-open interval: the post is already published at the boundary). ContentTree::getPaginatedPosts() slicing correctly across pages. LocalizedRouteLoader generating routes for every locale. BuildStaticSiteCommand completing without errors.
What's NOT worth testing: the email sending path in ContactController (requires mocking the mailer, testing infrastructure I don't own), media file copy operations in BuildStaticSiteCommand (requires real filesystem with images), ErrorController::__invoke (hard to trigger via HTTP client). High complexity to trigger, low ROI.
Testing Conventions That Matter
ContentItemFactory for all fixtures — Never construct ContentItem directly in tests. The factory provides sensible defaults and named constructors:
$post = ContentItemFactory::publishedPost([
'title' => 'Test Post',
'tags' => ['testing', 'php'],
], 'test-post', '/blog/test-post/');
XPath assertions, not CSS selectors — symfony/css-selector is not installed, so $crawler->filter('.class') throws LogicException. Use filterXpath():
$meta = $crawler->filterXpath('//meta[@name="robots"]/@content');
self::assertGreaterThan(0, $meta->count());
self::assertStringContainsString('noindex', $meta->text());
createStub() vs createMock() — PHPUnit 13 triggers notices for mocks that don't have expects() calls. Use createStub() in setUp() for interfaces where you only need return values. Create createMock() locally only in tests that verify method call counts:
// setUp() — no expects, use createStub()
$this->config = $this->createStub(SiteConfigServiceInterface::class);
$this->config->method('getPostsPerPage')->willReturn(10);
// Individual test — has expects, use createMock()
$mock = $this->createMock(MarkdownParserInterface::class);
$mock->expects(self::once())->method('parse')->willReturn($parsed);
declare(strict_types=1) on every test file — Same as production code. Yoda conditions in assertions. Blank line before return.
CI Integration
ddev test runs the full suite with testdox output. GitHub Actions runs the Unit suite on every push:
- name: PHPUnit
run: vendor/bin/phpunit --testsuite Unit
Integration tests need the full kernel and content files — viable locally via DDEV, but Unit-only in CI is the right trade-off. The Unit suite catches regressions in the core logic; Integration tests catch wiring issues that only appear with the full stack.
The Payoff
When I deliberately broke ContentItem::isDraft(), the test suite caught it immediately — 12 tests failed with clear messages about which property was wrong. Before the test suite, that kind of regression would only surface when someone noticed a draft post on the live site.
368 green tests means confidence to refactor. Extracting RelatedPostsService from ContentTree, making the tree immutable, moving constants to interfaces — all of that happened with the safety net of tests that would catch anything that broke.
The next post covers what happened next: an AI audit found 79 issues in code that had already passed PHPStan, CS Fixer, and manual review. Tests only catch what you think to test. An audit catches what you forgot to think about.