Configuration Override in Controller Tests

Hi All,

I’ve recently started adding unit tests to a ZF application that I’ve taken over from a previous developer. Mostly this has gone well (with some refactoring & migrating to Laminas) however I’ve hit a small snag trying to override some configs in my Controller tests. In my test, I’m setting up like below:

<?php

namespace ApplicationTest\Controller;

use Laminas\Stdlib\ArrayUtils;
use Laminas\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;
use Application\Controller\IndexController;

class IndexControllerTest extends AbstractHttpControllerTestCase {

    public function setUp(): void
    {
        $configOverrides = [
            'testing' => 'true'
        ];

        $this->setApplicationConfig(ArrayUtils::merge(
            include __DIR__ . '/../../../../config/application.config.php',
            $configOverrides
        ));

        // print_r($this->getApplicationConfig());die;
	// Output: 'true' - this is good!
		
        parent::setUp();
    }
}

And as an example, in my local.php:

<?php

return [
    'testing' => 'false'
];

Now if I access the configuration from my Module.php’s onBootstrap method, and dump there when running the test, the testing value is still false.

<?php

namespace Application;

use Laminas\Mvc\MvcEvent;

class Module
{
    public function getConfig()
    {
        return include __DIR__ . '/../config/module.config.php';
    }

    public function onBootstrap(MvcEvent $event)
    {
        $config = $event->getApplication()->getServiceManager()->get('Config');

        print_r($config['testing']);die;
        // Ouput: 'false' - but why?
    }
}

Is this because of how I’m accessing the config within the onBootstrap method?

The only reason I’m accessing it here and not via a factory for the controller is because the previous dev has setup the app authentication in the onBootstrap method, which I’ve snipped for brevity. I want to overwrite some configurations prior to running my Controller tests.

So on a side note, is the app authentication worth refactoring out of Module.php for better testing?

Thanks!

First, you was trying to override ApplicationConfig, not config. To override config, you can do like the following:

<?php
        $services          = $this->getApplicationServiceLocator();
        $config            = $services->get('config');
        $config['testing'] = true;
        $services->setAllowOverride(true);
        $services->setService('config', $config);
        $services->setAllowOverride(false);

But that may won’t work to be called via controller testing as well since bootstrap first already hit and you print_r() in the bootstrap.

I think you can do another approach by define like the following:

return [
    'testing' => filter_var(getenv('testing'), FILTER_VALIDATE_BOOLEAN) ?? false
];

and at phpunit.xml.dist, you can define

    <php>
        <env name="testing" value="true"  />
    </php>

Thanks for the info Samsonasik.

For the past 24 hours I’ve been tweaking and testing different approaches and decided to refactor the code entirely.

I migrated the code from onBootstrap method into a Listener, and then in my test I override that Listener service in the same way as your example.

Rather than wrapping production code in actual logic (if (testing)) which was always going to be iffy practice, I thought I might as well do it right. My goal is to mock a users login to test the controllers views.

At the moment it looks like:

IndexControllerTest.php:

<?php

namespace ApplicationTest\Controller;

use Laminas\Authentication\AuthenticationService;
use Laminas\Stdlib\ArrayUtils;
use Laminas\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;

/**
 * Class IndexControllerTest
 * @package ApplicationTest\Controller
 */
class IndexControllerTest extends AbstractHttpControllerTestCase {

    public function setUp(): void
    {
        $configOverrides = [];

        $this->setApplicationConfig(ArrayUtils::merge(
            include __DIR__ . '/../../../../config/application.config.php',
            $configOverrides
        ));

        parent::setUp();
    }

    public function mockAuth()
    {
        $appSrvLoc = $this->getApplicationServiceLocator();

        $mockAuth = $this->getMockBuilder(AuthenticationService::class)
            ->disableOriginalConstructor()
            ->getMock();

        $mockAuth->expects($this->any())
            ->method('hasIdentity')
            ->willReturn(true);

        $mockAuth->expects($this->any())
            ->method('getIdentity')
            ->willReturn(['id' => 1]);

        $appSrvLoc->setAllowOverride(true);
        $appSrvLoc->setService(AuthenticationService::class, $mockAuth);
        $appSrvLoc->setAllowOverride(false);
    }

