3

I want to test (with PHPUnit) a method that contains a foreach loop. I want the full path coverage. The original code is little too complex, so I created a minimal example below to illustrate my problem. There are 3 cases for the foreach loop in the PHP. (I use PHP 8.2.)

  1. Empty iterable something.
  2. Not empty iterable something.
  3. Not iterable something.
    public function dodo(array $a)
    {
        foreach ($a as $one) {
            return $one;
        }

        return null;
    }

It is easy to cover first two: a not empty array and an empty array. But how can I pass a not iterable something as the function argument? I have tried few ways, but I’ve got the TypeError.

$something->dodo(null); # TypeError

call_user_func([$something, 'dodo'], null); # TypeError

$rClass = new ReflectionClass($something);
$rMethod = $rClass->getMethod('dodo');
$rMethod->invoke($something, null); # TypeError

I don’t want to remove or change the type from the method definition. This would make the code a little less readable. Is there a way around? How can I write a test that will cover all cases of the foreach loop?

In other words: How i can call the dodo with an argument of a wrong type? I want to write test with very high code paths coverage.

Olivier
  • 13,283
  • 1
  • 8
  • 24
Michas
  • 8,534
  • 6
  • 38
  • 62
  • You need to declare that it call be nullable in your function: `?array $a` then just null coalesce `$a` in your loop: `foreach ($a ?? [] as $one)`. Also, no, null is not iterable. – Jaquarh Jul 11 '23 at 14:21
  • @Jaquarh I don’t want to change the method definition. It is correct. – Michas Jul 11 '23 at 14:32
  • 1
    Your question doesn't make sense, if you're using strict type definitions then they must be correct. If you do not want to change your definition, then do the null coalesce before you pass the data to the method ie: `$something->dodo(null ?? [])` where `null` is your variable. Otherwise, use correct type declerations and handle them. – Jaquarh Jul 11 '23 at 14:50
  • @Jaquarh I am writing a test and there is a quirk. I need to find a way around. I am not going to make the code less readable. However, I may write a very complicated test. – Michas Jul 11 '23 at 15:05
  • 5
    _"but I’ve got the TypeError"_ That's what's supposed to happen, that's a passed test. Use `$this->expectException(TypeError::class)` to denote that. – Alex Howansky Jul 11 '23 at 15:07
  • @AlexHowansky This is a corner case. I need to provide a broken argument to the foreach loop to satisfy one of three code paths. – Michas Jul 11 '23 at 15:28
  • 3
    _There are not three code paths._ It is impossible to get a non-iterable into the loop when you have it typehinted like this. You can verify that passing a non-iterable fails, but you can not force it take a non-iterable after you've told it to only take iterables. – Alex Howansky Jul 11 '23 at 15:30
  • @AlexHowansky The PHPUnit tells me there are 3 code paths. I want to solve it somehow. – Michas Jul 11 '23 at 15:38
  • What makes you think that the third path is "taking a broken argument"? Is the PHPUnit report telling you this specifically? – Alex Howansky Jul 11 '23 at 16:02
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/254442/discussion-between-michas-and-alex-howansky). – Michas Jul 11 '23 at 16:15

5 Answers5

10

When you add array as a type declaration, you basically tell PHP to make sure that no other type is allowed to be passed to your method. And the way PHP makes sure that no other type is passed, is by throwing that TypeError you are seeing.

In a way your code is equivalent to the following:

public function dodo($a)
{
    if (!is_array($a)) {
        throw new TypeError(...);
    }

    foreach ($a as $one) {
        return $one;
    }

    return null;
}

You basically answered your own question. You can call the method with a non-array argument just like you did: $something->dodo(null);. The TypeError you are seeing is the expected result.

And since PHP is already taking care that you are only working with arrays here, you have in fact covered all cases of that foreach loop.

Now that does not answer the question why PHPUnit is reporting three possible paths. I set up your example to verify the behaviour and indeed, it reported three paths of which I could only cover two with my tests.

PHPUnit itself uses Xdebug to calculate the coverage, and after some digging I found this issues in the Xdebug bugtracker, stating:

Right now, ZEND_FE_RESET_R/RW and ZEND_FE_FETCH_R/RW are both considered having two out branches, which adds an extra path, and an extra branch while generating code coverage without any reasonable explanation for users.

Now I am absolutely not an expert in PHP opcodes, but this could be closely related to the one uncovered branch you are struggling with. Please refer to @bishop's excellent answer that goes into more detail.

In my personal opinion you should not worry too much about uncovered paths, you are setting yourself a very high bar to clear, if not an impossible one (as you are experiencing here). I can highly recommend this article where the author covers (pun intended) the different coverage metrics. In the article the author concludes:

