Skip to content

Testing

phil/content-bundle

How the bundle is tested

SuiteDirectoryNeedsCovers
Unittests/Unitnothing outside the process; pdo_sqlitethe domain, every data mapper, merger and normalizer, the publication cycle over in-memory ports (one locale and several), a body of every format published as UJO and its HTML, the bundle's container wiring with and without DoctrineBundle and TwigBundle, the markup adapters, and the Doctrine storage on SQLite in memory
Functionaltests/Functionalpdo_sqlite, or a server named by PHIL_CONTENT_TEST_DATABASE_URLa kernel of its own (FrameworkBundle, DoctrineBundle, TwigBundle, SecurityBundle, phil/search-bundle): the publication cycle through Doctrine, a BBCode body stored as UJO on the live row, the controller (200, 301, 404), the archive, the Twig functions, the console commands, the page events, the search feed, the working copies and the administration
Integrationtests/Integrationa MySQL 8 and a PostgreSQL serverthe Doctrine storage — mapping, DBAL types, the unique constraint on the locale_key sentinel — on real servers; skipped without their URLs

The storage tests share one abstract case, tests/Unit/Infrastructure/Doctrine/ContentStorageTestCase.php, run on SQLite in the unit suite and on MySQL and PostgreSQL in the integration suite.

The functional kernel is tests/Functional/App/TestKernel.php: two kinds of content, news (a UUIDv7 identifier it generates) and static_page (an integer generated by the database), mounted under /{_locale}/news/{slug} and /{_locale}/{slug}, the second also on a route of its own, /{_locale}/about. The schema is dropped and created before each test. Its cache lives under the system temporary directory, one container per database URL.

Environment variables

VariableUsed byExample
PHIL_CONTENT_TEST_MYSQL_URLtests/Integration (MySQL)mysql://root:secret@127.0.0.1:3306/probe
PHIL_CONTENT_TEST_POSTGRESQL_URLtests/Integration (PostgreSQL)postgresql://user:password@127.0.0.1:5432/probe
PHIL_CONTENT_TEST_DATABASE_URLtests/Functional, instead of its SQLite filepostgresql://user:password@127.0.0.1:5432/probe?serverVersion=17

DoctrineBundle reads the version of a PostgreSQL server from the URL's serverVersion. The integration tests are in the PHPUnit groups mysql and postgresql.

Running the tests in the monorepo

The bundle is incubated in a monorepo and installed into the root project through a path repository; its tests run from the root vendor/, with the root PHPUnit configuration:

vendor/bin/phpunit packages/content-bundle/tests                  # every suite; Integration skipped without URLs
vendor/bin/phpunit packages/content-bundle/tests/Unit
vendor/bin/phpunit packages/content-bundle/tests/Functional

PHIL_CONTENT_TEST_MYSQL_URL='mysql://root:secret@127.0.0.1:3306/probe' \
PHIL_CONTENT_TEST_POSTGRESQL_URL='postgresql://user:password@127.0.0.1:5432/probe' \
    vendor/bin/phpunit --fail-on-skipped packages/content-bundle/tests/Integration

PHIL_CONTENT_TEST_DATABASE_URL='mysql://root:secret@127.0.0.1:3306/probe' \
    vendor/bin/phpunit packages/content-bundle/tests/Functional
PHIL_CONTENT_TEST_DATABASE_URL='postgresql://user:password@127.0.0.1:5432/probe?serverVersion=17' \
    vendor/bin/phpunit packages/content-bundle/tests/Functional

Running the tests of the bundle installed alone

The bundle ships no phpunit.xml. In a checkout of the bundle on its own:

composer install
vendor/bin/phpunit --no-configuration tests

composer.json declares the development dependencies the suites need: doctrine/doctrine-bundle, symfony/framework-bundle and symfony/twig-bundle for the functional kernel, symfony/event-dispatcher for the workflow events, symfony/uid for an identifier fixture, psr/log for the kernel's null logger, and phpunit/phpunit. composer-dependency-analyser.php checks that the declared dependencies match what src/ and tests/ use.

Testing an application that uses the bundle

Unit tests over in-memory ports

The application layer works over six ports, so it runs without a database when they are in memory. The bundle's own in-memory adapters live in tests/Unit/Fixture (InMemoryDimensionContentRepository, InMemoryRouteRepository, InMemoryContentRootRepository, InMemoryPublishedRows, InMemoryContentTransaction, and InMemoryContents, which wires the whole application layer over them). They are test code, not part of the autoloaded API: copy the pattern into your own tests rather than depending on them.

A minimal wiring, for a test of an application service:

use Phil\ContentBundle\Application\Merger\{ExcerptMerger, RoutableMerger, TemplateMerger, WorkflowMerger};
use Phil\ContentBundle\Application\Normalizer\WorkflowNormalizer;
use Phil\ContentBundle\Application\Pipeline\ContentNormalizer;
use Phil\ContentBundle\Application\Publication\{ContentPublisher, ContentVersions, ContentWorkflow,
    DimensionContentCopier};
use Phil\ContentBundle\Application\Routing\RouteUpdater;
use Symfony\Component\Clock\MockClock;

$mergers = [new TemplateMerger(), new WorkflowMerger(), new ExcerptMerger(), new RoutableMerger()];
$copier = new DimensionContentCopier($mergers);
$versions = new ContentVersions($rows, $copier, $transaction);
$publisher = new ContentPublisher(
    $rows, $roots, $versions, $copier, ContentWorkflow::create(), new RouteUpdater($routes, $roots),
    $transaction, new ContentNormalizer([new WorkflowNormalizer()]), new MockClock('2026-11-02 12:00:00'),
);

$rows, $roots, $routes and $transaction are your in-memory implementations of the ports (extension-points.md). ContentPublisher takes a clock: a MockClock fixes the publication dates; pass an EventDispatcher to ContentWorkflow::create() to exercise your guards.

Kernel tests

In a KernelTestCase or WebTestCase of the application, the services are under their class names: get ContentManager from the test container, create and publish content, then request its URL.

final class NewsPageTest extends WebTestCase
{
    public function testAPublishedNewsIsServed(): void
    {
        $client = self::createClient();
        $contents = self::getContainer()->get(ContentManager::class);

        $news = $contents->create('news', 'fr', ['templateKey' => 'news', 'slug' => 'opening', 'excerptTitle' => 'Opening']);
        $contents->publish($news, 'fr');

        $client->request('GET', '/fr/news/opening');

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('h1', 'Opening');
    }
}

Create the schema before the test (SchemaTool or your migrations), as the bundle's FunctionalTestCase does. Each ContentManager write commits its own transaction (nested in yours when one is open), so wrap the test in a transaction you roll back, or rebuild the schema, to isolate tests.

The bundle's own kernel (tests/Functional/App/TestKernel.php) also registers SecurityBundle with three users in memory, the administration behind the role ROLE_EDITOR, phil/search-bundle on its dbal adapter, and a recorder of the page events: ContentAdminTest signs in through the kernel's user provider and submits the administration's forms with their CSRF tokens; SearchFeedTest searches what a publication indexed; PageEventsTest reads the events dispatched. An application tests a listener of the page events the same way, with a listener service that keeps what it receives.

Console tests

The commands are ordinary Symfony commands: run them with CommandTester (or ApplicationTester) and assert on the output and the status code. phil:content:create -q prints the reference alone, which a test can read back.

Benchmarks

BodyNormalizerBench purifies bodies of three lengths; PublishedContentsBench and ContentUrlProviderBench read 10, 100 and 1,000 published articles on SQLite in memory. From the monorepo root:

composer bench -- packages/content-bundle/tests/Benchmark