    public function testHomeCanBeAccessed()
    {
        $this->mockAuth();

        $this->dispatch('/', 'GET');
        $this->assertResponseStatusCode(200);
        $this->assertModuleName('application');
        $this->assertControllerName(IndexController::class);
        $this->assertControllerClass('IndexController');
        $this->assertMatchedRouteName('home');
    }
}

And then I pass through AuthenticationService in the constructor of my Listener:

SessionListener.php

<?php

namespace Application\Listener;

use Laminas\Authentication\AuthenticationService;
use Laminas\EventManager\Event;
use Laminas\EventManager\EventManagerInterface;
use Laminas\EventManager\ListenerAggregateInterface;
use Laminas\Http\Response;
use Laminas\Mvc\MvcEvent;

class SessionListener implements ListenerAggregateInterface {

    /** @var AuthenticationService  */
    protected AuthenticationService $authService;

    /** @var array */
    protected array $listeners = [];

    /**
     * SessionListener constructor.
     * @param AuthenticationService $authenticationService
     */
    public function __construct(AuthenticationService $authenticationService)
    {
        $this->authService = $authenticationService;
    }

    /**
     * @return AuthenticationService
     */
    public function getAuthService(): AuthenticationService
    {
        return $this->authService;
    }

    /**
     * @return array
     */
    public function getListeners(): array
    {
        return $this->listeners;
    }

    /**
     * Attach event to listener
     *
     * @param EventManagerInterface $events
     * @param int $priority
     */
    public function attach(EventManagerInterface $events, $priority = 1)
    {
        $events->attach(MvcEvent::EVENT_DISPATCH,
            [$this, 'confirmSession'], 1
        );
    }

    /**
     * Confirm user has session
     *
     * @param Event $event
     * @return bool|Response
     */
    public function confirmSession(Event $event)
    {
        $controller = $event->getTarget();

        if (!$this->getAuthService()->hasIdentity()) {

            // Invalid user - redirect to the Login page.
            return $controller->redirect()->toRoute('login');
        }
        

        return true;
    }

    /**
     * Detach listeners
     *
     * @param EventManagerInterface $events
     */
    public function detach(EventManagerInterface $events)
    {
        foreach ($this->listeners as $index => $listener) {
            $events->detach($listener);
            unset($this->listeners[$index]);
        }
    }
}

I’ve got a little more validation to add in i.e. route permission access but I much prefer this code method for code separation.

I then attach the Listener in module.config.php like so:

<?php

namespace Application;

use Laminas\Authentication\AuthenticationService;
use Laminas\Router\Http\Literal;
use Laminas\Log\Logger;
use Laminas\Router\Http\Segment;
use Laminas\ServiceManager\Factory\InvokableFactory;

return [
    'controllers' => [
        'factories' => [
            Controller\IndexController::class => Controller\Factory\IndexControllerFactory::class
        ],
    ],
    'listeners' => [
        Listener\SessionListener::class
    ],
    'service_manager' => [
        'factories' => [
            AuthenticationService::class => InvokableFactory::class,
            Listener\SessionListener::class => Listener\Factory\SessionListenerFactory::class
        ]
    ]
    // ...
];

Current Issue

Hijacking my own thread here but weirdly, this approach was working earlier, however I just ran my tests again and now it’s failing. It looks like my mock AuthenticationService is no longer being fired and hasIdentity() is false, forcing a redirect…

Failed asserting response code "200", actual status code is "302"

Is there any better practice for this situation that’s more consistent or am I building these mocks incorrectly? I’ve got a similar mock for a curl wrapper to fake an API response which is still working fine.