100% path coverage is undoubtedly the holy grail, and where reasonably possible I think it's a good metric to aim at even if you don't hit it.

So to summarize, don't stress yourself too much. As they say, perfection is the enemy of good.

Christoph
  • 524
  • 10
  • 19
5

TL;DR: It's possible to capture the TypeError in unit tests, but type enforcement is not the problem as supposed in the OP. The problem is with how the PHP engine calculates paths through a foreach.


To answer the question as posed:

how can I pass a not iterable something as the function argument? I have tried few ways, but I’ve got the TypeError.

Given this source code:

<?php declare(strict_types=1);

namespace App;

class Example
{
    public function dodo(array $a)
    {
        foreach ($a as $one) {
            return $one;
        }

        return null;
    }
}

then this test case implements the three scenarios requested:

<?php declare(strict_types=1);                                                                                                       
                                                                                                                                     
use PHPUnit\Framework\TestCase;                                                                                                      
                                                                                                                                     
final class ExampleTest extends TestCase                                                                                             
{                                                                                                                                    
    public function testReturnsNullOfIterableWithoutFirstElement(): void                                                             
    {                                                                                                                                
        $example = new App\Example;                                                                                                  
                                                                                                                                     
        $result = $example->dodo([]);                                                                                                
                                                                                                                                     
        $this->assertNull($result);                                                                                                  
    }                                                                                                                                
                                                                                                                                     
    public function testReturnsFirstElementOfIterableWithElements(): void                                                            
    {                                                                                                                                
        $example = new App\Example;                                                                                                  
                                                                                                                                     
        $result = $example->dodo([ 'a' ]);                                                                                           
                                                                                                                                     
        $this->assertSame('a', $result);                                                                                             
    }                                                                                                                                
                                                                                                                                     
    public function testThrowTypeErrorOnNonIterable(): void                                                                          
    {                                                                                                                                
        $this->expectException(\TypeError::class);                                                                                   
                                                                                                                                     
        $example = new App\Example;                                                                                                  
                                                                                                                                     
        $result = $example->dodo(null);                                                                                               
    }                                                                                                                                
}                                                                                                                                    

As evidenced by line and branch coverage:

# ./vendor/bin/phpunit  --coverage-text
PHPUnit 10.2.6 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.8 with Xdebug 3.2.2
Configuration: /app/phpunit.xml

...                                                                 3 / 3 (100%)

Time: 00:00.103, Memory: 12.00 MB

OK (3 tests, 3 assertions)


Code Coverage Report:   
  2023-07-18 00:09:26   
                        
 Summary:               
  Classes: 100.00% (1/1)
  Methods: 100.00% (1/1)
  Lines:   100.00% (3/3)

App\Example
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  3/  3)

However, as noted in the comment, the issue appears when using the Xdebug path coverage analysis feature:

$ ./vendor/bin/phpunit --coverage-text
PHPUnit 10.2.6 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.8 with Xdebug 3.2.2
Configuration: /app/phpunit.xml

...                                                                 3 / 3 (100%)

Time: 00:00.529, Memory: 183.48 MB

OK (3 tests, 3 assertions)


Code Coverage Report:   
  2023-07-18 18:52:13   
                        
 Summary:               
  Classes: 100.00% (1/1)
  Methods: 100.00% (1/1)
  Paths:   66.67% (2/3)    <<<<---- WHY????
  Branches:   100.00% (4/4)
  Lines:   100.00% (3/3)

App\Example
  Methods: 100.00% ( 1/ 1)   Paths:  66.67% (  2/  3)   Branches: 100.00% (  4/  4)   Lines: 100.00% (  3/  3)

So, the implicit question is: why isn't this path covered? The OP speculated that the third path was when giving a "non-iterable", but it's not quite that.

Digging into the path analysis, using 3v4l.org and Vulcan Logic Dumper, notice that the internal behavior of foreach (on line 9) with its reset and fetch operations generates two output branches (column O):

line      #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------

    9     1      > FE_RESET_R                                       $2      !0, ->6
          2    > > FE_FETCH_R                                               $2, !1, ->6

