Zend\Session error with phpunit and php 7.2 (but not <= 7.1)

I’m trying to update my zend-mvc project to run with PHP 7.2, and it seems all good in the web environment, but when I run phpunit tests, I encounter errors like:

1) ApplicationTest\Controller\AuthControllerTest::testRedirectionFollowingAuthentication
Zend\ServiceManager\Exception\ServiceNotCreatedException: Service with name "Zend\Session\Config\ConfigInterface" could not be created. Reason: ini_set(): Headers already sent. You cannot change the session module's ini settings at this time

/opt/www/court-interpreters-office/vendor/zendframework/zend-servicemanager/src/ServiceManager.php:771
/opt/www/court-interpreters-office/vendor/zendframework/zend-servicemanager/src/ServiceManager.php:200
/opt/www/court-interpreters-office/vendor/zendframework/zend-session/src/Service/SessionManagerFactory.php:75
/opt/www/court-interpreters-office/vendor/zendframework/zend-servicemanager/src/ServiceManager.php:764
/opt/www/court-interpreters-office/vendor/zendframework/zend-servicemanager/src/ServiceManager.php:200
/opt/www/court-interpreters-office/vendor/zendframework/zend-session/src/Service/ContainerAbstractServiceFactory.php:160
/opt/www/court-interpreters-office/vendor/zendframework/zend-session/src/Service/ContainerAbstractServiceFactory.php:100
/opt/www/court-interpreters-office/vendor/zendframework/zend-servicemanager/src/ServiceManager.php:764
/opt/www/court-interpreters-office/vendor/zendframework/zend-servicemanager/src/ServiceManager.php:200
/opt/www/court-interpreters-office/module/Admin/src/Module.php:104
/opt/www/court-interpreters-office/vendor/zendframework/zend-eventmanager/src/EventManager.php:322
/opt/www/court-interpreters-office/vendor/zendframework/zend-eventmanager/src/EventManager.php:179
/opt/www/court-interpreters-office/vendor/zendframework/zend-mvc/src/Application.php:311
/opt/www/court-interpreters-office/vendor/zendframework/zend-test/src/PHPUnit/Controller/AbstractControllerTestCase.php:310
/opt/www/court-interpreters-office/module/InterpretersOffice/test/Controller/AuthControllerTest.php:70

Caused by
PHPUnit\Framework\Error\Warning: ini_set(): Headers already sent. You cannot change the session module's ini settings at this time[...]
/opt/www/court-interpreters-office/vendor/zendframework/zend-session/src/Service/ContainerAbstractServiceFactory.php:160
/opt/www/court-interpreters-office/vendor/zendframework/zend-session/src/Service/ContainerAbstractServiceFactory.php:100
/opt/www/court-interpreters-office/vendor/zendframework/zend-servicemanager/src/ServiceManager.php:764
/opt/www/court-interpreters-office/vendor/zendframework/zend-servicemanager/src/ServiceManager.php:200
/opt/www/court-interpreters-office/module/Admin/src/Module.php:104
/opt/www/court-interpreters-office/vendor/zendframework/zend-eventmanager/src/EventManager.php:322
/opt/www/court-interpreters-office/vendor/zendframework/zend-eventmanager/src/EventManager.php:179
/opt/www/court-interpreters-office/vendor/zendframework/zend-mvc/src/Application.php:311
/opt/www/court-interpreters-office/vendor/zendframework/zend-test/src/PHPUnit/Controller/AbstractControllerTestCase.php:310
/opt/www/court-interpreters-office/module/InterpretersOffice/test/Controller/AuthControllerTest.php:70

I have read https://github.com/zendframework/zend-session/issues/104, but before I start trying to re-wire my session stuff, I was hoping someone could confirm that this looks like the same issue.

The line in my application code that sets it off is in a Module.php's onBootstrap() where I check authentication and authorization. The logic is pretty standard. If they’re not logged in and requesting something for which authentication is required, I save the current url in a session variable and redirect them to the login page so that on successful login I know where to send them back.

For setting up sessions I have borrowed the technique found in https://olegkrivtsov.github.io/using-zend-framework-3-book/html/en/Working_with_Sessions/Session_Manager.html. I don’t have any factory for the SessionManager registered anywhere in my configuration so I am assuming (yes I ought to understand this better) there is some abstract factory magic happening.

