Resource icon

Unit testing XenForo addons - tutorial

Sim updated Unit testing XenForo addons - tutorial with a new update entry:

v2.1.0 release

v2.1.0 of the Unit Test Framework has been released. Updates are via Composer.

Note breaking changes:

Testing helper function isolateAddon has been removed in favour of a new variable set in TestCase.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 of TestCase.php and CreatesApplication.php are copied across from the updated...

Read the rest of this update entry...
 
Sim updated Unit testing XenForo addons - tutorial with a new update entry:

v3.0.0 release

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

Read the rest of this update entry...
 
/var/www/html/src/addons/Earl/WPVerification # phpunit
Code:
PHP Fatal error:  Uncaught Error: Class "Tests\TestCase" not found in /var/www/html/src/addons/Earl/WPVerification/tests/Unit/ExampleTest.php:5
Stack trace:
#0 /root/.composer/vendor/phpunit/phpunit/src/Util/FileLoader.php(66): include_once()
#1 /root/.composer/vendor/phpunit/phpunit/src/Util/FileLoader.php(49): PHPUnit\Util\FileLoader::load('/var/www/html/s...')
#2 /var/www/html/src/addons/Earl/WPVerification/vendor/phpunit/phpunit/src/Framework/TestSuite.php(398): PHPUnit\Util\FileLoader::checkAndLoad('/var/www/html/s...')
#3 /var/www/html/src/addons/Earl/WPVerification/vendor/phpunit/phpunit/src/Framework/TestSuite.php(537): PHPUnit\Framework\TestSuite->addTestFile('/var/www/html/s...')
#4 /var/www/html/src/addons/Earl/WPVerification/vendor/phpunit/phpunit/src/TextUI/TestSuiteMapper.php(67): PHPUnit\Framework\TestSuite->addTestFiles(Array)
#5 /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php(393): PHPUnit\TextUI\TestSuiteMapper->map(Object(PHPUnit\TextUI\XmlConfiguration\TestSuiteCollection), '')
#6 /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php(114): PHPUnit\TextUI\Command->handleArguments(Array)
#7 /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php(99): PHPUnit\TextUI\Command->run(Array, true)
#8 /root/.composer/vendor/phpunit/phpunit/phpunit(107): PHPUnit\TextUI\Command::main()
#9 /root/.composer/vendor/bin/phpunit(122): include('/root/.composer...')
#10 {main}

Next PHPUnit\TextUI\RuntimeException: Class "Tests\TestCase" not found in /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php:101
Stack trace:
#0 /root/.composer/vendor/phpunit/phpunit/phpunit(107): PHPUnit\TextUI\Command::main()
#1 /root/.composer/vendor/bin/phpunit(122): include('/root/.composer...')
#2 {main}
  thrown in /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php on line 101

Fatal error: Uncaught Error: Class "Tests\TestCase" not found in /var/www/html/src/addons/Earl/WPVerification/tests/Unit/ExampleTest.php:5
Stack trace:
#0 /root/.composer/vendor/phpunit/phpunit/src/Util/FileLoader.php(66): include_once()
#1 /root/.composer/vendor/phpunit/phpunit/src/Util/FileLoader.php(49): PHPUnit\Util\FileLoader::load('/var/www/html/s...')
#2 /var/www/html/src/addons/Earl/WPVerification/vendor/phpunit/phpunit/src/Framework/TestSuite.php(398): PHPUnit\Util\FileLoader::checkAndLoad('/var/www/html/s...')
#3 /var/www/html/src/addons/Earl/WPVerification/vendor/phpunit/phpunit/src/Framework/TestSuite.php(537): PHPUnit\Framework\TestSuite->addTestFile('/var/www/html/s...')
#4 /var/www/html/src/addons/Earl/WPVerification/vendor/phpunit/phpunit/src/TextUI/TestSuiteMapper.php(67): PHPUnit\Framework\TestSuite->addTestFiles(Array)
#5 /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php(393): PHPUnit\TextUI\TestSuiteMapper->map(Object(PHPUnit\TextUI\XmlConfiguration\TestSuiteCollection), '')
#6 /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php(114): PHPUnit\TextUI\Command->handleArguments(Array)
#7 /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php(99): PHPUnit\TextUI\Command->run(Array, true)
#8 /root/.composer/vendor/phpunit/phpunit/phpunit(107): PHPUnit\TextUI\Command::main()
#9 /root/.composer/vendor/bin/phpunit(122): include('/root/.composer...')
#10 {main}