Why? Because upon entering the loop, the engine must decide if the variable is iterable (that's one path) and, if it is, to start the iteration (the second path). It's as if every foreach has an implicit "if" before it checking for iterability.

Xdebug, which is providing the path coverage data, faithfully reports this. It's also why, as mentioned in chat to another answer, removing the type hint and forcing an invalid type satisfied the path analysis.

Regardless and unfortunately, there's no way to quell this engine behavior; it's how foreach works.

There is a feature request to explore if this can be papered over in Xdebug 3.3, but currently the only way to bypass it with strict types in place is to not use foreach.

<?php declare(strict_types=1);

namespace App;

class Example
{
    public function dodo(array $a)
    {
        if (0 === count($a)) {
            return null;
        }

        return reset($a);
    }
}

to yield:

$ ./vendor/bin/phpunit --coverage-text
PHPUnit 10.2.6 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.8 with Xdebug 3.2.2
Configuration: /app/phpunit.xml

...                                                                 3 / 3 (100%)

Time: 00:00.528, Memory: 183.48 MB

OK (3 tests, 3 assertions)


Code Coverage Report:   
  2023-07-18 19:30:16   
                        
 Summary:               
  Classes: 100.00% (1/1)
  Methods: 100.00% (1/1)
  Paths:   100.00% (2/2)
  Branches:   100.00% (3/3)
  Lines:   100.00% (3/3)

App\Example
  Methods: 100.00% ( 1/ 1)   Paths: 100.00% (  2/  2)   Branches: 100.00% (  3/  3)   Lines: 100.00% (  3/  3)

I suspect this may not be a palatable change, generally, for the OP. So, until code coverage has better support for foreach, I'd suggest loosening requirements on path coverage metrics.

bishop
  • 37,830
  • 11
  • 104
  • 139
  • 1
    There are more types of coverage. The lines and branches are covered, the paths are not. This is not default, I have set an option in the configuration file. https://docs.phpunit.de/en/9.6/configuration.html#the-pathcoverage-attribute – Michas Jul 18 '23 at 11:19
  • @Michas Ah, I understand now that you mean paths in the XDebug code coverage sense, and I see the problem you're encountering. My answer above does not solve it, and I will update it. – bishop Jul 18 '23 at 18:50
  • Thanks for confirming my suspicion! It was beyond my means to verify it myself. – Christoph Jul 20 '23 at 06:59
2
  1. Empty iterable something.
  2. Not empty iterable something.
  3. Not iterable something.

This is incorrect. These are not your paths. I'm not sure where you got the idea that "Path 3" has to do with passing a non-iterable to a typehinted iterable. The three paths that XDebug is reporting are:

  1. Enter the loop, return a value from within the loop.

This is tested by calling dodo() with a non-empty iterable.

  1. Skip over the loop, return null.

This is tested by calling dodo() with an empty iterable.

  1. Enter the loop, exit the loop, return null.

This can not be covered by a test because your loop never exits. So, you don't actually have three paths. However, XDebug doesn't know that. To avoid this odd issue, I'd simply rewrite the method so that it is not indeterminate:

public function dodo(array $a)
{
    return $a[0] ?? null;
}
Alex Howansky
  • 50,515
  • 8
  • 78
  • 98
  • 1
    I cannot rewrite my original code that I want to test. In the question is only a minimal example. I have run the test with an altered function, without any explicit type for the argument. I have passed an empty array, a filled array and a null. The PHPUnit informed me about covering all 3 paths. There was a warning though. I have tested this on a different loop that has no return in the middle. The result was the same. – Michas Jul 11 '23 at 17:50
  • 1
    @Michas: That sounds like you want to test for an internal error condition (the one that emits the warning). There is no need to do that in a unit-test. The metric might have tricked you into that, but trust the terrain, not the map. And test first, not afterwards. Metrics come afterwards. And between that there is the non-test code. Doing it in that order may give you more trust in your tests and code. – hakre Jul 15 '23 at 14:40
1

It's called unittest for a reason. So let's see the unit again:

public function dodo(array $a)
{
    foreach ($a as $one) {
        return $one;
    }

    return null;
}

Nothing fancy here, even ignore that the loop is not looping.

So the unit we got, what do we test? Two testcases you've identified, now you want a third test-case to test by invoking the unit with a non-array argument (I've identified even a fourth test-case, but that for later).

[How can I] call the dodo with an argument of a wrong type?

Well, obviously you call it with not an array, for example null. You did already and it summoned a TypeError.

This should be obvious because of the array typehint on the parameter $a. You also made clear you're aware, and you want to keep it. Nothing special, I'd say.

But somehow this is not the full story for you, as you specifically note:

I want to write test with very high code paths coverage.

You did already. Strictly speaking, you already did too much. For testing the unit, provoking the TypeError is not necessary, as this is an internal detail of the PHP language. While you can technically double-check if that still works, but one benefit of writing code is to express or define something, according to the rules of execution. And you don't want to test if PHP is still working, but dodo(), the unit.

Calling the dodo() function with not an array as first parameter (or also calling it with no parameter at all) will always result in an error.

So with those tests you cover no paths of the dodo() code, but those are paths that do exist (mind how philosophical that statement is), so you increase the code paths coverage, but this can not be calculated, you can only do that, but you can not measure it with code coverage (compare with quantum mechanics).

