Testing
phil/content-bundleHow the bundle is tested
| Suite | Directory | Needs | Covers |
|---|---|---|---|
| Unit | tests/Unit | nothing outside the process; pdo_sqlite | the 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 |
| Functional | tests/Functional | pdo_sqlite, or a server named by PHIL_CONTENT_TEST_DATABASE_URL | a 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 |
| Integration | tests/Integration | a MySQL 8 and a PostgreSQL server | the 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
| Variable | Used by | Example |
|---|---|---|
PHIL_CONTENT_TEST_MYSQL_URL | tests/Integration (MySQL) | mysql://root:secret@127.0.0.1:3306/probe |
PHIL_CONTENT_TEST_POSTGRESQL_URL | tests/Integration (PostgreSQL) | postgresql://user:password@127.0.0.1:5432/probe |
PHIL_CONTENT_TEST_DATABASE_URL | tests/Functional, instead of its SQLite file | postgresql://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