phpphp-8

phpunit tests failing after going to php 8.0 because of "Unknown named parameter"


We are preparing to move to php 8.0.15 once some third party libraries we require are ready for it.

Our centralized setUp() function for unit tests handles constructorArg population for our class mocks.

Using phpunit v9.5.14 currently, we get failed tests with the response Error : Unknown named parameter $User

We are not using named parameters in our codebase anywhere that we are aware of.

if (empty($this->constructorArgs)) {
    $this->constructorArgs = array('User');
}
if (!empty($this->constructorArgs) && is_array($this->constructorArgs)) {
    foreach ($this->constructorArgs as $classname) {
        if (is_array($classname)) {
            $args[key($classname)] = current($classname);
            $classname = key($classname);
        } else {
            if ($classname == "Twig" || $classname == "Twig\Environment") {
                $args[$classname] = TwigFactory::mockTwig();
            } else {
                $args[$classname] = $this->getMockBuilder($classname)->disableOriginalConstructor()->getMock();
            }
        }
        $container->set($classname, $args[$classname]);
    }
}

$this->mock = $this->getMockBuilder($this->class)
    ->setMethods($this->methods)
    ->setConstructorArgs($args)
    ->getMock();   <-- Error states this line, unfortunately no stack trace

The constructorArgs are populated into the setup like this:

$this->constructorArgs = array('User','AnotherClass', 'YetAnother');

We've tried everything we can think of, thinking that maybe it was something to do with the casing of the variable name in the class construct, i.e. "User $user" but so far nothing has resolved this.


Solution

  • Before we begin, let's create a minimal, reproducible example:

    class User {}
    
    class Example {
         public User $user;
    
         public function __construct(User $user) {
              $this->user = $user;
         }
    }
    
    class ExampleTest extends PHPUnit\Framework\TestCase {
         public function testExample() {
              $args = [];
              $args['User'] = $this->getMockBuilder('User')->disableOriginalConstructor()->getMock();
    
              $mock = $this->getMockBuilder(Example::class)
                    ->setConstructorArgs($args)
                    ->getMock();
    
              $this->assertTrue(true);
          }
    }
    

    Running with PHP 7.4 and PHPUnit 9.5.14, this passes; with PHP 8.0 and the same library, it gives the error you report:

    Error: Unknown named parameter $User

    Actually, we can simplify a bit further: rather than $args['User'] = $this->getMockBuilder('User')->disableOriginalConstructor()->getMock(); we can just say $args['User'] = new User; and get the same error.

    Now, let's look at what we're doing:

    1. We create an associative array, mapping class names to (mock) objects
    2. We pass that associative array to a Mock Builder's setConstructorArgs method
    3. Some magic happens...

    So, what does happen? Maybe the source of PHPUnit will give some clues.

    Well, setConstructorArgs just sets a property, which is used in getMock, then passed through a bunch of different methods; eventually, it ends up passed to MockObject\Generator::getObject, which if we strip out some error handling, does this:

    $class = new ReflectionClass($className);
    $object = $class->newInstanceArgs($arguments);
    

    So, let's see if we can use that to make an even more minimal example of our problem:

    class User {}
    
    class Example {
         public User $user;
    
         public function __construct(User $user) {
              $this->user = $user;
         }
    }
    
    $class = new ReflectionClass(Example::class);
    $object = $class->newInstanceArgs(['User' => new User]);
    

    Since this is self-contained code, we can use the handy online tool at https://3v4l.org to compare output in different PHP versions: https://3v4l.org/QU4jS

    As expected, PHP 7.4 is happy with it, PHP 8.0 and above give an error:

    Fatal error: Uncaught Error: Unknown named parameter $User in /in/QU4jS:14
    Stack trace:
    #0 /in/QU4jS(14): ReflectionClass->newInstanceArgs(Array)
    #1 {main}
      thrown in /in/QU4jS on line 14
    

    So, what is going on here? Well, the manual page for ReflectionClass::newInstanceArgs doesn't (currently) say much about what the provided array should look like, or named argument support, but we can take an educated guess: it's trying to match our associative array as named parameters to the constructor. Previous versions, since they had no named parameters, simply ignored the keys and applied the parameters in order.

    We can test this theory easily enough by making a class with two parameters to its constructor:

    class Example2 {
        public function __construct($first, $second) {
            echo "$first then $second\n";
        }
    }
    
    $class = new ReflectionClass(Example2::class);
    $object = $class->newInstanceArgs(['second' => 'two', 'first' => 'one']);
    

    When run on multiple versions we can see that older versions of PHP output "two then one", based on the order of the array; and newer versions output "one then two", based on the keys of the array.


    So, to cut a long story short, what's the fix? Quite simply, don't use keys in the array of constructor parameters:

    class ExampleTest extends PHPUnit\Framework\TestCase {
         public function testExample() {
              $args = [];
              $args[] = new User;
    
              $mock = $this->getMockBuilder(Example::class)
                    ->setConstructorArgs($args)
                    ->getMock();
    
              $this->assertTrue(true);
          }
    }
    

    If you need to use them during the setup logic to keep track of things, just use array_values to discard them when you pass them in:

    class ExampleTest extends PHPUnit\Framework\TestCase {
         public function testExample() {
              $args = [];
              $args['User'] = new User;
    
              $mock = $this->getMockBuilder(Example::class)
                    ->setConstructorArgs(array_values($args))
                    ->getMock();
    
              $this->assertTrue(true);
          }
    }