How can we Generate Unit Tests - Part 2: Building Scoring Script

I kicked off the unit test generator idea with the first post a week ago. It was a great success on Reddit, and I'm happy there is interest in the PHP community.

I often got asked about the testability score. How does it work, and how can it be measured in actual code? Well, let's find out.

"Any code you can share on how you find the methods
and give them a score?"

The best way is to learn by example. Let's evaluate 2 methods with different testability scores.

We're on a blog, so let's render a post and display it to the reader:

final class PostRenderer
{
    public function render(Post $post): string
    {
        $postContents = $post->getTitle();

        $postContents .= PHP_EOL . PHP_EOL;

        $postContents .= $post->getContents();

        return $postContents;
    }
}

final class PostController extends Controller
{
    public function __construct(
        private readonly PostRepository $postRepository
    ) {}

    public function detail(id $postId): Response
    {
        $post = $this->postRepository->findById($postId);

        return $this->render('post/detail.twig', [
            'post' => $post,
        ]);
    }
}

Which of those methods will be easier to test?


Let's forget we're human, and we can quickly feel the answer. But it will also take our attention and actually suck our cognitive energy to read the code.

Instead, we build an automated script that will evaluate it for us for any given project in instant speed.


1. The Raw Find and Parse

These 2 files are somewhere in our project. How do we get to them?

First, we find all PHP files in the project's source code.

$phpFiles = glob('src/**/*.php');
foreach ($phpFiles as $phpFile) {
    // $phpFile
}

We have the PHP file paths:

Now we add the all-mighty php-parser:

composer require nikic/php-parser
# we use the 4.15.2 version

Then we build the parser and parse file:

$parserFactory = new PhpParser\ParserFactory();
$phpParser = $parserFactory->create(ParserFactory::PREFER_PHP7);

// foreach...

/** \PhpParser\Node\Stmt[] $stmts */
$stmts = $phpParser->parse(file_get_contents($phpFile));

2. Get all Public Class Methods

The best way to find all public methods is to use the native NodeFinder service:

$nodeFinder = new PhpParser\NodeFinder();

/** @var ClassMethod[] $publicClassMethods */
$publicClassMethods = $nodeFinder->find($stmts, function (Node $node) {
    if (! $node instanceof ClassMethod) {
        return false;
    }

    return $node->isPublic();
});

Now that we have all the public class methods, we can evaluate their testability score.


3. Traverse With Node Visitors

Now we have to determine score hits, which will be a red flag for the testability of the public method.


We have:

Would you test a controller action method? I don't know about you, but I have never tested a controller with a PHPUnit test. Let's avoid it.


We mark a public method in a controller with high testability score. But how?

We have nodes so that we can use another php-parser service, a NodeTraverser:

use PhpParser\NodeTraverser;

foreach ($publicClassMethods as $publicClassMethod) {
    $nodeTraverser = new NodeTraverser;

    // add scoring node visitors
    $nodeTraverser->addVisitor(...);

    $nodeTraverser->traverse([$publicClassMethod]);
}

4. Add Scoring Node Visitor

Scoring node visitor has a straightforward if/else structure:

use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Name;

final class ActionControllerNodeVisitor extends NodeVisitorAbstract
{
    public function __construct(
        private readonly TestabilityScoreCounter $testabilityScoreCounter
    ) {
    }

    public function enterNode(Node $node)
    {
        if (! $node instanceof ClassMethod) {
            return null;
        }

        // there is no return type → skip it
        if (! $node->returnType instanceof Name) {
            // no return type
            return null;
        }

        $returnedClass = $node->returnType->toString();

        // check against your favorite framework "Response" class name
        if ($returnedClass !== 'Response') {
            return null;
        }

        // now we know the returns a response → give it a penalty of 1000 points
        $this->testabilityScoreCount->increase(1000);
    }
}

This is our first scoring node visitor!

In reality, we would also check for return inside the class method, which works without return type declaration. Also, we would check the parent class to be sure we have a controller class here. But for practical reasons, we keep it simple.

5. Putting Node Traverser and Scoring Together

use PhpParser\NodeTraverser;

$testabilityScoreResults = [];

foreach ($publicClassMethods as $publicClassMethod) {
    $nodeTraverser = new NodeTraverser;

    // add scoring node visitors
    $testabilityScoreCounter = new TestabilityScoreCounter();

    $nodeTraverser->addVisitor(new ActionControllerNodeVisitor($testabilityScoreCounter));
    $nodeTraverser->traverse([$publicClassMethod]);


    // here, we get a testability score for every public method
    $methodName = $publicClassMethod->name->toString();
    $testabilityScoreResults[$methodName] = $testabilityScoreCounter->getScore();
}

Now we have a complete script that:

6. Show Results for the Public Methods

You can now use this script to find the easiest methods to test and also what methods to avoid better.

This script already brings you to value, as you can learn testing by taking on low-hanging fruit first. It's handy to learn testing for anyone who still needs to try it. See you next week for another step.


Happy coding!




Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!