Joomla Magazine
Manual Index
PHPUnit Tests
Review
This article is the first of a series. It covers testing in general followed by details on PHPUnit tests.
Original Article
In this series, we will explore methods and tools to test a Joomla extension. In this first episode, we’ll focus on PHPUnit for running tests. A practical guide: how to set it up, what to test, and… what not to test.
Why testing?
Testing improves software reliability. Both the Joomla CMS and Joomla Framework packages include test suites. Best practice is to also test your custom extensions. Testing acts as a safety net, catching bugs before your users encounter them, and ensures that everything continues to function correctly when refactoring or adding new features.
Testing levels
Tests can vary in granularity, from small, individual parts (“units”) of your software to the entire application. This is called the test pyramid.
- Unit Tests focus on individual methods or functions in isolation. They are quick to run and form the basic tests for a developer. Unit tests verify that isolated parts of your software work correctly with specific inputs.
- Integration Tests examine how different parts of your extension work together and test the interaction between your extension and Joomla.These tests are slower than unit tests but provide confidence that individual parts of software work together correctly.
- End-to-End Tests validate complete user workflows as you would see it in the browser. They're the slowest to execute but provide the highest confidence that your extension works as users expect.
In this episode, we will use PHPUnit primarily for unit tests, but we will also show how to perform integration tests with it.
PHPUnit installation and preparation
PHPUnit can be installed in several ways. In Joomla CMS and Framework packages it is installed via Composer. We will follow that same approach for custom extensions. In the weblinks repository you can find an example of how to set it up for an extension.
Steps to install PHPUnit via Composer:
- Add PHPUnit to your developer dependencies in composer.json:
PHPUnit 12 needs PHP 8.3 (= minimum for Joomla 6). In Joomla’s composer.json at the moment you see PHPUnit required from version 9.6 onwards, as some dependencies may not yet be fully compatible with PHP 8.3."require-dev": { "phpunit/phpunit": "^12.4.0" } - Add a phpunit.xml configuration file to the root of your
repository. This tells PHPUnit where to find tests and helper files,
and defines some global setting.
Here, we specify the bootstrap file under the tests/unit directory, enable colour-coded test results, and indicate that all test files end with “Test.php”.<?xml version="1.0" encoding="UTF-8"?> <phpunit bootstrap="tests/unit/bootstrap.php" colors="true"> <testsuites> <testsuite name="My Unit Tests"> <directory suffix="Test.php">./tests/unit</directory> </testsuite> </testsuites> </phpunit> - Add a bootstrap.php file.
- Joomla extensions often rely on global
constants such as
_JEXECorJPATH_BASE. Because unit tests do not run within a full Joomla installation, you must define these constants to run your extension. - Often you’ll test a class that has been extended from a Joomla
class. For instance
MyModel extends \Joomla\CMS\MVC\Model\BaseDatabaseModel. PHPUnit must be able to locate that parent class. - Although some developers simulate all Joomla classes (“test doubles”), this is unnecessary: Joomla classes are already tested and you can just extend them. You only need to ensure they can be autoloaded. In the weblinks repository Joomla must be installed in the /joomla directory, so the tests can reference the original classes and autoloading.
- Joomla extensions often rely on global
constants such as
- Optional: Add an abstract UnitTestCase class. If you need the
same things in several tests, then you can make an abstract test
that extends from
\PHPUnit\Framework\TestCase. In the weblinks repository you’ll find such a UnitTestCase.php file, where agetQueryStub()method returns a\Joomla\Database\QueryInterface.
To install PHPUnit, clone the repository and run composer install.
Testing in isolation
PHPUnit tests run directly on your extension’s files. You don’t need to install the extension in Joomla first. You run your PHP-code in isolation.
As mentioned in point 3 above, installing Joomla in a directory allows easy access to base classes. This does not mean your extension runs inside Joomla; the code is still tested independently.
This differs from end-to-end tests, where the extension runs inside Joomla, and all interactions occur via the browser interface.
Dependencies and side effects
Dependencies are external classes required by your code, such as a database or user object. To isolate your code, inject these dependencies into your class, so they can be replaced by controlled implementations. For testing, we often replace them with test doubles, simplified objects with predictable behaviour.
As a rule of thumb, avoid using new inside your classes; inject
dependencies instead. With the exception of some classes in PHP’s global
namespace, like DateTime.
Side effects, changes to state outside the tested unit, should be isolated into separate methods that can be replaced with temporary test methods.
Writing testable code not only improves tests but also enhances software architecture, keeping changes in one part of the system isolated from others.
Test Doubles
To test a unit in isolation, replace dependencies with temporary objects that simulate behaviour.
- Stubs provide predefined responses to method calls. The default
method to create a stub in PHPUnit is
createStub().$db = $this->createStub(DatabaseInterface::class); $db->method('loadObjectList')->willReturn([1]); - Mocks allow you to track interactions, such as how often a method
is called. The default method to create a mock in PHPUnit is
createMock().
Both stubs and mocks are collectively called test doubles. If
default methods are insufficient, use getMockBuilder($className) to
customise them.
Creating the same database interface stub from above, but now with getMockBuilder():
$db = $this->getMockBuilder(DatabaseInterface::class)
->disableOriginalConstructor()
->disableOriginalClone()
->getMock();
$db->method('loadObjectList')->willReturn([1]);
Rule: use stubs and mocks minimally, just enough to isolate your unit.
What to test?
The CartModel has methods like getCart() (returns the logged-in
user’s cart) and addItem() (adds a product or updates its quantity).
Example-based testing
Use specific data examples to check if the program returns the expected output. Start with the happy path (valid inputs) and then test invalid inputs (negative testing) and edge cases (e.g., maximum items in the cart).
Property-based testing
Test properties that should always hold true (invariants), regardless of input. Examples:
- Retrieving the cart should never alter its content.
- No item should ever have a negative quantity.
- Items should not be listed twice; quantities should be cumulative.
Integration tests with PHPUnit
Integration tests examine how your extension interacts with Joomla. Unlike unit tests, you may use a real database instead of mocking all dependencies.
You can see some of those integration tests with PHPUnit in GitHub, with tests for Joomla’s Table object and for the Ldap authentication plugin.
Integration tests with PHPUnit let you choose what to mock, focusing precisely on one interaction.
You’ll see much more integration tests done with Cypress. Those tests will take more time to run, but they are easier to set up because they test the full system via the browser and require no mocking..
How to test
A unit test typically has 3 phases:
- Arrange (Setup): prepare the mocks, stubs and test-data.
- Act: call some methods that you want to test with the test-data.
- Assert: compare the output of the methods with what you expected.
There may also be a teardown phase to clean up.
Some excerpts of the tests for our CartModel.
/**
* Unit tests for CartModel
* @covers \MyCompany\Component\Shop\Site\Model\CartModel
*/
class CartModelTest extends TestCase
{
General setup of stubs, mocks and test data, to be used in multiple tests:
protected function setUp(): void
{
parent::setUp();
// Create stubs for dependencies that just need to return values
$this->stubQuery = $this->createStub(QueryInterface::class);
$this->stubUser = $this->createStub(User::class);
$this->stubSession = $this->createStub(SessionInterface::class);
$this->stubApplication = $this->createStub(CMSApplicationInterface::class);
$this->stubUserFactory = $this->createStub(UserFactoryInterface::class);
// Configure basic stub behavior
$this->setupBasicStubBehavior();
}
/**
* Setup basic stub behavior for common return values */
private function setupBasicStubBehavior(): void
{
// Query builder stub - just needs to return itself for chaining
$this->stubQuery->method('select')->willReturnSelf();
// Idem with the other methods of this Query builder stub.
// User stub - just needs to provide a user id
$this->stubUser->id = 123;
// User factory stub - just needs to return a user
$this->stubUserFactory->method('loadUserById')->willReturn($this->stubUser);
// Session stub - just needs to return session ID
$this->stubSession->method('getId')->willReturn('test_session_123');
// Application stub - just needs to return user identity
$this->stubApplication->method('getIdentity')->willReturn($this->stubUser);
}
Example-based test if the getCart() method returns the shopping cart of the logged-in user:
/**
* Test retrieving cart for logged-in user
*/
public function testGetCartReturnsCartWithItemsForLoggedInUser(): void
{
// Arrange - We don’t use the real database.
// Use stub because we only need return values.
$stubDatabase = $this->createStub(DatabaseInterface::class);
$stubDatabase->method('getQuery')->willReturn($this->stubQuery);
$expectedCart = $this->createTestCart(['user_id' => 123]);
$expectedItems = [
$this->createTestCartItem(['product_id' => 456, 'quantity' => 2]),
$this->createTestCartItem(['product_id' => 789, 'quantity' => 1, 'id' => 2])
];
// Stub returns data - we don't verify if setQuery was called
$stubDatabase->method('setQuery')->willReturn(null);
// In our code the cart is loaded with the loadAssoc() method
$stubDatabase->method('loadAssoc')
->willReturnOnConsecutiveCalls($expectedCart, null);
// The items are loaded with the loadAssocList() method
$stubDatabase->method('loadAssocList')->willReturn($expectedItems);
// Use stub for validator - just needs to return values.
$stubValidator = $this->createStub(ProductValidatorInterface::class);
// Instantiate the CartModel with our stubs.
$cartModel = new CartModel(
$stubDatabase,
$this->stubApplication,
$this->stubSession,
$this->stubUserFactory,
$stubValidator
);
// Act
$result = $cartModel->getCart();
// Assert - Focus on the RESULT, not the interactions
$this->assertIsArray($result);
$this->assertEquals(123, $result['user_id']);
$this->assertArrayHasKey('items', $result);
$this->assertCount(2, $result['items']);
$this->assertEquals(3, $result['total_items']);
$this->assertEquals(32.97, $result['total_amount']);
}
A property-based unit test:
/**
* Property: Cart values are always non-negative
*
* No matter what operations are performed, cart should never
* have negative quantities or amounts.
*/
public function testPropertyCartValuesNeverNegative(): void
{
// Perform random cart operations
$operations = ['add', 'update', 'delete'];
for ($iteration = 0; $iteration < 50; $iteration++) {
$this->setUp();
$this->setupCartWithRandomItems();
// Perform random operation
$operation = $operations[array_rand($operations)];
try {
switch ($operation) {
case 'add':
$this->setupSuccessfulAddScenario();
$this->cartModel->addItem(
$this->generateRandomProductId(),
rand(1, 20)
);
break;
case 'update':
$cart = $this->cartModel->getCart();
if (!empty($cart['items'])) {
$this->setupSuccessfulUpdateScenario();
$this->cartModel->updateItem(
$cart['items'][0]['id'],
rand(1, 20)
);
}
break;
case 'delete':
$cart = $this->cartModel->getCart();
if (!empty($cart['items'])) {
$this->setupSuccessfulDeleteScenario();
$this->cartModel->deleteItem($cart['items'][0]['id']);
}
break;
}
} catch (\Exception $e) {
// Some operations might fail, that's okay
continue;
}
$cart = $this->cartModel->getCart();
// PROPERTY: All values must be non-negative
$this->assertGreaterThanOrEqual(
0,
$cart['total_items'],
"Total items became negative (iteration {$iteration})"
);
$this->assertGreaterThanOrEqual(
0,
$cart['total_amount'],
"Total amount became negative (iteration {$iteration})"
);
foreach ($cart['items'] as $item) {
$this->assertGreaterThanOrEqual(
0,
$item['quantity'],
"Item quantity became negative (iteration {$iteration})"
);
}
}
}
An integration test with PHPUnit:
/**
* Database Integration
* Integrates: CartModel ←→ Database
*
* Tests that cart data is correctly written to and read from the database.
* N.B.: We use the real database!.
*/
public function testCartPersistsToDatabase(): void
{
// Act: Add item using CartModel
$this->cartModel->addItem($this->testProductIds[0], 2);
// Assert: Verify data exists in database
$query = $this->database->getQuery(true)
->select('*')
->from('#__shop_cart_items')
->where('product_id = ' . $this->testProductIds[0]);
$this->database->setQuery($query);
$item = $this->database->loadAssoc();
// Verifies CartModel correctly wrote to database
$this->assertNotNull($item, 'Cart item should exist in database');
$this->assertEquals(2, $item['quantity']);
$this->assertNotNull($item['created_at'], 'Timestamp should be set');
}
Test coverage fetishism
In PHPUnit you can automatically check if you have covered all your code with tests. Some managers or clients like that: they can easily check if everything is tested. But satisfying them with such easy metrics is the only thing for which this feature is useful. As a developer you should not use it, and especially not when making extensions on top of Joomla. Trying to unit test all code of your extension only leads to shallow tests that are not useful.
Every test should add to better development, lead to better understanding your code, and prevent bugs. Otherwise you better leave them out. Superfluous tests are only a maintenance burden. False positive tests are even dangerous. Testing is about quality, not quantity!
What not to test in Unit Tests?
- Parent code and frameworks:
Don’t test Joomla core or third-party libraries while testing your extension. Assume they work as intended; focus only on your own logic and integrations. Avoid testing APIs, databases, or file systems directly; use mocks or stubs. Unit tests should be isolated, fast, and deterministic. - Private/protected methods:
Avoid testing private or protected methods directly. Test them indirectly through the public interface that uses them. You can test private methods via reflection, but try to avoid that and just see the private method as an implementation detail. This is more a rule of thumb than set in stone; it could be beneficial to unit test a complex private method. - Static methods:
Be cautious with static methods: they’re hard to mock. Like private methods, test their effects via the public behavior that relies on them, not in isolation. In general: only use static methods sporadically as a stateless function library, without dependencies and side effects. - ** Implementation details**:
Test what the code does, not how it does it. Although this is hard with unit tests, try to not lock tests to internal structures, algorithms, or specific function calls. This allows refactoring without breaking tests. - Algorithm duplication:
Never re-implement the logic in your test. For example, if you sort data in production code, don’t copy the sorting algorithm in the test, but assert that the final result is correctly ordered. - Testing your test data:
Don’t validate the test setup itself. For example, if your test saves a valid JSON file, reads it back, and checks if it is a valid JSON file, you’re not proving anything about how your code handles that file.
Conclusion
Unit testing is a valuable tool for extension developers to verify that features work correctly and remain stable after code changes. Focus on testing observable *behaviour *rather than implementation details. As much as possible, test your code’s public contract, not its internals or dependencies.
Used wisely, unit tests improve code quality without slowing down development.
Next month
Next month we will explore Cypress. It is mainly used for end-to-end tests. With Cypress you test your extension as a user would, focusing on output rather than code specifics.
Notes
- The Practical Test Pyramid
- PHPUnit Installation
- Weblinks for Joomla!
- See PHPUnit Configuration for details of the PHPUnit configuration file.
- See the Weblinks bootstrap.php file and the CMS bootstrap.php file
- See the Weblinks UnitTestCase.php file and the Joomla CMS UnitTestCase.php file
- See Weblinks tests cypress integration and Joomla System Integration Tests for integration tests with Cypress.
- PHPUnit code coverage
- See Public vs private methods, accessing private fields, and many more.
- Some older documentation about unit tests in Joomla:
- Cypress