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?
PostRenderer::render()
= X?PostController::detail()
= Y?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.
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:
/src/Controller/PostController.php
/src/Renderer/PostRenderer.php
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));
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.
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]);
}
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.
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:
✅
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-source packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!