Next PHPUnit\TextUI\RuntimeException: Class "Tests\TestCase" not found in /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php:101
Stack trace:
#0 /root/.composer/vendor/phpunit/phpunit/phpunit(107): PHPUnit\TextUI\Command::main()
#1 /root/.composer/vendor/bin/phpunit(122): include('/root/.composer...')
#2 {main}
  thrown in /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php on line 101

Please help...
I'm running php version 8.0, xenforo version 2.2
Looks like the class is not autoloading

ahh nevermind, this was a silly mistake, i missed the:
JSON:
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }

part in composer.json
 
Last edited:
Okay, here is the real problem I'm facing now.
I have added a new API endpoint, and the controller class that belongs to it has the "actionPost" method.
what is the correct procedure to create a controller instance for the testing purpose of this actionPost method?
edit:
maybe I asked another silly question, I found it extremly tricky. I have to use the mockbuilder and mock everything inside actionPost()
 
Last edited:
Okay, here is the real problem I'm facing now.
I have added a new API endpoint, and the controller class that belongs to it has the "actionPost" method.
what is the correct procedure to create a controller instance for the testing purpose of this actionPost method?
edit:
maybe I asked another silly question, I found it extremly tricky. I have to use the mockbuilder and mock everything inside actionPost()

As a general design principal, your controllers should be as "thin" as possible - about the only thing they should be doing is coordinating everything (some simple branching logic), and dealing with errors.

Most of the work should be done by your repository or service classes - which are much easier to test.

I suggest refactoring your code to move all of your business logic out of your controller into other classes.

I generally don't unit test my controllers because there isn't really anything in them worth testing. Doing feature tests using HTTP queries to test the controller function would be the ideal way to test this - but that's not something my framework currently has the ability to facilitate.
 
I have just recently learned the ways and love of repositories and service classes now that I can read through the XF code a bit better lol. I'm working on moving all of my logic there currently so it was nice to see your message above @Sim as I was also planning on using your unit testing setup to my advantage.
 
I tried to mock the user entity
PHP:
$user_mock = $this->mockEntity('XF:User');
but I'm getting this error:
Code:
Error: Call to a member function em() on null
 /opt/project/code/src/addons/Earl/PV/vendor/hampel/xenforo-test-framework/src/Concerns/InteractsWithEntityManager.php:67
 /opt/project/code/src/addons/Earl/PV/tests/Unit/WhatsAppTest.php:38
 /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php:146
 /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php:99


