Developers
Testing
Hubzero has a working PHPUnit suite across the framework libraries, seven components and two plugins, plus two custom linters that catch faults nothing else does. This page says what is there, how to run it, what is worth adding to it, and how to run one test instead of all of them.
A full run on 2.4-main passes:
Tests: 855, Assertions: 2905, PHPUnit Deprecations: 83, Skipped: 6.
The deprecations come from PHPUnit 11 warning about older test syntax, not from failures. Six tests skip themselves when what they need is absent.
What is worth testing
The suite is not a safety net over the whole CMS and pretending otherwise wastes your afternoon. It is 855 tests over a codebase of several thousand files, and it splits sharply:
| Suite | Tests | What it covers |
|---|---|---|
libraries |
694 | The framework: the query builder, Relational, the config registry and its processors, the container, facades, cache, encryption, utility string and array helpers |
components |
150 | Seven components, and mostly their pure helpers — search query building, a migration's column statements, a publication bundle builder, SAML metadata |
plugins |
11 | Two plugins, one method each |
Everything in that table has the same shape: a class you can construct with
new, hand some input, and check the output of. That is what this suite is
good at, and it is what to write a test for.
What is not covered, and what a test here cannot easily reach:
- Controllers. Nothing tests a site, administrator or API controller
end to end. A controller reaches for
Request,User,Config, the document and the session, and the test bootstrap gives you a container with enough in it to load the file, not enough to serve a request. - Views. No layout is rendered by any test.
- Permissions. No test exercises
User::authorise()or an access level. A change to aWHEREclause that widens who can read a row will not be caught here; say what it now allows in the pull request instead. - MySQL.
Hubzero\Test\Databaseruns against SQLite, so a test proves the query builder produced something SQLite accepted. It does not prove MySQL accepts it, and it does not exercise a migration.
So the practical rule for a new extension — say a component that books time on a lab instrument — is: put the logic that decides something in a class of its own and test that. The overlap rule the booking has to enforce, the policy that says who may cancel, the parser for the reservation code: those are testable, they are where the bugs will be, and testing them costs you nothing in fixtures. The controller that calls them is checked by using it.
What exists
| Tool | Where | What it does |
|---|---|---|
| PHPUnit 11.5 | core/vendor/bin/phpunit |
The test runner |
core/phpunit.xml.dist |
The shipped configuration: three suites | |
muse test |
core/bin/muse |
Lists and runs one extension's tests |
tools/lint/missing-facade-imports.php |
Finds unqualified facade calls in namespaced files | |
tools/lint/undefined-language-keys.php |
Finds language keys nothing defines | |
core/bin/php_tests.sh |
PSR-12 style plus a syntax check, over a list of files | |
.github/workflows/php-lint.yml |
CI: php -l over core and app, the facade linter, and the language-key ceiling |
|
.github/workflows/tests.yml |
CI: the PHPUnit suite |
Tests are named *Test.php and live in a Tests or tests directory
inside the thing they test:
core/libraries/Hubzero/Database/Tests/QueryTest.php
core/libraries/Hubzero/Config/Tests/RegistryTest.php
core/components/com_blog/tests/EntryTest.php
core/components/com_search/tests/boostQueryHelperTest.php
core/plugins/user/hubzero/tests/HubzeroIsThirdPartyPlaceholderTest.php
Running them
Everything, through the shipped configuration:
cd core
vendor/bin/phpunit -c phpunit.xml.dist
Or one suite at a time — libraries, components, plugins:
vendor/bin/phpunit -c phpunit.xml.dist --testsuite libraries
The libraries suite covers libraries/Hubzero and excludes three
production classes that happen to be named Test.php. components globs
components/*/tests and components/*/helpers/tests; plugins globs
plugins/*/*/tests.
Running one test
A full run takes about half a minute, which is short enough to sit through and long enough that you will stop doing it while you iterate. Two ways to narrow it.
By file — pass the path after the configuration:
cd core
vendor/bin/phpunit -c phpunit.xml.dist libraries/Hubzero/Utility/Tests/InflectorTest.php
By name — --filter takes a method name, a class name, or a regular
expression matching either:
vendor/bin/phpunit -c phpunit.xml.dist --filter testBasicFetch
vendor/bin/phpunit -c phpunit.xml.dist --filter QueryTest
Run both from core. PHPUnit finds phpunit.xml.dist there without being
told, so -c is belt and braces — but only there. Run the same command from
the installation root and PHPUnit picks up a root phpunit.xml if you have
one, which is a different configuration with a different bootstrap and
different suite names. That filename is in .gitignore, so a root
phpunit.xml is yours, not the project's, and what it does is not what CI
does.
Through muse
Muse wraps the runner for one extension at a time.
muse test show lists what can be run:
$ php core/bin/muse test show
lib_base
lib_cache
lib_config
lib_database
…
core:com_blog
core:com_courses
core:com_groups
core:plg_authentication_orcid
core:plg_user_hubzero
Names are the extension with its prefix — com_, mod_, plg_{group}_,
tpl_, or lib_ for a framework subsystem — with core: or app: in front
of everything but a library, because the same extension can exist in both
trees.
php core/bin/muse test run lib_config
run requires an extension; there is no way to run everything through muse.
Use PHPUnit directly for that.
Writing a test
Two base classes, both real PHPUnit 11 test cases.
Basic
Hubzero\Test\Basic extends
PHPUnit\Framework\TestCase and adds nothing. If your test needs no
database, extend it — or extend TestCase directly — and follow the
PHPUnit documentation.
Database
Hubzero\Test\Database is
for tests that need a driver. It gives you a real, throwaway SQLite database
rather than a connection to anyone's development server:
public function testBasicFetch()
{
$dbo = $this->getMockDriver();
$query = new Query($dbo);
$rows = $query->select('*')
->from('users')
->whereEquals('id', '1')
->fetch();
$this->assertCount(1, $rows, 'Query should have returned one result');
}
getMockDriver() returns a fully functioning driver over a SQLite file. Two
fixtures back it, in a Fixtures directory beside the test:
Tests/Fixtures/test.sqlite3
Tests/Fixtures/seed.xml
test.sqlite3 supplies the schema; seed.xml supplies the rows, reloaded
for each test class. Override $fixture and $seed on the test class to
use different filenames, or override getDataSet() for anything more
involved.
Hubzero\Test\Database also bootstraps the facades — it adopts the
container the bootstrap installed rather than replacing it, and registers an
event dispatcher — so a model that calls Event::trigger() at file scope
does not fatal.
Scaffolding
php core/bin/muse scaffolding create test lib_database --type=database
The first argument after test is the extension; --type is basic or
database.
The linters
Two faults are invisible to PHP's own syntax check and to any test that does not happen to execute the affected line. Both have a linter.
Missing facade imports
The CMS registers Route, Lang, User, Config and the rest as
root-namespace aliases. Inside a namespaced file, an unqualified
Route::url() resolves to Current\Namespace\Route and fatals when the line
runs. The file parses; nothing complains until that branch executes, which
on a rarely used error path can be years.
php tools/lint/missing-facade-imports.php # core components, plugins, modules, libraries
php tools/lint/missing-facade-imports.php --fix # insert the missing `use` statements
It reads the file with PHP's tokenizer, so a name in a comment, a string or a heredoc is not counted. It exits non-zero on a finding, and the PHP lint workflow runs it on every push. The tree is currently clean; 730 of these were fixed at once, and this is what keeps them from coming back.
Undefined language keys
Lang::txt() returns its argument unchanged when the key is not found, so a
missing string is not an error — the raw key is printed into the page.
php tools/lint/undefined-language-keys.php
php tools/lint/undefined-language-keys.php core/components/com_blog
A key counts as defined if any en-GB file anywhere under core/ or app/
defines it, which is deliberately generous: only a handful of files load per
request, so a key defined in some other extension may still fail at runtime.
Keys built at runtime ('COM_X_' . strtoupper($type)) cannot be checked and
are skipped, so a clean run does not prove every string resolves.
It runs in CI against a ceiling rather than against zero. 444 keys are
undefined today, so --max=444 in the workflow lets the check pass while
refusing to let the number grow. Define the keys your change asks for, and
when you have fixed some, lower the ceiling so the count cannot creep back
up.
Continuous integration
Three workflows run on GitHub:
php-lint.yml— on every push to2.4-mainand on pull requests touching any.phpfile. It runsphp -lover every PHP file incoreandappoutsidevendor, then the facade linter, then the language-key linter against its ceiling.tests.yml— the PHPUnit suite, on the same events, for changes undercore. It installs the Composer dependencies, caching them againstcomposer.lock, and runsvendor/bin/phpunit -c phpunit.xml.dist.pages.yml— builds this documentation, runs the builder's own Python tests, regenerates the references and fails if the committed copy is stale, and checks every internal link.
A third, dev-push.yml, deploys to a Purdue development host and is
disabled (if: false).
Run the suite before sending a change anyway; CI is the backstop, not the first line.
What the old page claimed that is not true
This page was imported from help.hubzero.org and described the 2015 state. For the record:
- Tests extend
PHPUnit\Framework\TestCase, notPHPUnit_Framework_TestCase. PHPUnit here is 11.5, not 4.6. - The "tests are only supported in individual extensions with 2.1.10+" note is long spent. Extension tests work and several ship.
muse test showoutput has changed; the list above is the real one.
Two loose ends worth knowing about:
tests/Unitandtests/Featurein the installation root are empty, and no configuration file refers to them. They are a stub of a layout that was never adopted. Do not put tests there.- A
phpunit.xmlin the root, if you have one, is yours: the filename is in.gitignore.core/phpunit.xml.distis the shipped configuration, and it is the one to change if the change should reach other people.
Rewritten and checked against 2.4-main @ 348f0057c2 on 2026-09-10.