4.0.3 (2026-09-17)
- bugfix: a user built by
buildVisitor()- and so byactingAsMember()andactingAsGuest()- inherited an administrator record from the forum the tests run against. XenForo's guest user pre-hydratesOption,ProfileandPrivacybut notAdmin, so that relation lazy-loaded byuser_id, andactingAsMember()defaults touser_id1. A built user withis_adminset therefore reported whatever admin permissions your own forum grants at that id, so a route or service guarded byassertAdminPermission()passed without the test granting anything, and the same test could fail on someone else's forum.hasAdminPermission()is now always false for a built user; pass a user you loaded yourself toactingAs()if you need a real administrator
4.0.2 (2026-09-10)
- bugfix: a faked response reached the caller with its body already read, so
$response->getBody()->getContents()returned an empty string under bothfakesHttp()andfakesHttpByUrl(). XenForo'sXF\Http\Readeralways gives Guzzle a sink -php://tempwhen you pass no$saveTo- and both fakes read the body to the end to write it there; Guzzle's ownMockHandler, behindfakesHttp(), has the identical code. Real Guzzle returns the rewound sink as the body, so production was never affected.getContents()is how XenForo core reads a response, so an add-on reading one the same way could not be tested through either fake. A(string)cast was unaffected, because it seeks to the start before reading - which is also why nothing here caught it
3.0.6 (2026-09-10)
Backported from 4.0.2.
- bugfix: a response faked with
fakesHttp()reached the caller with its body already read, so$response->getBody()->getContents()returned an empty string. XenForo'sXF\Http\Readeralways gives Guzzle a sink -php://tempwhen you pass no$saveTo- and Guzzle'sMockHandler, behindfakesHttp(), reads the body to the end to write it there and does not rewind it. Real Guzzle returns the rewound sink as the body, so production was never affected.getContents()is how XenForo core reads a response, so an add-on reading one the same way could not be tested through the fake. A(string)cast was unaffected, because it seeks to the start before reading
4.0.1 (2026-09-05)
- docs: the README listed
isolateAddonamong the available helpers. It was removed in 3.0.0 and calling it is a fatal - use the$addonsToLoadproperty intests/TestCase.phpinstead- docs: the README's helper lists did not mention anything added in 4.0.0.
actingAs()and the visitor helpers,makeEntity()/createEntity(),fakesEvents(),fakesHttpByUrl(),setConfig(),UsesDatabaseTransactionsand the database assertions are all listed now- docs: the README notes that
swapFs()needsleague/flysystem-memoryin your ownrequire-dev- the scaffold ships
tests/Feature/ExampleTest.phpin place oftests/Feature/.gitkeep. It keeps the directory in git exactly as the.gitkeepdid, and additionally shows where feature tests go, the same waytests/Unit/ExampleTest.phpdoes. It guards nothing:failOnEmptyTestSuitefires on an empty run rather than an empty suite, so a Feature suite that collects nothing - or that holds a test PHPUnit never picked up - still exits 0 while the Unit suite passes. It also removes the one signal the.gitkeepgave:--testsuite Featureexited 1 on an empty directory and exits 0 once anything is in it. The README says both, and says to read the count from that command rather than its exit code
4.0.0 (2026-09-05)
- new
makeEntity()andcreateEntity()helpers for building entities with given values- new
fakesHttpByUrl()- chooses the http response by request URL rather than by call order- new
fakesEvents()- records code events and stops them reaching listeners, withassertEventFired(),assertEventFiredTimes(),assertEventNotFired()andassertNoEventsFired()- new
actingAs(),actingAsMember()andactingAsGuest()helpers - run a test as a given user, with permissions granted in memory rather than read from the database- new
setVisitorPermissions(),setVisitorContentPermissions()andbuildVisitor()helpers- new
setConfig()helper - set aconfig.phpvalue, whichsetOption()cannot do- new
UsesDatabaseTransactionstrait - wraps each test in a transaction and rolls it back, so tests can exercise real entity saves without leaving anything behind- new
assertDatabaseHas(),assertDatabaseMissing()andassertDatabaseCount()assertions- PHPUnit 11 and 12 are now supported
- bugfix:
fakesRegistry()failed with a fatal error -DataRegistrydid not match the XenForo 2.3 method signatures- bugfix: mail assertions failed with a
TypeErroronce any mail had been sent- bugfix:
assertJobQueued()with a count called a method that does not exist- bugfix:
assertExceptionLogged(),assertActionLogged()andassertChangeLogged()with a count asserted against the wrong value- bugfix:
Job\Manager::runByIds()returned null where an array was documented- bugfix:
DataRegistry::delete()called a cache method Symfony'sAdapterInterfacedoes not declare, so deleting a registry key fataled whenever the registry had been constructed with a cache. It now callsdeleteItems(), as XenForo itself doesJob\Manager::runQueue(),runUnique()andrunJobEntry()returnnullexplicitly, and the docblocks on_enqueue()andsetOptions()name the parameters those methods actually takeswapFs(),mockFs(),fakesRegistry()andbuildVisitor()throw aLogicExceptionif XenForo hands back a filesystem, registry or repository of an unexpected type, rather than fatalling on an undefined methodapp()throws aLogicExceptionwhen called before the application has been booted, rather than returning null for something else to fail on laterError::logException()accepts a non-throwable, as XenForo's own signature does, and turns it into anErrorException. The check that does so was previously unreachablesetOptions()is documented as returningXF\Options, which is what it has always returned; the docblock saidarray.swap()is documented as taking and returningmixed, which covers the closures and the config array it has always acceptedapp()is documented as returningHampel\Testing\App, which is the class the framework boots- bugfix: parameters are explicitly nullable, removing deprecation notices on PHP 8.4
- bugfix:
fakesHttpByUrl()ignored Guzzle'ssink, so code downloading to a file - which is what XF\Http\Reader::getUntrusted($url, $limits, $saveTo) does - received the response and wrote nothing, while every request assertion still passed- bugfix: a second
fakesHttp()orfakesHttpByUrl()call in one test had no effect, because XenForo's cachedreaderstill held the first fake's client; the failure surfaced later as "Mock queue is empty"- bugfix: every user built by
buildVisitor()/ actingAs* landed on permission combination id 1 - the forum's real guest combination. Permissions granted to one built user therefore applied to all of them, and a permission the test never granted was read from the development forum rather than denied, so the same test could pass on one forum and fail on another. Each built user now gets its own combination id; passpermission_combination_idyourself to opt out- bugfix:
fakesEvents()recorded event arguments by reference, since XenForo fires extension points as$app->fire('event', [&$args])and copying an array preserves the references in it. An assertion therefore saw whatever the caller left in the variable after the event, not what was fired. Arguments are now recorded as they were at the moment of firing; objects are still recorded as the same instance- bugfix:
fakesMail()did not disable mail queueing, so mail sent withqueue()was enqueued as aMailSendjob and never reached the test transport - the assertions then reported "The expected mail was not sent".enableMailQueueis aconfig.phpvalue, not an option, andsetOption()cannot reach it. Mail sent withsend()was unaffected, which is why this survived since 2024;queue()is what batch and job code normally calls. The package had no mail test at all - there is one now- bugfix:
swapFs()andmockFs()returned the real local filesystem adapter, rather than the fake, if anything had already resolved the filesystem. XenForo builds its mounts once and caches them underfs, so rewriting the config did not reach them. A test in that position read and wrote the real data directory - exactly the side effects the helpers exist to prevent - and nothing reported it- bugfix:
mockRepository()stored the mock under the identifier it was given, butgetRepository()normalises before looking one up. A spelling XenForo accepts everywhere else - 'XF:UserRepository', or the full class name - therefore registered a mock nothing consulted: the real repository ran and the unmet expectations were never reported. Identifiers are now normalised the same way XenForo normalises them- bugfix:
mockService()built an untyped Mockery double when the short name resolved to a class that does not exist, so a misspelled service name produced a test which passed while asserting against nothing. It now throws aLogicException. The mock is also typed as the class XenForo would really have built, resolved through the class alias map and the extension chainassertErrorLogged()andassertErrorNotLogged()no longer require a message, matchingassertExceptionLogged()- omit it to assert that any error at all was, or was not, logged- error and exception handlers are restored after each test. This is what makes PHPUnit 11 and 12 support possible rather than a separate fix:
XF::start()installs handlers and never removes them, which PHPUnit 11 onwards reports as risky on every test that boots XenForo. PHPUnit 10 does not report it, so the symptom cannot be reproduced on 3.0.3phpunit.xmlnow fails the suite on deprecations, notices, warnings, risky tests, PHPUnit's own deprecations (failOnPhpunitDeprecation) and a run which executes no tests (failOnEmptyTestSuite). The last one covers the only genuinely silent case: a suite that has stopped collecting tests exits 0 by default and reads as passing- the
tests/Featuredirectory is included, which PHPUnit requires in order to run. It ships a.gitkeepso that it survives being committed - git does not track empty directories, so atests/Featureyou create by hand is absent in every clone, including CI. Commit the.gitkeep- the
fakes*helpers andswapFs()now return the object they document, rather than the closure the container had not yet resolved- code style is now XenForo's own, applied with
xenforo-ltd/xf-cs-fixerleague/flysystem-memory2.0 and above are rejected - XenForo 2.3 ships Flysystem 1.x
Breaking changes:
- minimum PHP version is now 8.3. If your addon pins
config.platform.phpbelow 8.3, Composer cannot install this package at all - the solve fails outright. Raising that pin is not dev-only in effect: it also lets Composer select runtime dependencies above the PHP version your addon declares, and those ship in your release zip. After raising it, check that every package incomposer.lock'spackagesarray still satisfies your addon's own PHP floor, and cap any that do not. If you cannot raise it, the 3.x line carries the fixes above that apply to v3 - see 3.0.4 and 3.0.5- PHPUnit 12 is now allowed, and for most addons this package is the only thing that pins PHPUnit at all - so a composer update will select 12 where it used to select 10. PHPUnit 12 no longer reads metadata from doc comments, so tests using
@dataProvider,@depends,@coversor@grouperror rather than run. Convert them to attributes (#[DataProvider]and friends, understood by 10, 11 and 12) or pinphpunit/phpunityourself. PHPUnit 11 reports these as deprecations and still exits 0, which is why the suppliedphpunit.xmlnow setsfailOnPhpunitDeprecationmakeEntity()andcreateEntity()are new protected methods onTestCase. If your test classes already define a method of either name, PHP refuses to load them - a private helper cannot narrow a protected parent - and the run dies at class load withAccess level to ...before any test executes. Rename yours. Check which one you are matching before deleting yours to inherit ours:createEntity()saves andmakeEntity()does not, so a local helper that only builds maps tomakeEntity(). InheritingcreateEntity()in its place compiles, looks right, and silently turns every in-memory build into a database writephpunit.xmlhas been updated and should be re-copied into your addon - or diffed against yours if you have customised it, since re-copying discards your changes. The diff is small- the files in tests/ have been restyled, so a diff against your own copies will show formatting changes as well as the changes described above
3.0.5 (2026-09-04)
A documentation and packaging fix. No code changes to the framework itself.
- the
tests/Featuredirectory is now shipped, with a.gitkeepso it survives being committed.phpunit.xmldeclares a Feature test suite, and PHPUnit refuses to run at all when the directory is missing - it reportsTest directory "..." not foundand exits without running anything. Since git does not track empty directories, following the README exactly produced a suite that never ran- docs: the README's install command was
cprather thancp -r, which simply fails on a directory- docs: the README's
fakesMail()example was v1.x Swiftmailer code, swapping amailer.queuecontainer key that was removed in 3.0.0, and described the test transport as implementing\Swift_Transport. It extends Symfony Mailer'sAbstractTransport- docs: the README's
phpunit.xmlexample was the PHPUnit 9 format, with attributes that no longer exist in the PHPUnit 10 this package requires- docs:
isolateAddonwas still listed in the README as an available helper. It was removed in 3.0.0 - use the$addonsToLoadproperty intests/TestCase.phpinstead- docs:
swapFs()needsleague/flysystem-memoryin your ownrequire-dev, which neither the README nor DOCS.md mentioned - it is a Composer suggestion, so it is not installed for you- docs: the source and issue links pointed at Bitbucket; the package is on GitHub
3.0.4 (2026-09-04)
A maintenance release for the v3 line. Backported from the unreleased 4.0.0 branch, limited to helpers that exist in v3 — no new helpers, and no change to the PHP or PHPUnit requirements. If you can run PHP 8.3, prefer 4.x when it is released.
- bugfix:
fakesRegistry()failed with a fatal error —DataRegistrydid not match the XenForo 2.3 method signatures, so the class could not be declared. The helper has been unusable for the whole v3 line- bugfix: mail assertions failed with a
TypeErroronce any mail had been sent — the test transport assigned a string over the array the assertions count, which also meant only the last mail was kept- bugfix:
fakesMail()did not disable mail queueing, so mail sent withqueue()was enqueued as aMailSendjob and never reached the test transport — the assertions then reported "The expected mail was not sent".enableMailQueueis aconfig.phpvalue, not an option, andsetOption()cannot reach it. Mail sent withsend()was unaffected, which is why this went unnoticed;queue()is what batch and job code normally calls- bugfix:
assertJobQueued()with a count calledassertJobsQueuedTimes(), which does not exist- bugfix:
assertExceptionLogged(),assertActionLogged()andassertChangeLogged()with a count passed the count as the identifier, so they silently asserted something other than what was asked- bugfix:
swapFs()andmockFs()returned the real local filesystem adapter, rather than the fake, if anything had already resolved the filesystem. XenForo builds its mounts once and caches them underfs, so rewriting the config did not reach them. A test in that position read and wrote the real data directory — exactly the side effects the helpers exist to prevent — and nothing reported it- bugfix: a second
fakesHttp()call in one test had no effect, because XenForo's cachedreaderstill held the first fake's client; the failure surfaced later as "Mock queue is empty". The same applied to a first fake installed after the reader had resolved- bugfix:
mockRepository()stored the mock under the identifier it was given, butgetRepository()normalises before looking one up. A spelling XenForo accepts everywhere else —XF:UserRepository, or the full class name — therefore registered a mock nothing consulted: the real repository ran and the unmet expectations were never reported- bugfix:
mockService()built an untyped Mockery double when the short name resolved to a class that does not exist, so a misspelled service name produced a test which passed while asserting against nothing. It now throws aLogicException. The mock is also typed as the class XenForo would really have built, resolved through the class alias map and the extension chain- bugfix: the
fakes*helpers andswapFs()returned the closure handed toswap()rather than the object the container builds from it, so the documented return type was never what came back- bugfix:
Job\Manager::runByIds()returned null where an array was documented, and the docblocks referenced aHampel\Testing\Job\JobResultclass which has never existed- bugfix: parameters are explicitly nullable, removing deprecation notices on PHP 8.4
- docs:
DOCS.mdlistedassertMailQueued(),assertMailQueuedTimes(),assertMailNotQueued()andassertNoMailQueued(). None have existed since v3.0.0 removed the queue fake, and calling one is a fatal. ThefakesMailexample also asserted againstgetTo()as a Swiftmaileremail => namemap; it returns SymfonyAddressobjects, so the example could never match- an integration test suite has been added covering every fix above. It needs a XenForo install (
XF_ROOT=/srv/www/myforum composer integration), skips without one, and is export-ignored so it never reaches an addon
3.0.3 (2024-12-30)
- bugfix: some code related to mail queueing has been removed since we no longer use it
3.0.2 (2024-08-11)
- new expanded extension class — use global static maps to keep track of extensions and aliases between test runs
v3.0.0 of the Unit Test Framework has been released with compatibility for XF 2.3. Updates are via Composer.
XenForo 2.1 addons should use Unit Test Framework v1.x
XenForo 2.2 addons should use Unit Test Framework v2.x
XenForo 2.3 addons should use Unit Test Framework v3.x
Changes:
- update Job manager to work with changes for XF 2.3 - particularly the new JobParams class
- update mail handling to work with Symfony mail
- remove mail queue support - we now simply disable queueing in the config to force all emails to be sent via our test transport
v2.1.0 of the Unit Test Framework has been released. Updates are via Composer.
Note breaking changes:
Testing helper functionisolateAddonhas been removed in favour of a new variable set inTestCase.php
When updating to the newest version of the framework, you'll need to remove any references to isolateAddon and ensure that the updated versions ofTestCase.phpandCreatesApplication.phpare copied across from the updated package.
Specifically, the following lines:
TestCase.php
PHP:protected $addonsToLoad = [];
CreatesApplication.php
PHP:$options['xf-addons'] = $this->addonsToLoad ?: []; return \XF::setupApp('Hampel\Testing\App', $options);
Please read the documentation - in particular section 9 on "Configuring the Framework" for more information.
XenForo 2.2 implements Swiftmail 6, which changes some of the method/interface signatures - so I've had to push a new major release for XF 2.2 compatibility.
XenForo 2.1 addons should use Unit Test Framework v1.x
XenForo 2.2 addons should use Unit Test Framework v2.x
- bugfix: fixed typo in function name
assertSimpleCacheEqual=>assertSimpleCacheEquals- bugfix: close the database connection on tearDown to avoid connection limit issues (unless it's been mocked)
We use essential cookies to make this site work, and optional cookies to enhance your experience.