what am I doing wrong?
What I'm trying to do is mock the user object's IP address.
Sorry, I keep coming to this thread because I do not have many example codes to get some ideas on how to UnitTest on XenForo. :(
 
Last edited:
I tried to mock the user entity
PHP:
$user_mock = $this->mockEntity('XF:User');
but I'm getting this error:
Code:
Error: Call to a member function em() on null
 /opt/project/code/src/addons/Earl/PV/vendor/hampel/xenforo-test-framework/src/Concerns/InteractsWithEntityManager.php:67
 /opt/project/code/src/addons/Earl/PV/tests/Unit/WhatsAppTest.php:38
 /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php:146
 /root/.composer/vendor/phpunit/phpunit/src/TextUI/Command.php:99


what am I doing wrong?
What I'm trying to do is mock the user object's IP address.
Sorry, I keep coming to this thread because I do not have many example codes to get some ideas on how to UnitTest on XenForo. :(
Ah! I found the reason.
I'm supposed to add
parent::setUp(); inside setUp()
otherwise $this->app does not work.
 
  • Like
Reactions: Sim
I will share something related to unit testing here with everyone who's interested in mocking $this->app()->http()->client() in a different way:

I have this line in my addon code, and I am going to mock the response as opposed to sending the HTTP request to server (api.ipstack.com):
$res = $client->get("http://api.ipstack.com/$earliest_ip?access_key=$api");
Now, we have to mock the response to something like
PHP:
json_encode([
'ip' => '172.24.12.100',
'country_name'=>'US'
])

Here is how I did it:

Step 1:
Create a Code event listener:
navigate: ACP -> Development-> Code event listeners.

Listening to event: http_client_options
Event hint: blank
Execute callback: Earl\AddOnName\tests\Unit\Listener :: httpClientOptions_mock_response

then create this class Earl\AddOnName\tests\Unit\Listener, and method httpClientOptions_mock_response

Select your add-on, put a description, Save.

Now remember, You must disable this code event listener before you execute the build-addon command, or this code event listener will work every time XenForo tries to make requests using the $client object. You do not want that on your production site. (I asked a question here about how to enable code event listeners on the fly by keeping them disabled in default, but no one responded, so this is how we do it)

Step 2:
Listener class:
PHP:
namespace Earl\AddOnName\tests\Unit;

use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\RequestInterface;
use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\Create;



class Listener
{


    public static function httpClientOptions_mock_url(array &$options) :void
    {
     
        if (\XF::config('development')['enabled']) {
            // Create the original handler (e.g., CurlHandler)
            $originalHandler = new CurlHandler();


            // Define a custom handler that wraps the original handler
            $customHandler = function (RequestInterface $request, array $options) use ($originalHandler): PromiseInterface {
                $uri = (string) $request->getUri();

                // Check if the request is for the ipstack API
                if (strpos($uri, "http://api.ipstack.com/") === 0) {
                    // Return a mocked response wrapped in a fulfilled promise and short-circuiting the request.

                    return Create::promiseFor(new Response(200, [], \XF\Util\Json::encodePossibleObject([
                        'ip' => '172.24.12.100',
                        'country_name'=>'US'
                    ])));
                }
                // If it's not the targeted request, delegate to the original handler
                return $originalHandler($request, $options);
            };
            // Push the custom handler into the handler stack
            $stack = HandlerStack::create($customHandler);
            // Assign the handler stack to the options
            $options['handler'] = $stack;
        }
    }
}
This way we use GuzzleHttp's middleware to intercept the HTTP connection and short-circuit it, then return the mocked response as opposed to sending the real request to the server,
We also check if we are sending the request to our specific URL that we need to mock, otherwise we will return the original response without mocking. In this way, we can control which URL we are mocking, and check what exactly we are mocking, with our custom guzzle HTTP handler, we have the $response object so we have full control of it.
If you need more explanation on how this works, copy this listener class PHP code and paste it into Chatgpt
that's it!
Now you can write In your test files, your assets. Happy mocking :P
 
Last edited:
I will share something related to unit testing here with everyone who's interested in mocking $this->app()->http()->client() in a different way:

I have this line in my addon code, and I am going to mock the response as opposed to sending the HTTP request to server (api.ipstack.com):
$res = $client->get("http://api.ipstack.com/$earliest_ip?access_key=$api");
Now, we have to mock the response to something like
PHP:
json_encode([
'ip' => '172.24.12.100',
'country_name'=>'US'
])

Here is how I did it:

Step 1:
Create a Code event listener:
navigate: ACP -> Development-> Code event listeners.

Listening to event: http_client_options
Event hint: blank
Execute callback: Earl\AddOnName\tests\Unit\Listener :: httpClientOptions_mock_response

then create this class Earl\AddOnName\tests\Unit\Listener, and method httpClientOptions_mock_response

Select your add-on, put a description, Save.

Now remember, You must disable this code event listener before you execute the build-addon command, or this code event listener will work every time XenForo tries to make requests using the $client object. You do not want that on your production site. (I asked a question here about how to enable code event listeners on the fly by keeping them disabled in default, but no one responded, so this is how we do it)

Step 2:
Listener class:
PHP:
namespace Earl\AddOnName\tests\Unit;

use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\RequestInterface;
use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\Create;



class Listener
{


    public static function httpClientOptions_mock_url(array &$options) :void
    {
  
        if (\XF::config('development')['enabled']) {
            // Create the original handler (e.g., CurlHandler)
            $originalHandler = new CurlHandler();


            // Define a custom handler that wraps the original handler
            $customHandler = function (RequestInterface $request, array $options) use ($originalHandler): PromiseInterface {
                $uri = (string) $request->getUri();

                // Check if the request is for the ipstack API
                if (strpos($uri, "http://api.ipstack.com/") === 0) {
                    // Return a mocked response wrapped in a fulfilled promise and short-circuiting the request.

                    return Create::promiseFor(new Response(200, [], \XF\Util\Json::encodePossibleObject([
                        'ip' => '172.24.12.100',
                        'country_name'=>'US'
                    ])));
                }
                // If it's not the targeted request, delegate to the original handler
                return $originalHandler($request, $options);
            };
            // Push the custom handler into the handler stack
            $stack = HandlerStack::create($customHandler);
            // Assign the handler stack to the options
            $options['handler'] = $stack;
        }
    }
}
This way we use GuzzleHttp's middleware to intercept the HTTP connection and short-circuit it, then return the mocked response as opposed to sending the real request to the server,
We also check if we are sending the request to our specific URL that we need to mock, otherwise we will return the original response without mocking. In this way, we can control which URL we are mocking, and check what exactly we are mocking, with our custom guzzle HTTP handler, we have the $response object so we have full control of it.
If you need more explanation on how this works, copy this listener class PHP code and paste it into Chatgpt
that's it!
Now you can write In your test files, your assets. Happy mocking :P

You can do this simply using the fakesHttp directive in my unit test framework without any code event listeners required:

PHP:
use GuzzleHttp\Psr7\Response;

// set up our mock data
$mockData = json_encode([
    'ip' => '172.24.12.100',
    'country_name'=>'US'
]);

// alternatively, if we store json files in the `tests/mock` folder, we can load them easily:
$mockData = $this->getMockData('ipstack.json');

// now set up our fake response - this can be an array of responses if our code makes multiple HTTP calls
$this->fakesHttp([
    new Response(200, [], $mockData)
]);

// finally - execute our code as normal
// this won't cause the HTTP request to be sent, Guzzle will intercept it and return our mock response
$res = $client->get("http://api.ipstack.com/$earliest_ip?access_key=$api");

// we can check the history of all requests made - and then make assertions that the requests were what we expected
$history = $this->getHttpHistory();
 
Last edited:
You can do this simply using the fakesHttp directive in my unit test framework without any code event listeners required:

PHP:
use GuzzleHttp\Psr7\Response;

// set up our mock data
$mockData = json_encode([
    'ip' => '172.24.12.100',
    'country_name'=>'US'
]);

// alternatively, if we store json files in the `tests/mock` folder, we can load them easily:
$mockData = $this->getMockData('ipstack.json');

// now set up our fake response - this can be an array of responses if our code makes multiple HTTP calls
$this->fakesHttp([
    new Response(200, [], $mockData)
]);

// finally - execute our code as normal
// this won't cause the HTTP request to be sent, Guzzle will intercept it and return our mock response
$res = $client->get("http://api.ipstack.com/$earliest_ip?access_key=$api");

// we can check the history of all requests made - and then make assertions that the requests were what we expected
$history = $this->getHttpHistory();
fakesHttp returns the client object? and aren't we supposed to use that client object to make the request?
Because I var_dumped the history, and I received a bunch of HTTP data and I don't think it shortcircuits the request.
 
fakesHttp returns the client object? and aren't we supposed to use that client object to make the request?
Because I var_dumped the history, and I received a bunch of HTTP data and I don't think it shortcircuits the request.

It returns it only as a convenience - you can either use the returned object, or use \XF::app()->http()->client() as normal.

It swaps out the \XF::app()->http()->client() with the Guzzle mock handler.

Test it by specifying some random mock data to return - if the response contains your mock data, then it did what it was supposed to do.
 
It returns it only as a convenience - you can either use the returned object, or use \XF::app()->http()->client() as normal.

It swaps out the \XF::app()->http()->client() with the Guzzle mock handler.

Test it by specifying some random mock data to return - if the response contains your mock data, then it did what it was supposed to do.
it worked!

This line

$res = $client->get("[URL]http://api.ipstack.com/$earliest_ip?access_key=$api[/URL]");

stays inside my repository method named getIPCountryOfUser(User $user){..}

As you can see it only accepts the User object, so I thought I had dependency inject a Client object into this method because I cannot change what's inside of that method from a test method.
but your method still worked, I did not have to pass a mocked HTTP client object into my repository method (getIPCountryOfUser)
that's awesome!
Thank you!
 
Last edited:
As you can see it only accepts the User object, so I thought I had dependency inject a Client object into this method because I cannot change what's inside of that method from a test method.
but your method still worked, I did not have to pass a mocked HTTP client object into my repository method (getIPCountryOfUser)

Yes, this is the power of the dependency injection container that is the heart of XenForo - it's a single point of access to all systems that run the framework, making it easy for us to swap them out for testing purposes.

Instead of creating our own HTTP client every time we need to use it, we always use the client provided by the container. Then when testing, we can swap that out with the mock client so any requests to the container get the mocked version instead. It's a very elegant system.

Interestingly, you can use containers for your own addons - see my post on SubContainers here: https://xenforo.com/community/threa...pendency-injection-in-xf.195587/#post-1523025
 
Back
Top Bottom