But, again, I’m a little confused/uncertain and hoping to confirm that the approach suggested in the discussion at https://github.com/zendframework/zend-session/issues/104 is the way to go.

OK, after a full two days of struggle, I found at least the proximate cause of my heartbreak and patched it up, and now my phpunit tests are happy under php 7.[0-2]. I was accessing the SessionManager from the service manager early in the cycle (as suggested in https://olegkrivtsov.github.io/using-zend-framework-3-book/html/en/Working_with_Sessions/Session_Manager.html) because that was supposed to be a good idea, so as to make it become the default session manager. The official docs https://docs.zendframework.com/zend-session/manager/ don’t seem to support that notion. And I still don’t have a complete grasp of all this plumbing. But… onwards!

The same here. I am on it for second day. I’ve had the sessionManager instantiation in Module as well. Removed it, but with phpunit I still get an error. ini_set does not work. Strangely the application works. Its just phpUnit test that dont. Did u create as special factory for sessionManager?

I’ve had to do something a little ugly. In the Module.php’s onBootstrap method, I check if ('testing' != getenv('environment')) before doing the thing that was causing trouble. I don’t like it when I don’t thoroughly understand how my own project works, but this seems to be working.

I don’t think the session component should initialize itself at all while in CLI mode.

You may want to swap out the default session storage with an array-backed one: https://github.com/zendframework/zend-session/blob/2cfd90e1a2f6b066b9f908599251d8f64f07021b/src/Storage/ArrayStorage.php

According to the SessionManagerFactory (the one that is failing in your trace), you can swap out the storage by declaring it as a service: https://github.com/zendframework/zend-session/blob/2cfd90e1a2f6b066b9f908599251d8f64f07021b/src/Service/SessionManagerFactory.php#L86

When running tests, that means adding an autoload config that should be used.

In my opinion, this should be a non-issue upfront though, because a test scenario that instantiates the entire framework is quite problematic, and should be avoided anyway.

1 Like

I definitely don’t disagree, I just have a question: if you are writing controller action tests, as in https://docs.zendframework.com/tutorials/unit-testing/, are you not instantiating the entire framework?

Hmm, no, I also don’t use zendframework/zend-test.

I’m personally using mocks and stubs quite copiously, and if I need a full system test I design the test in such a way that I don’t even know that the application is running PHP (black box testing).

1 Like

ah, ok. let us know when you’re going to teach a course on how to do that. I’ll be the first to sign up. :slight_smile:

seriously, my tests are perhaps better than nothing but they are not the state of the art.

May I know if you have found a proper workaround this? I have researched the links you provided as well as the official documentation and I’m unfortunately have reached a point without luck to overcome this similar issue when doing unit testing.

Sorry, I haven’t. The workaround I’ve been using (above) has been quietly working just fine all these months (and years) as the project develops, so I haven’t been motivated to think about it.

I ended up writing a patch for the SessionConfig.php and applying it with cweagans/composer-patches

upgrading phpunit form 7.1 to 7.5 solved for me. But I was having this problem while using ZF3 Session inside a ZF1 Application

I was experiencing this same issue in PHP 7.4 and PHPUnit 9.5^. Its now working fine both in MVC as well as in UnitTests. Here’s how I resolved it.

module.config.php

"service_manager" => [
        "aliases" => [
            "Login" => Container::class,
        ],
        "factories" => [
            ConfigInterface::class => SessionConfigFactory::class,
            SessionManager::class => CustomSessionManagerFactory::class,
            Container::class => CustomSessionContainerFactory::class,

login.local.php (for UnitTest test/_fixture/config/autoload/login.local.php)

    "session_config"  => [
        "cookie_lifetime" => 60*60*1, // 1 hour ---> 3600
        "gc_maxlifetime" => 60*60*24*7, // 7 days ---> 604800
        "remember_me_seconds" => 3600,
        "use_cookies" => true,
        "config_class" => StandardConfig::class, //For MVC, it should be SessionConfig::class
    ],
    "session_storage" => [
        "type" => Laminas\Session\Storage\ArrayStorage::class, //For MVC, it should be SessionArrayStorage::class
    ],

CustomSessionContainerFactory.php

class CustomSessionContainerFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): Container
    {
        $sessionManager = $container->get(SessionManager::class);
        return new Container("Login", $sessionManager); //This points to the Alias defined in module.config.php
    }
}

CustomSessionManagerFactory.php

class CustomSessionManagerFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): SessionManager
    {
        $config = $container->has("config") ? $container->get("config") : [];
        $config = $config["session_config"] ?? [];

        $sessionConfig = !empty($config["config_class"]) ? new $config["config_class"]() : new SessionConfig(); //For UnitTest, it'll fetch StandardConfig from _fixture/config/autoload/login.local.php
        $sessionConfig->setOptions($config);

        return new SessionManager($sessionConfig);
    }
}

