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.

flowchart TB S["Static checks: PHP-CS-Fixer, PHPCS, PHPStan"] --> U U["Unit tests: PHPUnit, no database, no web server<br/>stubs for container, database, application"] --> I I["Integration tests: PHPUnit with a real MySQL, MariaDB or PostgreSQL<br/>install SQL, queries, table classes"] --> Y Y["System tests: Cypress drives a real browser<br/>installed package, administrator forms, site pages, Web Services API"] --> M M["Manual tests: Patch Tester and a human<br/>for changes to Joomla core itself"]

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.

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 the JPATH_* 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() or Text::_() 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.

  • final means the class cannot be replaced by a PHPUnit double. Test the real object and replace its collaborators.

  • setDatabase() and setApplication() are public, so the collaborators are injected directly.

  • ContentPrepareEvent is 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 Dispatcher first (new ConfirmConsent($dispatcher, ['params' => []])), which CMSPlugin accepts 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 of CMSApplicationInterface instead. A stub of Language that returns 'test' for _() is how core tests keep Text::_() calls from failing.

  • Prefer a real value object over a stub. Registry, Date, Uri and 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 each require creates 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 after register() 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() or PluginHelper::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, a DifficultyCalculator) that receive their dependencies in the constructor, and unit-test those;

  • leave getListQuery() and getTable() 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

Class "Tallpine\..." not found

The extension’s namespace is not registered. In a plain checkout there is no namespacemap.php entry for it; add the prefix to the autoloader in tests/bootstrap.php.

Call to a member function ... on null from Factory::getApplication() or getIdentity()

The code under test pulls the application out of a global. Inject CMSApplicationInterface (or accept it through setApplication()), then pass a stub.

Error: Cannot mock final class ...

Core plugin classes and many framework classes are final. Test the real object, or depend on an interface and stub that.

The language string shows as PLG_CONTENT_TALLPINE_CONDITIONS_CLOSED

No language file is loaded in a unit test. Assert on the key, or stub Language::_().

Restricted access printed and the test run ends

A file under test calls die because _JEXEC is undefined. The core bootstrap defines it; make sure yours includes it before any class is loaded.

A deprecation fails the run

Joomla 6.x emits E_USER_DEPRECATED for 7.0 removals; remove the use or lower failOnDeprecation. Never silence the deprecation inside production code.

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:

  • IntegrationTestCase extends PHPUnit\Framework\TestCase and, in setUp(), calls DBTestHelper::setupTest($this) when the test implements DBTestInterface;

  • DBTestTrait supplies setDBDriver(), getDBDriver() and getSchemasToLoad(), whose default is ['datasets/framework.sql'];

  • DBTestHelper creates the driver once through DatabaseFactory::getDriver() with a table prefix of jos plus the PHP major and minor version (jos83_ on PHP 8.3), then reads the SQL files named by the test from tests/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);
    }
}

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 joomla image installs a package at first start (JOOMLA_EXTENSIONS_PATHS) and later with php cli/joomla.php extension:install --path=.... A shell script then asserts on extension: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.php or 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 request test calls the endpoints that plg_webservices_tallpine registers 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 with db_) that create content in the database, and API commands (names that start with api_) that call the Web Services API. The npm package joomla-cypress (declared as "joomla-cypress": "^2.0.0") adds Joomla-specific commands such as doAdministratorLogin and installExtensionFromFolder. The package smtp-tester runs 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, getMails and clearEmails. Tasks are asynchronous and have no cy. namespace.

  • The first spec, tests/System/integration/install/Installation.cy.js, deletes configuration.php so that the web installer runs, installs Joomla and then edits configuration.php (SMTP settings, the API secret). The installation folder must therefore not be deleted, or the suite cannot be run twice. The installer creates configuration.php read-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"
  }
}

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

administrator/components/com_foo or site/components/com_foo

Module

administrator/modules/mod_foo or site/modules/mod_foo

Plugin

plugins/<type>/<name>

Web Services API

api/com_foo

CLI command