So if you're interested in using code coverage as a tool, the right result is when testing for such errors (which should not be done, but anyway), to see no lines of dodo()s' code covered. So you can review that calling the dodo() function with a non-array is not entering the function. So you see nothing, but there is (was) something.

This should also give you the confidence that code coverage is working well.

So it looks to me that there is a misunderstanding, that only if there were more lines covered, more code paths would have been covered.

While this is normally true, this is not true when you test for preconditions that are errors. Additionally, this smells as you're testing for internals of the PHP language. Normally, such tests aren't done as unit-tests because they're out of scope, and they don't produce coverage (as you noticed).

Let's make this code path must be covered and more, more, more (better, better, better, faster, faster, faster, bigger, bigger, bigger, feed me I can't get enough) hitting the extreme:

Test with PHP versions the unit is not only an error when calling it, but already when PHP loads the definition of the unit.

Have you done that? I mean, testing for calling dodo() with a non-array is only one error condition and a pretty simple one, this doesn't even cover the case when dodo() can't be defined because of that array typehint.

This is the fourth test-case, and it is one you haven't even thought about: Covering the impossible code path.

Breakout Exercise: Find out with which PHP version the unit is a syntax error, if a Phpunit version exists that runs with that PHP version, Xdebug or a different code coverage obtaining mechanism exists and how code path coverage looks like for syntax errors.

Takeaway: You test your unit, not other internals of the language. That is because you use the language, that's enough test of it (the more civil formulations these produce are "don't test your privates" or "don't test third-party code").

And this is not because we couldn't do it, but because we're not interested in that. Increasing code path coverage for all this is neither giving you more insight, nor better feedback, nor better metrics.


public function dodo(array $a)
{
    // @codeCoverageIgnoreStart
    foreach ($a as $one) { // @codeCoverageIgnoreEnd
        return $one;
    }

     return null;
}

Path coverage is broken for every foreach over an iterable. Your tests still may be fine and cover all paths, it is only the coverage is unable to report that properly.

public function dodo($a = null)
{
    foreach ($a as $one) {
        return $one;
    }

    return null;
}

Path coverage is not broken for every foreach over anything. Your tests, if they cover all paths, would do too much, you can use both array or iterable to define the parameter $a to make PHP TypeError already instead of branching internally into the error condition.

If you need error-free code, you can test with is_iterable():

public function dodo($a = null)
{
    if (!is_iterable($a)) {
        return $a;
    }

    foreach ($a as $one) {
        return $one;
    }

    return null;
}

That this last example does not give you 100% path coverage should be the final hint, why 100% path coverage with the current implementation is nothing you want to obtain with code involving foreach.

The best suggestion I can give is not to write tests for the metrics, as it is so much the inverse of how (caution: this is subjective) tests should be written:

  1. first write the test
  2. the test must fail for the reason expected
  3. write the code, as little as necessary to make the test pass
  4. use the coverage to verify you have not written code that is not necessary (dead code)
  5. refactor
  6. commit

Coverage is last, read only and not the driver. Your tests are leading the code, and not the generated coverage leads the tests.

As this is in iterations, it is easy to shift over into having the coverage drive the tests, whenever that happens, we have to reconsider if we are still clear about what we are writing the tests for. A test-first approach may help to guide when hitting such problems.

hakre
  • 193,403
  • 52
  • 435
  • 836
1

A similar question has already been asked here.

When Xdebug analyses a foreach statement, it considers 3 possible paths:

  1. Empty iterable
  2. Non-empty iterable
  3. Not an iterable

These are the theoretical possible cases. Whether they are actually possible in a specific piece of code is not determined by Xdebug. It would be extremely difficult to do it in the general case.

If you think that simply checking the type hint is enough to eliminate the third case, consider this:

function test(array $a)
{
    if(empty($a))
        $a = null;
    foreach($a as $v)
        echo $v;
}

Despite the array type hint, it's still possible to pass null to foreach.

The real question I would ask is: what's the point of considering the third case? It gives the same result as case 1 but emits a warning. So it's not even a valid case, you're not supposed to do that. It might even raise a fatal error in a future version of PHP. So, in my opinion, only 2 paths should be considered for a foreach statement.

Olivier
  • 13,283
  • 1
  • 8
  • 24
  • IMHO, there is no use to test for that, unless you write a qc test in PHP core project for foreach. The third case is already an assertion that PHP does internally for an error case. The problem that did arise for the OP as I understood is, that branch coverage leaks internal paths that is only within the PHP implementation as code paths for the code under test. That clearly is an error as the OP then felt invited to test for internals, which you should not do in a unit-test (normally). This perpetuated in an error from reading the path coverage report. – hakre Jul 15 '23 at 14:27