IndexControllerFactory.php (for Dependency Injection in MVC app)

class IndexControllerFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): IndexController
    {
        return new IndexController(
              $container->get("Login"), 
              new Config($container->get("config")));
    }
}

IndexController.php (In MVC app)

class IndexController extends AbstractActionController
{
    protected $sessionContainer;
    protected Config $config;

   public function __construct(Container $sessionContainer, Config $config)
    {
        $this->sessionContainer = $sessionContainer;
        $this->config = $config;
    }

    public function indexAction()
    {
        return new ViewModel(["session" => $this->sessionContainer]);
    }
}

index.phtml (In MVC app)

<?php
var_dump($this->session);
?>

OUTPUT

object(Laminas\Session\Container)[366]
  protected 'name' => string 'Login' (length=5)
  protected 'manager' => 
    object(Laminas\Session\SessionManager)[365]
      protected 'defaultDestroyOptions' => 
        array (size=2)
          'send_expire_cookie' => boolean true
          'clear_storage' => boolean false
      protected 'defaultOptions' => 
        array (size=1)
          'attach_default_validators' => boolean true
      protected 'defaultValidators' => 
        array (size=1)
          0 => string 'Laminas\Session\Validator\Id' (length=28)
      protected 'name' => null
      protected 'validatorChain' => 
        object(Laminas\Session\ValidatorChain)[372]
          protected 'storage' => 
            object(Laminas\Session\Storage\SessionArrayStorage)[364]
              ...
          protected 'events' => 
            array (size=1)
              ...
          protected 'eventPrototype' => 
            object(Laminas\EventManager\Event)[373]
              ...
          protected 'identifiers' => 
            array (size=0)
              ...
          protected 'sharedManager' => null
      protected 'config' => 
        object(Laminas\Session\Config\SessionConfig)[362]
          protected 'knownSaveHandlers' => null
          protected 'phpErrorCode' => boolean false
          protected 'phpErrorMessage' => boolean false
          protected 'rememberMeSeconds' => int 3600
          protected 'saveHandler' => null
          protected 'serializeHandler' => null
          protected 'validCacheLimiters' => 
            array (size=5)
              ...
          protected 'validHashBitsPerCharacters' => 
            array (size=3)
              ...
          protected 'validSidBitsPerCharacters' => 
            array (size=3)
              ...
          protected 'validHashFunctions' => null
          protected 'name' => null
          protected 'savePath' => null
          protected 'cookieLifetime' => int 3600
          protected 'cookiePath' => null
          protected 'cookieDomain' => null
          protected 'cookieSameSite' => null
          protected 'cookieSecure' => null
          protected 'cookieHttpOnly' => null
          protected 'useCookies' => boolean true
          protected 'options' => 
            array (size=1)
              ...
      protected 'defaultConfigClass' => string 'Laminas\Session\Config\SessionConfig' (length=36)
      protected 'storage' => 
        object(Laminas\Session\Storage\SessionArrayStorage)[364]
      protected 'defaultStorageClass' => string 'Laminas\Session\Storage\SessionArrayStorage' (length=43)
      protected 'saveHandler' => null
      protected 'validators' => 
        array (size=1)
          0 => string 'Laminas\Session\Validator\Id' (length=28)
  private 'defaultValue' (Laminas\Session\AbstractContainer) => null
  protected 'storage' => 
    array (size=0)
      empty
  protected 'flag' => int 2
  protected 'iteratorClass' => string 'ArrayIterator' (length=13)
  protected 'protectedProperties' => 
    array (size=6)
      0 => string 'name' (length=4)
      1 => string 'manager' (length=7)
      2 => string 'storage' (length=7)
      3 => string 'flag' (length=4)
      4 => string 'iteratorClass' (length=13)
      5 => string 'protectedProperties' (length=19)