Testing Joomla Extensions
|
This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production. This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages. |
An extension that is only tested by clicking through the administrator breaks the first time a Joomla minor release,
a new PHP version or the other database engine changes something underneath it. This page, checked on 2026-10-30,
shows the test layers that Joomla core uses itself and how to apply them to a third-party extension: PHPUnit unit
tests, PHPUnit integration tests against a real database, Cypress system (end-to-end) tests, a MySQL, MariaDB and
PostgreSQL matrix, and the Patch Tester for people who test other people’s changes. It is written for the extension
developer and the person who maintains the build, against Joomla 6.2.x (the 6.2.1 source tree was read for the
commands and file names below) and PHP 8.3 or later, the minimum of Joomla 6. The running example is the Tallpine
Hiking Club package pkg_tallpine, whose component com_tallpine has the namespace
Tallpine\Component\Tallpine, whose content plugin plg_content_tallpine_conditions appends a trail-closure notice
and whose module mod_tallpine_nexthikes lists upcoming hikes.
The classes under test are described elsewhere and are not re-taught here: the plugin in Plugin development and events, the module in Module development, the component in Component MVC: administrator, the container in Namespaces, autoloading and dependency injection, the database layer in The database API, the SQL scripts in Installer scripts and SQL updates, and the packaging pipeline in Build and CI for Joomla extensions. The generic tooling is covered by Testing PHP with PHPUnit and Pest, Getting started with Cypress and Compose for local development; this page concentrates on what is specific to Joomla.
The test layers at a glance
Joomla core runs four kinds of automated checks on every pull request: code style (PHP-CS-Fixer for PHP, ESLint and
Stylelint for JavaScript and CSS), static analysis (PHPStan), PHPUnit tests in two suites called Unit and
Integration, and Cypress system tests. The Developer Manual describes the same split: unit tests exercise framework
classes one method at a time, and system tests check "that the application works from the user’s point of view", which
is why people who know Joomla but not PHP can write them. An extension can adopt exactly the same layers.
The lower layers are fast and precise; the upper layers are slow and realistic. A reasonable allocation for
pkg_tallpine is: many unit tests for the logic in plugins, helpers and services, a few integration tests for the
queries and the install and update SQL on both database families, and a small set of Cypress scenarios for the journeys
that matter (install the package, create a trail, see it on the site, read it through the API). Coding standards and
static analysis are described in Coding standards and static
analysis; this page starts after they pass.
What the official documentation covers
The Developer Manual’s testing section has two parts, Automated Testing (Concepts, System Testing, Unit Testing) and
Manual Testing (including the Patch Tester page). The System Testing setup page and the Patch Tester page are written
out. The Unit Testing "Writing Tests" page and the System Testing "Writing Tests" page have no content yet. The real reference for writing tests is therefore the tests folder of the
joomla-cms repository and its README files (tests/Unit/README.md, tests/Integration/README.md and
tests/System/README.md), which this page follows. The Manual’s pages for version 6.1 also still name Node.js 16.16 as
the current LTS and old database minimums for the test tools; the 6.2 source requires Node.js 20 or later
("node": ">=20.0.0" in package.json) and its CI uses MySQL 8.0.13 to 9, MariaDB 10.4 to 12.0 and PostgreSQL 12 to
18.
Source: Joomla Manual: Automated Testing
Corrections to older books and tutorials
Older books such as Developing Extensions for Joomla! 5 (Packt, 2023) say that Joomla core uses Codeception for its
acceptance tests. That is no longer true: the system tests of Joomla 4.x and later are Cypress specs, the Manual says
"We are using Cypress for our system (end2end) testing", and package.json of 6.2 contains the scripts
cypress:install, cypress:open and cypress:run and no Codeception dependency. A tutorial that tells you to run
vendor/bin/codecept run against a Joomla checkout, or that calls JFactory::getDbo() in a test, describes the
Joomla 3 era; those APIs are covered by the Backward Compatibility 6 plugin only and are removed in 7.0 (see
Deprecations and preparing for Joomla 7). Both books also
target Joomla 4.x or 5.0; Joomla 4 reached end of life in October 2025, 5.x is security-only and 6.2 is the current
release.
A project layout for testing pkg_tallpine
Keep the tests in the repository of the extension, outside the folders that go into the installable package. The build
script that creates pkg_tallpine.zip copies only the extension folders, so tests never reaches a customer’s site.
tallpine/
|-- composer.json
|-- phpunit.xml.dist
|-- cypress.config.mjs
|-- package.json
|-- docker-compose.yml
|-- pkg_tallpine.xml
|-- com_tallpine/
| |-- admin/ (services/provider.php, src/, sql/ ...)
| `-- site/
|-- mod_tallpine_nexthikes/
|-- plg_content_tallpine_conditions/
| |-- services/provider.php
| |-- src/Extension/TallpineConditions.php
| `-- tallpine_conditions.xml
`-- tests/
|-- bootstrap.php
|-- Unit/
| |-- DatabaseStubTrait.php
| |-- Plugin/Content/TallpineConditions/TallpineConditionsTest.php
| |-- Module/NextHikes/HelperTest.php
| `-- Component/ProviderTest.php
|-- Integration/
| |-- DatabaseTestCase.php
| `-- TrailTableTest.php
`-- System/
|-- support/
`-- integration/
|-- install/Install.cy.js
|-- administrator/Trails.cy.js
`-- api/Trails.cy.js
This mirrors the layout of core (tests/Unit, tests/Integration and the folders below tests/System/integration) so that anyone who
has read core tests can read yours.
PHPUnit unit tests
The toolchain
Joomla 6.2 requires "phpunit/phpunit": "^12.5" in its composer.json, and the unit tests run on PHP 8.3, 8.4 and 8.5
(and on Windows as well). Use the same major version in your extension so that the attribute syntax and the stub and mock
rules match core. PHPUnit 12 uses PHP attributes instead of annotations (#[DataProvider], #[TestDox] and
#[Test]), requires data providers to be static, and distinguishes a stub (createStub(), which returns canned
values and never fails a test) from a mock (createMock(), on which you set expectations with expects()): a mock
without expectations produces a notice. The Joomla core test suite follows the same rules. For the general PHPUnit
workflow see Testing PHP with PHPUnit and Pest; the
parts that are specific to Joomla follow.
Core runs its own tests with ./libraries/vendor/bin/phpunit --testsuite Unit, because a Joomla checkout puts Composer’s
vendor-dir in libraries/vendor. No edits to phpunit.xml.dist are needed for the unit suite. Its phpunit element
points the bootstrap at tests/Unit/bootstrap.php, and both suites match files that end in Test.php.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/Unit/bootstrap.php" colors="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory suffix="Test.php">./tests/Integration</directory>
</testsuite>
</testsuites>
<php>
<const name="JTEST_DB_ENGINE" value="mysqli" />
<const name="JTEST_DB_HOST" value="mysql" />
<const name="JTEST_DB_NAME" value="test_joomla" />
<const name="JTEST_DB_USER" value="joomla_ut" />
<const name="JTEST_DB_PASSWORD" value="joomla_ut" />
</php>
</phpunit>
Source: joomla-cms 6.2-dev: phpunit.xml.dist (the LDAP constants of the original are omitted here)
The values above are the throw-away credentials of CI containers that exist only for the duration of a test run. They are the ones in the repository’s own file; never reuse them on a server that holds data.
Older tutorials copy phpunit.xml.dist to phpunit.xml and edit it. That is still how the integration suite is pointed
at a local database, but for CI the Joomla workflow leaves the file alone and overrides the constants with environment
variables (JTEST_DB_ENGINE, JTEST_DB_HOST, JTEST_DB_USER and JTEST_DB_NAME), which DBTestHelper reads with
getenv() before it falls back to the constant. Do the same in your own CI.
What the core bootstrap does
tests/Unit/bootstrap.php builds a minimal framework, not a running site. It defines _JEXEC, raises error reporting to
E_ALL, fixes the PHP precision ini setting to 14, and defines the path constants (JPATH_BASE, JPATH_ROOT,
JPATH_LIBRARIES, JPATH_SITE, JPATH_ADMINISTRATOR, JPATH_CACHE, JPATH_API, JPATH_INSTALLATION,
JPATH_MANIFESTS, JPATH_PLUGINS, JPATH_THEMES and JDEBUG) relative to the current working directory, unless they
are already defined. It then loads the JLoader, requires libraries/vendor/autoload.php, replaces Composer’s
autoloader with Joomla’s decorated ClassLoader, loads libraries/namespacemap.php, and defines JVERSION.
Three consequences matter to an extension author.
-
Run PHPUnit from the root of a Joomla checkout (the bootstrap uses
getcwd()), or define theJPATH_*constants yourself before including it. -
The namespace map of installed extensions (
libraries/namespacemap.php) is a file that a real site generates when extensions are installed. A fresh checkout has none, so the namespaces of your extension are not known to the autoloader. Register them in your own bootstrap. -
No application, session, user or database exists. Every
Factory::getApplication(),Factory::getDbo()orText::_()call in code under test either fails or returns a key instead of a translation. This is the reason to inject the application and the database, as the extension classes in this sub-section do.
The Manual does not document a bootstrap for third-party extensions. The pattern below reuses the core bootstrap from a
Joomla checkout whose location is given by an environment variable, and adds the namespaces of pkg_tallpine.
<?php
// tests/bootstrap.php
$joomlaRoot = getenv('JOOMLA_ROOT') ?: dirname(__DIR__) . '/.joomla';
if (!is_file($joomlaRoot . '/tests/Unit/bootstrap.php')) {
fwrite(STDERR, "Set JOOMLA_ROOT to a Joomla 6.2 checkout on which 'composer install' has run.\n");
exit(1);
}
// The core bootstrap derives every JPATH_* constant from the working directory.
chdir($joomlaRoot);
require $joomlaRoot . '/tests/Unit/bootstrap.php';
// The extension is not installed in that checkout, so teach the autoloader where its classes live.
$prefixes = [
'Tallpine\\Component\\Tallpine\\Administrator\\' => dirname(__DIR__) . '/com_tallpine/admin/src/',
'Tallpine\\Component\\Tallpine\\Site\\' => dirname(__DIR__) . '/com_tallpine/site/src/',
'Tallpine\\Module\\NextHikes\\Site\\' => dirname(__DIR__) . '/mod_tallpine_nexthikes/src/',
'Tallpine\\Plugin\\Content\\TallpineConditions\\' => dirname(__DIR__) . '/plg_content_tallpine_conditions/src/',
'Tallpine\\Tests\\' => __DIR__ . '/',
];
spl_autoload_register(static function (string $class) use ($prefixes): void {
foreach ($prefixes as $prefix => $directory) {
if (str_starts_with($class, $prefix)) {
$file = $directory . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require $file;
}
return;
}
}
});
The extension’s own composer.json and phpunit.xml.dist then only need the development tools and the bootstrap. The
JOOMLA_ROOT checkout is created once with git clone --depth 1 --branch 6.2-dev https://github.com/joomla/joomla-cms
.joomla && composer install --working-dir=.joomla; CI caches it. Because the tests are compiled against that exact
checkout, a matrix over Joomla versions is just a matrix over the branch that is cloned.
{
"name": "tallpine/pkg_tallpine",
"type": "project",
"require-dev": {
"phpunit/phpunit": "^12.5",
"friendsofphp/php-cs-fixer": "^3.94",
"phpstan/phpstan": "^2.1"
},
"config": { "sort-packages": true },
"scripts": {
"test": "phpunit --testsuite Unit",
"test:integration": "phpunit --testsuite Integration"
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/bootstrap.php" colors="true" failOnDeprecation="false" failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory suffix="Test.php">tests/Integration</directory>
</testsuite>
</testsuites>
<php>
<env name="JTEST_DB_ENGINE" value="mysqli" force="false"/>
<env name="JTEST_DB_HOST" value="127.0.0.1" force="false"/>
<env name="JTEST_DB_NAME" value="test_tallpine" force="false"/>
<env name="JTEST_DB_USER" value="tallpine_ut" force="false"/>
<env name="JTEST_DB_PASSWORD" value="CHANGE_ME" force="false"/>
</php>
</phpunit>
failOnDeprecation is left off deliberately at first: Joomla 6.x logs E_USER_DEPRECATED notices for APIs that are
removed in 7.0 (for example the automatic $db property of a plugin, or passing a dispatcher to the CMSPlugin
constructor). Run the suite once with failOnDeprecation="true" to find them, as described in
Deprecations and preparing for Joomla 7, and turn it on in
CI when the count is zero.
Writing a plugin test
The plugin from The database API reads a closure date and appends a notice to the
trail text. It is final, uses DatabaseAwareTrait, gets its configuration from the params entry of the array passed
to the constructor, and handles a concrete event class. All three facts make it easy to test without Joomla running.
-
finalmeans the class cannot be replaced by a PHPUnit double. Test the real object and replace its collaborators. -
setDatabase()andsetApplication()are public, so the collaborators are injected directly. -
ContentPrepareEventis a plain object that can be created with an argument array (context,subject,params,page), and the plugin writes its result into the subject, so the test asserts on the subject.
First a trait that builds the database stub. A stub of DatabaseInterface returns the values you configure for
createQuery(), quoteName(), setQuery() and loadResult(). The query is a stub of the abstract DatabaseQuery so
that every fluent method (select(), from(), where(), bind(), whereIn(), order(), setLimit()) exists and
returns itself.
<?php
namespace Tallpine\Tests\Unit;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\DatabaseQuery;
trait DatabaseStubTrait
{
/**
* Builds a DatabaseInterface stub whose query builder is fluent and whose loaders return fixed values.
*
* @param mixed $loadResult Returned by loadResult().
* @param array $loadObjectList Returned by loadObjectList().
*/
protected function createDatabaseStub(mixed $loadResult = null, array $loadObjectList = []): DatabaseInterface
{
$query = $this->createStub(DatabaseQuery::class);
foreach (['select', 'from', 'where', 'whereIn', 'bind', 'order', 'setLimit'] as $method) {
$query->method($method)->willReturnSelf();
}
$db = $this->createStub(DatabaseInterface::class);
$db->method('createQuery')->willReturn($query);
$db->method('quoteName')->willReturnArgument(0);
$db->method('setQuery')->willReturnSelf();
$db->method('loadResult')->willReturn($loadResult);
$db->method('loadObjectList')->willReturn($loadObjectList);
return $db;
}
}
Source: joomla-cms 6.2-dev: tests/Unit/UnitTestCase.php (core builds a similar query stub with an anonymous DatabaseQuery subclass)
The test itself checks the subscription map, the three behaviours of the handler and, because the closure date is data that an editor can influence, the escaping of the output.
<?php
namespace Tallpine\Tests\Unit\Plugin\Content\TallpineConditions;
use Joomla\CMS\Event\Content\ContentPrepareEvent;
use Joomla\Registry\Registry;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Tallpine\Plugin\Content\TallpineConditions\Extension\TallpineConditions;
use Tallpine\Tests\Unit\DatabaseStubTrait;
#[TestDox('The Tallpine conditions plugin')]
class TallpineConditionsTest extends TestCase
{
use DatabaseStubTrait;
private function createPlugin(mixed $closedUntil): TallpineConditions
{
$plugin = new TallpineConditions(['params' => []]);
$plugin->setDatabase($this->createDatabaseStub($closedUntil));
return $plugin;
}
private function createEvent(string $context, \stdClass $item): ContentPrepareEvent
{
return new ContentPrepareEvent('onContentPrepare', [
'context' => $context,
'subject' => $item,
'params' => new Registry(),
'page' => 0,
]);
}
#[TestDox('subscribes to onContentPrepare')]
public function testSubscribedEvents(): void
{
$this->assertSame(['onContentPrepare' => 'onContentPrepare'], TallpineConditions::getSubscribedEvents());
}
#[TestDox('appends the closure notice to a trail')]
public function testAppendsNotice(): void
{
$item = (object) ['id' => 42, 'text' => '<p>Ridge loop</p>'];
$this->createPlugin('2026-11-15')->onContentPrepare($this->createEvent('com_tallpine.trail', $item));
$this->assertStringContainsString('Closed until 2026-11-15', $item->text);
$this->assertStringStartsWith('<p>Ridge loop</p>', $item->text);
}
#[TestDox('leaves other contexts alone')]
public function testIgnoresOtherContexts(): void
{
$item = (object) ['id' => 7, 'text' => '<p>News</p>'];
$this->createPlugin('2026-11-15')->onContentPrepare($this->createEvent('com_content.article', $item));
$this->assertSame('<p>News</p>', $item->text);
}
#[TestDox('adds nothing when the trail is open')]
public function testOpenTrail(): void
{
$item = (object) ['id' => 42, 'text' => '<p>Ridge loop</p>'];
$this->createPlugin(null)->onContentPrepare($this->createEvent('com_tallpine.trail', $item));
$this->assertSame('<p>Ridge loop</p>', $item->text);
}
#[DataProvider('hostileDates')]
#[TestDox('escapes the stored date: $stored')]
public function testEscapesOutput(string $stored): void
{
$item = (object) ['id' => 42, 'text' => ''];
$this->createPlugin($stored)->onContentPrepare($this->createEvent('com_tallpine.trail', $item));
$this->assertStringNotContainsString('<script>', $item->text);
}
public static function hostileDates(): iterable
{
yield 'script tag' => ['<script>alert(1)</script>'];
yield 'attribute break' => ['" onmouseover="x'];
}
}
Source: Joomla Manual: How plugins work; the construction of events with an argument array follows the core test tests/Unit/Plugin/Content/ConfirmConsent in joomla-cms 6.2-dev
Run it from the extension repository:
export JOOMLA_ROOT="$PWD/.joomla"
composer install
./vendor/bin/phpunit --testsuite Unit --testdox
Details that decide whether such a test is stable:
-
Pass an array to a plugin constructor. Core’s own tests still hand the constructor a
Dispatcherfirst (new ConfirmConsent($dispatcher, ['params' => []])), whichCMSPluginaccepts in 6.x but marks with a deprecation that says the form is unsupported from 7.0. The array form (new TallpineConditions(['params' => []])) is what the service provider uses and is the one to copy. -
Do not stub what you cannot control. A test that stubs
Factory::getApplication()needs the global state of the framework; inject a stub ofCMSApplicationInterfaceinstead. A stub ofLanguagethat returns'test'for_()is how core tests keepText::_()calls from failing. -
Prefer a real value object over a stub.
Registry,Date,Uriand the event classes are cheap to construct and give better tests than doubles. -
Assert on observable results. Verifying that
select()was called with a given column makes the test fail on every harmless refactoring of the query. The integration layer below is where the query is checked against a database.
Testing a module helper
A Helper of a module is a plain class with an injected database, so it is a good unit-test target. The test supplies a
stubbed application and database and checks the branches around the query. The query itself is not tested here.
<?php
namespace Tallpine\Tests\Unit\Module\NextHikes;
use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\User\User;
use Joomla\Registry\Registry;
use PHPUnit\Framework\TestCase;
use Tallpine\Module\NextHikes\Site\Helper\TallpineNexthikesHelper;
use Tallpine\Tests\Unit\DatabaseStubTrait;
class HelperTest extends TestCase
{
use DatabaseStubTrait;
public function testReturnsEmptyListWhenNothingIsScheduled(): void
{
$user = $this->createStub(User::class);
$user->method('getAuthorisedViewLevels')->willReturn([1, 5]);
$app = $this->createStub(SiteApplication::class);
$app->method('getIdentity')->willReturn($user);
$app->method('getLanguageFilter')->willReturn(false);
$helper = new TallpineNexthikesHelper();
$helper->setDatabase($this->createDatabaseStub(null, []));
$this->assertSame([], $helper->getHikes(new Registry(['count' => 99]), $app));
}
}
The Dispatcher is tested the same way: create it with a stub application, call the protected getLayoutData() through
a small subclass or through dispatch(), and give it a helper factory that returns a stub helper
(HelperFactoryAwareTrait::setHelperFactory()). The layout file is better covered by a system test, because it is only
HTML.
Mocking the dependency-injection container
The container is Joomla\DI\Container, and a service provider is an object whose register(Container $container) method
adds services. Because both are ordinary classes, a test can build a real container, register the extension’s provider
and overwrite the services that need Joomla to be running. This verifies the wiring that a mistyped namespace string or a
forgotten setMVCFactory() call would otherwise break only on a live site.
<?php
namespace Tallpine\Tests\Unit\Component;
use Joomla\CMS\Categories\CategoryFactoryInterface;
use Joomla\CMS\Component\Router\RouterFactoryInterface;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\HTML\Registry;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use PHPUnit\Framework\TestCase;
use Tallpine\Component\Tallpine\Administrator\Extension\TallpineComponent;
class ProviderTest extends TestCase
{
public function testProviderBuildsTheExtensionObject(): void
{
/** @var ServiceProviderInterface $provider */
$provider = require dirname(__DIR__, 3) . '/com_tallpine/admin/services/provider.php';
$container = new Container();
$provider->register($container);
// Replace the services that need a booted application.
$container->set(ComponentDispatcherFactoryInterface::class, $this->createStub(ComponentDispatcherFactoryInterface::class));
$container->set(Registry::class, $this->createStub(Registry::class));
$container->set(MVCFactoryInterface::class, $this->createStub(MVCFactoryInterface::class));
$container->set(CategoryFactoryInterface::class, $this->createStub(CategoryFactoryInterface::class));
$container->set(RouterFactoryInterface::class, $this->createStub(RouterFactoryInterface::class));
$this->assertInstanceOf(TallpineComponent::class, $container->get(ComponentInterface::class));
}
}
A few cautions apply to container tests.
-
The provider file
return`s an anonymous class instead of declaring a named one, so a test loads it with `require, and eachrequirecreates a new object. That is convenient, and it is why the provider must not keep state. -
Container::set()replaces an existing definition unless it was registered as protected, so overriding afterregister()is allowed. A provider that sets a protected key cannot be overridden and has to be tested through its real dependencies. -
Providers that call
Factory::getApplication()orPluginHelper::getPlugin()directly, as the plugin provider does, cannot be exercised without a booted application. Keep those providers to a few lines (they are wiring, not logic) and cover them with the install step of the system test. -
The container resolves services lazily. A
get()that is never called never fails, so assert on the services you care about.
Models, tables and controllers
Model and table classes are tied to the framework: ListModel and AdminModel extend BaseDatabaseModel, the MVC
factory injects the database after construction, and a Table subclass talks to the database in store() and load().
The practical rule is to keep the code that deserves a unit test out of those classes:
-
put calculations, validation rules and formatting in small final classes or static-free services (a
TrailRepository, aDifficultyCalculator) that receive their dependencies in the constructor, and unit-test those; -
leave
getListQuery()andgetTable()to the integration layer, where the query is executed; -
leave controllers to system tests. A controller needs a session, a token check and a redirect.
When a test must instantiate a model, create it with the component’s MVCFactory or call setDatabase() explicitly with
a stub or a test connection; do not call getDatabase() in the model’s constructor, because the database is injected
afterwards (see The database API).
Common unit-test errors
| Symptom | Cause and remedy |
|---|---|
|
The extension’s namespace is not registered. In a plain checkout there is no |
|
The code under test pulls the application out of a global. Inject |
|
Core plugin classes and many framework classes are |
The language string shows as |
No language file is loaded in a unit test. Assert on the key, or stub |
|
A file under test calls |
A deprecation fails the run |
Joomla 6.x emits |
Integration tests against a real database
What core provides
The Integration suite runs against a real database, selected by JTEST_DB_ENGINE (mysqli or pgsql),
JTEST_DB_HOST, JTEST_DB_NAME, JTEST_DB_USER and JTEST_DB_PASSWORD. Core ships three helpers in
tests/Integration:
-
IntegrationTestCaseextendsPHPUnit\Framework\TestCaseand, insetUp(), callsDBTestHelper::setupTest($this)when the test implementsDBTestInterface; -
DBTestTraitsuppliessetDBDriver(),getDBDriver()andgetSchemasToLoad(), whose default is['datasets/framework.sql']; -
DBTestHelpercreates the driver once throughDatabaseFactory::getDriver()with a table prefix ofjosplus the PHP major and minor version (jos83_on PHP 8.3), then reads the SQL files named by the test fromtests/Integration/datasets/<engine>/, splits them into statements and executes them once per run.
The folder datasets contains one directory per engine (mysqli and pgsql), each with the same two files
(framework.sql and testtable.sql). That symmetry is the point: the same test runs against both families, and each
family has its own SQL. The integration README also explains that the LDAP tests need an OpenLDAP container and that
setting JTEST_LDAP_HOST to an empty value skips them; an extension that does not touch authentication can ignore this.
An integration test base class for an extension
The core classes live in the Joomla\Tests\Integration namespace of the CMS repository and are available when the tests
run inside a Joomla checkout with development dependencies, but a standalone extension repository should not depend on
them. A small equivalent is enough: it builds the driver from the same environment variables, creates the schema from
the same SQL files that the installer will run, and wraps every test in a transaction that is rolled back.
<?php
namespace Tallpine\Tests\Integration;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\DatabaseFactory;
use PHPUnit\Framework\TestCase;
abstract class DatabaseTestCase extends TestCase
{
private static ?DatabaseDriver $driver = null;
protected static function db(): DatabaseDriver
{
if (self::$driver === null) {
self::$driver = (new DatabaseFactory())->getDriver(
getenv('JTEST_DB_ENGINE') ?: 'mysqli',
[
'host' => getenv('JTEST_DB_HOST') ?: '127.0.0.1',
'user' => getenv('JTEST_DB_USER') ?: 'tallpine_ut',
'password' => getenv('JTEST_DB_PASSWORD') ?: '',
'database' => getenv('JTEST_DB_NAME') ?: 'test_tallpine',
'port' => getenv('JTEST_DB_PORT') ?: null,
'prefix' => 'tp_',
]
);
self::installSchema(self::$driver);
}
return self::$driver;
}
/**
* Runs the extension's own install script for the engine under test.
*/
private static function installSchema(DatabaseDriver $db): void
{
$engine = str_starts_with(getenv('JTEST_DB_ENGINE') ?: 'mysqli', 'pg') ? 'postgresql' : 'mysql';
$file = dirname(__DIR__, 2) . '/com_tallpine/admin/sql/install.' . $engine . '.utf8.sql';
// Table prefix substitution is done by the driver when a query is set.
foreach (array_filter(array_map('trim', explode(";\n", (string) file_get_contents($file)))) as $statement) {
$db->setQuery($statement)->execute();
}
}
protected function setUp(): void
{
parent::setUp();
self::db()->transactionStart();
}
protected function tearDown(): void
{
self::db()->transactionRollback();
parent::tearDown();
}
}
The base class is a pattern, not an API. Two points are worth noting. First, splitting the install script on ; is a
simplification that is fine for plain CREATE TABLE files and wrong for a script with stored routines or strings that
contain a semicolon, which is why the core helper has a proper splitter; the installer’s Installer::splitSql() is the
reference. Second, MySQL implicitly commits DDL statements, so a transaction protects the data written by the test but
not the tables created by installSchema(); run the install once and keep the rows under transaction control.
The test then exercises the real queries of the repository class on whichever engine the job selected.
<?php
namespace Tallpine\Tests\Integration;
use Tallpine\Component\Tallpine\Administrator\Service\TrailRepository;
class TrailRepositoryTest extends DatabaseTestCase
{
public function testFindsPublishedTrailsByDifficulty(): void
{
$db = self::db();
$rows = [
['Ridge loop', 'hard', 1],
['Meadow walk', 'easy', 1],
['Old quarry', 'hard', 0],
];
foreach ($rows as [$title, $difficulty, $state]) {
$db->setQuery(
$db->createQuery()
->insert($db->quoteName('#__tallpine_trails'))
->columns($db->quoteName(['title', 'alias', 'difficulty', 'state', 'access', 'created']))
->values(implode(',', [
$db->quote($title),
$db->quote(strtolower(str_replace(' ', '-', $title))),
$db->quote($difficulty),
(int) $state,
1,
$db->quote('2026-10-30 08:00:00'),
]))
)->execute();
}
$repository = new TrailRepository($db);
$titles = array_column($repository->findPublishedByDifficulty('hard'), 'title');
$this->assertSame(['Ridge loop'], $titles);
}
}
Source: Joomla Manual: Database
An integration test earns its cost where behaviour differs between engines: an ORDER BY on a column that is not
selected, GROUP BY with non-aggregated columns, case-sensitive LIKE, boolean and date handling, identifiers and
auto-increment columns, ON DUPLICATE KEY versus ON CONFLICT. The query builder abstracts quoting, limits and many
functions, but it does not translate arbitrary SQL, and the install SQL for MySQL and PostgreSQL stays two separate
files (see The database API). Older books say the query builder "translates queries
for every database"; for the SQL you write by hand that is not the case, and only an integration test on both engines
proves it.
Other integration approaches
PHPUnit against a database is one approach. Three others are used for extensions.
-
Install the package into a live container and probe it. The official
joomlaimage installs a package at first start (JOOMLA_EXTENSIONS_PATHS) and later withphp cli/joomla.php extension:install --path=.... A shell script then asserts onextension:list, on the schema version row and on the database contents, which is what the upgrade test in Build and CI for Joomla extensions does. See Local development with Docker for the variables. -
Run PHPUnit inside the booted site. Install the extension into a Joomla checkout, run
php cli/joomla.phpor a console plugin command, and let the test code boot the real application and container. This is the most realistic and the slowest option, and it is the one for code that cannot be separated from the framework, such as a console command (see Console command plugins). -
Contract tests for the Web Services API. With the container running, a PHPUnit or Cypress
requesttest calls the endpoints thatplg_webservices_tallpineregisters and compares the JSON:API structure. The system-test section below does it with Cypress.
Cypress system tests
How core does it
The Joomla System Tests are end-to-end tests that run in a real browser with Cypress. The 6.2 source describes the
design in tests/System/README.md:
-
Cypress runs as a Node.js application and reads
cypress.config.mjs. -
The Cypress Test Runner controls a browser in which the Joomla front end, back end, API and web installer run.
-
Two kinds of custom helpers extend
cy: database commands (names that start withdb_) that create content in the database, and API commands (names that start withapi_) that call the Web Services API. The npm packagejoomla-cypress(declared as"joomla-cypress": "^2.0.0") adds Joomla-specific commands such asdoAdministratorLoginandinstallExtensionFromFolder. The packagesmtp-testerruns a throw-away SMTP server so tests can read the e-mails Joomla sends. -
Cypress tasks run code in Node.js, for example
queryDB,cleanupDB,writeRelativeFile,deleteRelativePath,copyRelativeFile,checkForLogs,clearLogs,startMailServer,getMailsandclearEmails. Tasks are asynchronous and have nocy.namespace. -
The first spec,
tests/System/integration/install/Installation.cy.js, deletesconfiguration.phpso that the web installer runs, installs Joomla and then editsconfiguration.php(SMTP settings, the API secret). Theinstallationfolder must therefore not be deleted, or the suite cannot be run twice. The installer createsconfiguration.phpread-only (444).
The 6.2.1 package.json pins "cypress": "^15.18.0", the eslint-plugin-cypress plugin, and the scripts
{
"scripts": {
"cypress:install": "cypress install",
"cypress:open": "cypress open --config-file cypress.config.mjs",
"cypress:run": "cypress run --config-file cypress.config.mjs"
}
}
Source: joomla-cms 6.2-dev: package.json
The specs live in tests/System/integration, in six folders that the configuration lists in specPattern: install,
administrator, site, api, plugins and cli. Component, module and plugin tests follow a naming convention, so a
spec for a new extension is found by anyone who knows where to look.
| Extension | Folder under tests/System/integration |
|---|---|
Component back end or front end |
|
Module |
|
Plugin |
|
Web Services API |
|
CLI command |
|
Tests should be repeatable, independent of one another, small and do one thing. Core specs create their data with the
db_ commands, clean up in afterEach, and a global afterEach in tests/System/support/index.js calls
cy.checkForPhpNoticesOrWarnings(), cy.task('checkForLogs') and cy.task('cleanupDB'), so that a PHP notice or an
error in the web server log fails the test even when the page looks right.
Running core’s own system tests
The documented sequence for a local environment, taken from the 6.2.1 README, is:
git clone --depth 1 https://github.com/joomla/joomla-cms /var/www/html
cd /var/www/html
composer install
npm ci
cp cypress.config.dist.mjs cypress.config.mjs
# edit cypress.config.mjs: set baseUrl and the values under expose
npm run cypress:run
The cypress.config.dist.mjs of 6.2.1 contains the settings below. The test site’s baseUrl points at the checkout,
specPattern lists the six folders, Firefox is the default browser, videos are off, and screenshots are taken when a
test fails. The block under expose holds the environment of the test installation; it uses Cypress' newer public
environment values mechanism, and allowCypressEnv: false disables the older Cypress.env() access.
import { defineConfig } from 'cypress';
import setupPlugins from './tests/System/plugins/index.mjs';
export default defineConfig({
fixturesFolder: 'tests/System/fixtures',
videosFolder: 'tests/System/output/videos',
screenshotsFolder: 'tests/System/output/screenshots',
viewportHeight: 1000,
viewportWidth: 1200,
e2e: {
setupNodeEvents(on, config) {
setupPlugins(on, config);
},
baseUrl: 'https://localhost/',
specPattern: [
'tests/System/integration/install/**/*.cy.{js,jsx,ts,tsx}',
'tests/System/integration/administrator/**/*.cy.{js,jsx,ts,tsx}',
'tests/System/integration/site/**/*.cy.{js,jsx,ts,tsx}',
'tests/System/integration/api/**/*.cy.{js,jsx,ts,tsx}',
'tests/System/integration/plugins/**/*.cy.{js,jsx,ts,tsx}',
'tests/System/integration/cli/**/*.cy.{js,jsx,ts,tsx}',
],
supportFile: 'tests/System/support/index.js',
scrollBehavior: 'center',
browser: 'firefox',
screenshotOnRunFailure: true,
video: false,
allowCypressEnv: false,
},
expose: {
sitename: 'Joomla CMS Test',
name: 'jane doe',
email: 'admin@example.com',
username: 'ci-admin',
password: 'CHANGE_ME_TEST_ONLY',
db_type: 'MySQLi',
db_host: 'localhost',
db_port: '',
db_name: 'test_joomla',
db_user: 'root',
db_password: '',
db_prefix: 'jos_',
smtp_host: 'localhost',
smtp_port: '1025',
cmsPath: '.',
logFile: '',
},
});
Source: joomla-cms 6.2-dev: cypress.config.dist.mjs (the password value is replaced by a placeholder)
Some practical details from the README:
-
Run a single spec with
npx cypress run --spec tests/System/integration/install/Installation.cy.js, or several with a brace pattern:npx cypress run --spec 'tests/System/integration/{administrator,site,api,plugins}/**/*.cy.js'. The second form runs everything except the installation, which only works after Joomla is installed. -
npm run cypress:openopens the Cypress GUI, which lets you rewind a run and see what the automatic browser did. -
console.log()output from the browser appears in headless runs only with the Electron browser andELECTRON_ENABLE_LOGGING=1. -
Cypress commands and tasks are asynchronous, so always chain them with
.then(). Do not usecy.wait(20000)in committed tests; it is only a debugging aid. -
If the web server and Cypress run as different users, writing
configuration.phpfails withEACCESor, on Windows,EPERM. Run Cypress as a user that may write the file (the README suggestssudo, withCYPRESS_CACHE_FOLDERset to your own cache), or serve the site as the same user. -
EADDRINUSEon port 1025 means thatsmtp_portis in use (Mailpit, from the local Docker setup, uses the same port); choose another. -
On slow machines raise
defaultCommandTimeoutin the config from the default 4 seconds.
The Manual’s page for System Testing (6.1) describes an older, equivalent setup: a cypress.env.json file that overrides
the environment (sitename, name, email, username, password, db_type, db_host, db_name, db_user,
db_password, db_prefix) and the command npx cypress open --e2e --browser=chrome --config baseUrl=http://joomla-cms.test.
The 6.2 source keeps these values in cypress.config.mjs under expose and passes overrides with --expose on the
command line. Which form applies depends on the Joomla branch you are testing, so follow the README of that branch.
Core’s Docker setup and CI jobs
For a run without installing PHP and a web server, tests/System/docker-compose.yml defines a system-tests service
(image joomlaprojects/docker-images:cypress8.4, the working copy mounted at /app, the entry point
tests/System/entrypoint.sh), a MySQL 8.3 service whose data directory is a tmpfs, and phpMyAdmin. The command is
docker compose down && docker compose up system-tests, and the web server is then reachable at
http://localhost:8080 or https://localhost:8443. Because the database lives in memory, every run starts empty, which
is why the installation spec runs first.
entrypoint.sh takes the Joomla base folder, a test group name, the database engine, the database host and the browser.
It rsyncs the working copy into /tests/www/<test group>/ (so several jobs can share one image), turns on
Apache’s rewrite module, excludes configuration.php from the opcode cache, copies cypress.config.dist.mjs to
cypress.config.mjs if necessary, and finally runs npx cypress run with --expose
cmsPath=…,db_type=…,db_host=…,db_prefix=<group>_,logFile=… and a baseUrl that contains the group name.
The CI workflow (.github/workflows/ci.yml) runs the specs on each of the following combinations, once with Chrome and
once with Edge.
| Test group | PHP | Database |
|---|---|---|
|
8.5 |
MySQL 9 |
|
8.5 |
MySQL 8.4 |
|
8.3 |
MySQL 8.0.13 (the oldest supported) |
|
8.4 |
MariaDB 12.0 |
|
8.3 |
MariaDB 10.4 (the oldest supported) |
|
8.5 |
PostgreSQL 18 |
|
8.3 |
PostgreSQL 12 (the oldest supported) |
The job uploads tests/System/output as the artifact system-test-output even when the tests fail, which is where the
screenshots of failed tests are found. The pairing of the newest PHP with the newest database and of the oldest PHP with
the oldest database is the standard way to cover a support range with few jobs; the same table is the right matrix for
an extension that declares Joomla 6.2 and PHP 8.3 as its minimum.
System tests for pkg_tallpine
An extension has its own Cypress project and drives a Joomla site that already exists (a container from
Local development with Docker or a CI service). The project depends
on cypress and joomla-cypress, reuses the Joomla helpers and adds a few commands of its own.
{
"name": "pkg-tallpine-tests",
"private": true,
"engines": { "node": ">=20.0.0" },
"scripts": {
"cypress:run": "cypress run --config-file cypress.config.mjs",
"cypress:open": "cypress open --config-file cypress.config.mjs"
},
"devDependencies": {
"cypress": "^15.18.0",
"joomla-cypress": "^2.0.0"
}
}
// cypress.config.mjs
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:8080',
specPattern: 'tests/System/integration/**/*.cy.js',
supportFile: 'tests/System/support/index.js',
screenshotsFolder: 'tests/System/output/screenshots',
video: false,
screenshotOnRunFailure: true,
allowCypressEnv: false,
},
// Values for joomla-cypress; the password comes from the shell, never from the repository.
expose: {
username: 'ci-admin',
password: process.env.TALLPINE_ADMIN_PASSWORD ?? 'CHANGE_ME',
email: 'admin@example.com',
name: 'Tallpine Admin',
sitename: 'Tallpine Test',
},
});
Source: joomla-cypress on GitHub; the layout of the configuration follows cypress.config.dist.mjs
joomla-cypress documents commands in five groups: user commands (doAdministratorLogin, doAdministratorLogout,
doFrontendLogin, doFrontendLogout, createUser), Joomla commands (installJoomla, installJoomlaMultilingualSite,
cancelTour, disableStatistics, setErrorReportingToDevelopment), extension commands (installExtensionFromFolder,
installExtensionFromUrl, installExtensionFromFileUpload, uninstallExtension, installLanguage, enablePlugin,
setModulePosition, publishModule, displayModuleOnAllPages), support commands (clickToolbarButton,
checkForPhpNoticesOrWarnings, checkForSystemMessage, searchForItem, setFilter, checkAllResults) and common
commands (createMenuItem, createCategory, selectOptionInFancySelect, toggleSwitch, iframe). The target site
needs the en-GB language for the administrator user used by the commands, because the commands find buttons by their
English labels.
The support file registers them and adds the global checks that core also has:
// tests/System/support/index.js
import { registerCommands } from 'joomla-cypress';
registerCommands();
// Fail a test when Joomla prints a PHP notice or warning into the page.
afterEach(() => cy.checkForPhpNoticesOrWarnings());
// Creates or reads the API token of the test administrator (same flow as the core support command api_getBearerToken).
Cypress.Commands.add('tallpine_apiToken', () => {
cy.session('tallpineApiToken', () => {
cy.doAdministratorLogin();
cy.visit('/administrator/index.php?option=com_users&view=users');
cy.get('a[href*="task=user.edit"]').first().invoke('attr', 'href').then((href) => {
const id = new URL(href, 'http://localhost').searchParams.get('id');
cy.visit(`/administrator/index.php?option=com_users&task=user.edit&id=${id}#attrib-joomlatoken`);
});
cy.get('#fieldset-joomlatoken').then((fieldset) => {
if (fieldset.find('#jform_joomlatoken_reset1').length > 0) {
cy.get('#jform_joomlatoken_reset1').click();
}
});
cy.clickToolbarButton('Save');
cy.get('#jform_joomlatoken_token').invoke('val').then((token) => {
window.localStorage.setItem('tallpineToken', token);
});
}).then(() => window.localStorage.getItem('tallpineToken'));
});
The first spec installs the package from a folder that the web server can read (the Compose file below mounts the build
output at /tmp/build) and checks that Joomla has registered every child extension.
// tests/System/integration/install/Install.cy.js
describe('Installing pkg_tallpine', () => {
it('installs the package and its children', () => {
cy.doAdministratorLogin();
cy.installExtensionFromFolder('/tmp/build/pkg_tallpine', 'package');
cy.checkForSystemMessage('Installation of the package was successful');
cy.visit('/administrator/index.php?option=com_installer&view=manage&filter[search]=tallpine');
['pkg_tallpine', 'com_tallpine', 'mod_tallpine_nexthikes', 'Content - Tallpine Conditions'].forEach((name) => {
cy.contains(name);
});
});
});
A back-end journey creates a trail through the real form, with the toolbar buttons and the system message that users see. The expected message is the translation of the component’s language string, which is another thing the spec protects.
// tests/System/integration/administrator/Trails.cy.js
describe('Tallpine trails in the administrator', () => {
beforeEach(() => {
cy.doAdministratorLogin();
});
it('creates a trail and lists it', () => {
cy.visit('/administrator/index.php?option=com_tallpine&task=trail.add');
cy.get('#jform_title').clear().type('Ridge loop');
cy.get('#jform_distance_km').clear().type('12.5');
cy.clickToolbarButton('Save & Close');
cy.checkForSystemMessage('Trail saved.');
cy.contains('Ridge loop');
});
it('refuses a trail without a title', () => {
cy.visit('/administrator/index.php?option=com_tallpine&task=trail.add');
cy.clickToolbarButton('Save');
cy.get('#jform_title').should('have.class', 'invalid');
});
});
Source: joomla-cms 6.2-dev: com_banners Banner.cy.js (the pattern of the Banners spec)
An API spec uses the token command and asserts on the JSON:API document.
// tests/System/integration/api/Trails.cy.js
describe('The Tallpine trails endpoint', () => {
it('returns published trails as JSON:API', () => {
cy.tallpine_apiToken().then((token) => {
cy.request({
method: 'GET',
url: '/api/index.php/v1/tallpine/trails',
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.api+json' },
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.data).to.be.an('array');
response.body.data.forEach((trail) => {
expect(trail.type).to.eq('trails');
expect(trail.attributes).to.have.property('title');
});
});
});
});
it('rejects a request without a token', () => {
cy.request({ url: '/api/index.php/v1/tallpine/trails', failOnStatusCode: false })
.its('status').should('eq', 401);
});
});
Source: joomla-cms 6.2-dev: com_banners API spec; the endpoints are described in Extending the Web Services API
Rules that keep such tests reliable:
-
Each spec creates and removes its own data. Core does it with
db_createArticleand friends and a globalcleanupDBtask; an extension without a database helper can use the Web Services API of the component, acy.taskthat runs a SQL statement with a Node database driver, ordocker exec ... php cli/joomla.phpin a task. -
A test must never depend on the order of the specs. Core’s only intentional exception is the installation spec.
-
Do not select by generated ids or by translated text where a stable attribute exists. Joomla form fields have predictable ids (
#jform_title), and toolbar buttons are found byclickToolbarButton()by label. -
Run with PHP error display on, as
setErrorReportingToDevelopmentdoes, and keep the check for PHP notices in everyafterEach. A page that renders while Joomla logs a deprecation is a bug the user will meet in 7.0. -
E-mails are checked against a test SMTP server, never a real one. Mailpit (see Local development with Docker) lets a spec read the message via its HTTP API; the core suite uses
smtp-tester.
Further Cypress techniques, such as network stubbing, sessions and component tests, are covered by the site’s Cypress core concepts, Cypress sessions and authentication and flaky-test guidance.
Testing on MySQL, MariaDB and PostgreSQL
Joomla supports MySQL, MariaDB (both through the mysqli or the pdomysql driver) and PostgreSQL (pgsql); Microsoft
SQL Server has not been supported since 4.0. The minimum versions differ between the Developer Manual (MariaDB 10.4 and
PostgreSQL 12 enforced, 10.6 and 14 supported) and the upgrade guide (10.6 and 14); the CI of core exercises MariaDB
10.4 and PostgreSQL 12 as the oldest and MariaDB 12.0 and PostgreSQL 18 as the newest, so those are the limits to test
against. Migrating a site from one engine to the other and downgrading are not supported, so a customer who runs your
extension on PostgreSQL cannot move to MySQL; this is the reason to test both from the first release.
The differences that tests should expose:
| Area | What differs |
|---|---|
Install and update SQL |
Two files per script: |
Data types |
|
Quoting |
|
Strictness |
PostgreSQL rejects a |
Booleans and counts |
Use |
Upsert |
|
A local Compose file that provides both families, and a runner that executes the integration suite on whichever one is selected, makes the matrix reproducible on a laptop. The database passwords come from the environment.
# docker-compose.yml
services:
mariadb:
image: mariadb:10.4
environment:
MARIADB_ROOT_PASSWORD: ${TEST_DB_PASSWORD:?set it in .env}
MARIADB_DATABASE: test_tallpine
MARIADB_USER: tallpine_ut
MARIADB_PASSWORD: ${TEST_DB_PASSWORD:?set it in .env}
tmpfs: [/var/lib/mysql]
ports: ["3307:3306"]
mysql:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${TEST_DB_PASSWORD:?set it in .env}
MYSQL_DATABASE: test_tallpine
MYSQL_USER: tallpine_ut
MYSQL_PASSWORD: ${TEST_DB_PASSWORD:?set it in .env}
tmpfs: [/var/lib/mysql]
ports: ["3308:3306"]
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: test_tallpine
POSTGRES_USER: tallpine_ut
POSTGRES_PASSWORD: ${TEST_DB_PASSWORD:?set it in .env}
tmpfs: [/var/lib/postgresql]
ports: ["5433:5432"]
joomla:
image: joomla:6.2-php8.4-apache
environment:
JOOMLA_DB_HOST: mariadb
JOOMLA_DB_USER: tallpine_ut
JOOMLA_DB_PASSWORD: ${TEST_DB_PASSWORD:?set it in .env}
JOOMLA_DB_NAME: test_tallpine
JOOMLA_ADMIN_USER: Test Admin
JOOMLA_ADMIN_USERNAME: ci-admin
JOOMLA_ADMIN_PASSWORD: ${TALLPINE_ADMIN_PASSWORD:?set it in .env}
JOOMLA_ADMIN_EMAIL: admin@example.com
JOOMLA_SITE_NAME: Tallpine Test
volumes:
- ./build:/tmp/build:ro
ports: ["8080:80"]
depends_on: [mariadb]
The same commands then cover each engine; only the environment variables change.
# .env (not committed): TEST_DB_PASSWORD and TALLPINE_ADMIN_PASSWORD
docker compose up -d mariadb mysql postgres
JTEST_DB_ENGINE=mysqli JTEST_DB_HOST=127.0.0.1 JTEST_DB_PORT=3307 \
JTEST_DB_PASSWORD="$TEST_DB_PASSWORD" ./vendor/bin/phpunit --testsuite Integration
JTEST_DB_ENGINE=pgsql JTEST_DB_HOST=127.0.0.1 JTEST_DB_PORT=5433 \
JTEST_DB_PASSWORD="$TEST_DB_PASSWORD" ./vendor/bin/phpunit --testsuite Integration
The Compose file for the site itself belongs to the local development page; here the point is that the database
services are throw-away (tmpfs) and that no password is written in the file. See
Compose for local development for .env handling.
A GitHub Actions matrix
The workflow runs the unit suite on PHP 8.3 to 8.5 and the integration suite on the edges of the database range. It
mirrors what core’s ci.yml does, with service containers for MariaDB and PostgreSQL. Pin the action versions with the
commit hashes that core uses when you want reproducible builds; the tags below are shortened for readability.
name: Tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.3', '8.4', '8.5']
steps:
- uses: actions/checkout@v6
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, gd, intl, pdo_mysql, mysqli, pgsql, pdo_pgsql
- name: Fetch a Joomla 6.2 checkout
run: |
git clone --depth 1 --branch 6.2-dev https://github.com/joomla/joomla-cms .joomla
composer install --working-dir=.joomla --no-progress
- run: composer install --no-progress
- run: JOOMLA_ROOT="$PWD/.joomla" ./vendor/bin/phpunit --testsuite Unit
integration:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { engine: mysqli, php: '8.3', image: 'mariadb:10.4', port: 3306 }
- { engine: mysqli, php: '8.5', image: 'mariadb:12.0', port: 3306 }
- { engine: pgsql, php: '8.3', image: 'postgres:12', port: 5432 }
- { engine: pgsql, php: '8.5', image: 'postgres:18', port: 5432 }
services:
db:
image: ${{ matrix.image }}
env:
MARIADB_ROOT_PASSWORD: ${{ secrets.TEST_DB_PASSWORD }}
MARIADB_DATABASE: test_tallpine
POSTGRES_PASSWORD: ${{ secrets.TEST_DB_PASSWORD }}
POSTGRES_DB: test_tallpine
ports: ['${{ matrix.port }}:${{ matrix.port }}']
steps:
- uses: actions/checkout@v6
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, gd, intl, mysqli, pgsql
- run: |
git clone --depth 1 --branch 6.2-dev https://github.com/joomla/joomla-cms .joomla
composer install --working-dir=.joomla --no-progress
composer install --no-progress
- name: Integration tests
env:
JOOMLA_ROOT: ${{ github.workspace }}/.joomla
JTEST_DB_ENGINE: ${{ matrix.engine }}
JTEST_DB_HOST: 127.0.0.1
JTEST_DB_PORT: ${{ matrix.port }}
JTEST_DB_USER: ${{ matrix.engine == 'pgsql' && 'postgres' || 'root' }}
JTEST_DB_PASSWORD: ${{ secrets.TEST_DB_PASSWORD }}
run: ./vendor/bin/phpunit --testsuite Integration
The JTEST_DB_PORT variable is read by the extension’s own DatabaseTestCase; the core helper does not use it. The Cypress job then follows the
steps of the extension’s build: build the package, start the joomla service with JOOMLA_EXTENSIONS_PATHS, wait for
the site, run npm run cypress:run and upload tests/System/output as an artifact on failure.
The Patch Tester
The Patch Tester is not for testing your extension; it is the tool that lets anyone test Joomla core pull requests on a test site without a Git checkout of the change. It matters to an extension developer for two reasons: it is how a bug you found in Joomla (a failing system test on a new minor release, for example) gets confirmed and merged, and it is the way to check an upcoming core change against your extension before it ships. The Developer Manual’s rule is that a change needs two testers who are not the code author, which is why Patch Tester is one of the most useful contributions a developer can make.
Setting up a test site
The Manual recommends a dedicated test installation per branch, either in GitHub Codespaces (a pre-configured browser environment) or locally. A local site is built from a clone of the repository, not from the release zip, because the release package lacks the source CSS and JavaScript needed to test changes to them.
git clone https://github.com/joomla/joomla-cms.git
mv joomla-cms jcms-62
cd jcms-62
git checkout 6.2-dev
composer install
npm ci
# then open http://localhost/jcms-62 and run the web installer
Source: Joomla Manual: Manual Testing
The Manual’s example uses a MySQL user created only for testing (a CREATE USER statement for a dedicated test account), one database
per branch, and suggests a list limit of 10 and a session lifetime of 60 minutes in Global Configuration. The web
installer’s installation folder is not removed, and extra languages are not installed during the first run. If
something goes wrong, empty the database and delete configuration.php and reinstall, or start with a new database and a
new clone.
Installing and configuring the component
Patch Tester (com_patchtester) is an ordinary component maintained in the joomla-extensions/patchtester repository;
the release described here is 5.0.1 (2026-01-25). It is installed like any extension and then connected to
GitHub.
-
Download the latest
com_patchtesterZIP from the Releases page of the repository. -
In the administrator, open System → Install → Extensions and upload the package.
-
Select Joomla Patch Tester in the administrator menu and click Options in the toolbar. On the GitHub Authentication tab paste a GitHub API token. A token is needed because the unauthenticated GitHub API allows too few requests; the Manual’s page does not explain how to create one. Give it no write scopes, and never commit or share it.
-
Optionally open the Patch Tester tab and set Advanced to Yes, to enable testing of patches that change npm or Composer dependencies.
-
Click Save & Close, then Fetch Data. After a few seconds the list of open pull requests appears.
Testing a pull request
-
Filter the list with Filter Options, for example by the label
bug, and open View on Joomla! Issue Tracker for a candidate. The issue tracker entry holds the Summary of Changes, Testing Instructions and the discussion; do not test while reviewers have unresolved concerns. -
Back in Patch Tester, click Apply Patch on the pull request. The button is hidden for pull requests with npm resource changes, Composer dependency changes or release-candidate changes; in Advanced mode you apply those by hand and run
npm ci(ornpm run build:cssornpm run build:jswhen only CSS or only JavaScript changed). -
Follow the testing instructions and look for side effects.
-
If the change works, return to the issue tracker and click Test This, fill in the form and submit your result. If it does not, say what happened.
-
Click Revert to remove the patch and, for patches that changed npm resources, run
npm ciagain.
Patch Tester also installs sample data on sites built from a repository clone (testing data, in English only), and the
blog and multilingual sample data that every installation has. Resetting means emptying the database and deleting
configuration.php. If installing a language fails with "Unable to detect manifest file.", the Manual describes a
workaround: open the language’s Details URL, copy the ZIP address from the XML, download the file and install it as an
ordinary extension.
Each pull request is also built by the CI as an installable package. The .drone.yml of the repository runs a package
pipeline that uploads the build to artifacts.joomla.org and posts a "Prebuilt packages are available for download"
status on the pull request, which is the alternative when the change cannot be applied with Patch Tester.
Security and discipline
-
Use Patch Tester only on a disposable test site. It downloads code from public pull requests, and a pull request is unreviewed code until the test passes.
-
Keep the GitHub token out of screenshots and bug reports, give it the least privilege it needs, and revoke it when the test site is deleted.
-
Never apply a patch to a production site or to a site that holds client data.
-
Record the result in the issue tracker; a pull request merges only after the required number of independent positive tests.
Version notes
| Version | Testing-relevant change |
|---|---|
Joomla 4.x |
Core system tests are Cypress specs; the Codeception tool of Joomla 3 is gone. PSR-12 is the coding style from 4.2. |
Joomla 5.0 |
Concrete event classes ( |
Joomla 6.0 |
|
Joomla 6.2 |
PHPUnit 12.5, PHP 8.3 to 8.5 in the unit and integration jobs, Cypress 15.x and |
Joomla 7.0 |
The automatic |
Common errors and pitfalls
-
Testing the framework, not the extension. A unit test that stubs the database, the application and the event and then asserts that the stubs were called proves nothing. Assert on the outcome (the changed text, the returned list).
-
Static calls that cannot be replaced.
Text::_(),Route::_(),Factory::getApplication()andUri::root()need a booted application. Isolate them in thin methods and cover them in system tests. -
Tests that pass only on MySQL. A SQL string with backticks,
LIMIT x,yorON DUPLICATE KEYpasses on a developer’s MariaDB and fails in the PostgreSQL job. Run both from the start. -
Shared state between specs. The system tests of the extension must not depend on data left by an earlier spec; clean up in
afterEach, not inafter. -
Hard-coded waits. Replace
cy.wait(5000)with an assertion that Cypress retries (should,contains) or with an aliasedcy.intercept()request. -
Running Cypress against a production site. The installation spec deletes
configuration.php. Point Cypress only at a throw-away site. -
Credentials in the repository. The values in
phpunit.xml.distandcypress.config.mjsare placeholders. Passwords and tokens come from environment variables or CI secrets. -
Ignoring the log. A system test that does not check the PHP error log passes while Joomla writes deprecations. Keep the
checkForPhpNoticesOrWarningsand log checks from core.
Security considerations
Test code and test data are part of the attack surface of a project. Use test databases that nobody else can reach, never
a copy of production data with real e-mail addresses, and never commit a configuration.php. CI jobs that receive secrets
should not run for forks' pull requests. A test installation of Joomla has a known administrator password by design; do
not expose it to the internet, and reset it when the container is destroyed. Remember that the System Tests install a
site with JOOMLA_INSTALLATION_DISABLE_LOCALHOST_CHECK set in CI: that variable switches off a safety check of the
installer and belongs in disposable containers only. Finally, treat the security checks of the extension as tests as
well: a Cypress spec that submits a form without a token and expects a rejection, and a unit test for every filter or
escape, are cheaper than the incident (see Security in extensions).