cli

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:open opens 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 and ELECTRON_ENABLE_LOGGING=1.

  • Cypress commands and tasks are asynchronous, so always chain them with .then(). Do not use cy.wait(20000) in committed tests; it is only a debugging aid.

  • If the web server and Cypress run as different users, writing configuration.php fails with EACCES or, on Windows, EPERM. Run Cypress as a user that may write the file (the README suggests sudo, with CYPRESS_CACHE_FOLDER set to your own cache), or serve the site as the same user.

  • EADDRINUSE on port 1025 means that smtp_port is in use (Mailpit, from the local Docker setup, uses the same port); choose another.

  • On slow machines raise defaultCommandTimeout in 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

cmysqlmax

8.5

MySQL 9

cmysqllts

8.5

MySQL 8.4

cmysql

8.3

MySQL 8.0.13 (the oldest supported)

cmariamax

8.4

MariaDB 12.0

cmaria

8.3

MariaDB 10.4 (the oldest supported)

cpostgresmax

8.5

PostgreSQL 18

cpostgres

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);
  });
});

Rules that keep such tests reliable:

  • Each spec creates and removes its own data. Core does it with db_createArticle and friends and a global cleanupDB task; an extension without a database helper can use the Web Services API of the component, a cy.task that runs a SQL statement with a Node database driver, or docker exec ... php cli/joomla.php in 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 by clickToolbarButton() by label.

  • Run with PHP error display on, as setErrorReportingToDevelopment does, and keep the check for PHP notices in every afterEach. 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: sql/install.mysql.utf8.sql and sql/install.postgresql.utf8.sql, and two update folders (sql/updates/mysql and sql/updates/postgresql), selected by the driver attribute in the manifest.

Data types

int unsigned, tinyint and datetime are MySQL types. PostgreSQL uses integer, smallint, timestamp and serial or identity columns. Store missing dates as NULL, not as a zero date, which PostgreSQL rejects.

Quoting

quoteName() produces backticks on MySQL and double quotes on PostgreSQL. A hard-coded backtick in a string breaks on PostgreSQL.

Strictness

PostgreSQL rejects a GROUP BY that lists too few columns, compares text case-sensitively in LIKE (use LOWER() or ILIKE through the query builder helpers) and does not coerce a string to an integer silently.

Booleans and counts

Use ParameterType::INTEGER or ParameterType::BOOLEAN in bind() and cast results; numbers come back as strings on some drivers.

Upsert

ON DUPLICATE KEY UPDATE exists only on MySQL and MariaDB, ON CONFLICT only on PostgreSQL. Write the logic in PHP or branch on getServerType().

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

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.

  1. Download the latest com_patchtester ZIP from the Releases page of the repository.

  2. In the administrator, open System → Install → Extensions and upload the package.

  3. 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.

  4. Optionally open the Patch Tester tab and set Advanced to Yes, to enable testing of patches that change npm or Composer dependencies.

  5. Click Save & Close, then Fetch Data. After a few seconds the list of open pull requests appears.

Testing a pull request

  1. 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.

  2. 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 (or npm run build:css or npm run build:js when only CSS or only JavaScript changed).

  3. Follow the testing instructions and look for side effects.

  4. 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.

  5. Click Revert to remove the patch and, for patches that changed npm resources, run npm ci again.

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 (ContentPrepareEvent and others) replace argument arrays, which makes plugin handlers easy to call directly in a test. Database access in plugins and models moves to DatabaseAwareTrait.

Joomla 6.0

JPATH_PLATFORM is no longer defined and the legacy J* aliases move behind the Backward Compatibility 6 plugin, so a test that relies on them fails on a default 6.x install. AdminModel::getItem() returns stdClass.

Joomla 6.2

PHPUnit 12.5, PHP 8.3 to 8.5 in the unit and integration jobs, Cypress 15.x and joomla-cypress 2.x in the system tests, Node.js 20 or later, database matrix MySQL 8.0.13 to 9, MariaDB 10.4 to 12.0, PostgreSQL 12 to 18.

Joomla 7.0

The automatic $app and $db properties of plugins are removed, and setError() calls are dropped from core; tests that pass today only through compatibility code fail there.

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() and Uri::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,y or ON DUPLICATE KEY passes 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 in after.

  • Hard-coded waits. Replace cy.wait(5000) with an assertion that Cypress retries (should, contains) or with an aliased cy.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.dist and cypress.config.mjs are 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 checkForPhpNoticesOrWarnings and 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).