Because I work with Laravel, the code examples in this post are written in Laravel context, but you can use this technique in whatever framework you're using, or not using.

The Problem

Imagine the following scenario: You have a blog that contains both original posts and links to external posts. You wrote a class that's responsible for calculating post stats. For some stats you want to count only original posts, and for the others you want to count all posts.

The class exposes the getNewPostsCount() and getCategoryPostsCount() which count only original posts, and the getAllItemsCount() method that should count all types of posts. All of these methods rely on a private method that returns a scoped query builder. Something like this:

<?php
  
namespace App\Support;

use App\Models\Post;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;

class Stats
{  
  public function getNewPostsCount(): int
  {
	return $this
	  ->baseQuery()
	  ->where('published_at', '>', Carbon::now()->subWeek())
	  ->count();
  }
  
  public function getCategoryPostsCount(int $categoryId): int
  {
	return $this
	  ->baseQuery()
	  ->where('category_id', $categoryId)
	  ->count();
  }
  
  public function getAllItemsCount(): int
  {
	return $this
	  ->baseQuery(false)
	  ->count();
  }
  
  private function baseQuery(bool $originalOnly = true): Builder
  {
	return Post::when(
	  $originalOnly,
	  fn(Builder $query) => $query->where('is_original', true)
	)->get();
  }
}

Now, when you write the test for this class, you want to make sure that baseQuery() scopes the query correctly, so you don't have to think of this case separately in every test you write.

Reflection API To The Rescue

If you're not familiar with PHP's reflection API, I highly recommend that you take a look. Here's what PHP's official documentation has to say about it:

PHP comes with a complete reflection API that adds the ability to introspect classes, interfaces, functions, methods and extensions. Additionally, the reflection API offers ways to retrieve doc comments for functions, classes and methods.

We're going to make use of 2 specific classes: ReflectionClass and ReflectionMethod.

More specifically, we're going to use ReflectionClass in order to get a ReflectionMethod instance for the private method that we want to access, which we will use to make the method accessible using the setAccessible method. Finally, we will invoke the method with the provided arguments using the invokeArgs method.

Our method will receive the object from which it should invoke the method as its first argument, the name of the method as its second argument, and an array of arguments (if needed) as its third parameter.

I extracted this method to a trait that can be used in any of my tests, and I like to keep those traits in the Tests\Support\Concerns namespace. Here's what it looks like:

<?php

namespace Tests\Support\Concerns;

use ReflectionClass;

trait InteractsWithInaccessibleMethods
{
    public function invokeInaccessibleMethod(&$object, string $method, array $params = [])
    {
        try {
            $reflectionClass = new ReflectionClass(get_class($object));
            $reflectionMethod = $reflectionClass->getMethod($method);
        } catch (\Exception $e) {
            return null;
        }

        $reflectionMethod->setAccessible(true);

        return $reflectionMethod->invokeArgs($object, $params);
    }
}

And this is how we can make use of it in our tests:

<?php
  
namespace Tests\Unit\Support;

use App\Support\Stats;
use Tests\TestCase;
use Tests\Support\Concerns\InteractsWithInaccessibleMethods;
  
class StatsTest extends TestCase
{
  use InteractsWithInaccessibleMethods;
  
  private Stats $stats;
  
  protected function setUp(): void
  {
	parent::setUp();
	
	$this->stats = new Stats();
  }
  
  /** @test */
  public function it_scopes_base_query_correctly(): void
  {	
	/**
	* Let's get the scoped query.
	* $originalOnly defaults to true, so the third parameter isn't needed.
	*/
	$originalScopedQuery = $this->invokeInaccessibleMethod(
	  $this->stats,
	  'getExpiredLeadsQuery'
	);
	
	/**
	* Let's get the non-scoped query.
	* This time we'll pass `false` in the array at the third parameter.
	*/
	$nonOriginalScopedQuery = $this->invokeInaccessibleMethod(
	  $this->stats,
	  'getExpiredLeadsQuery',
	  [false]
	);
	
	// Make some assertions to verify that the queries are scoped correctly.
  }
  
  /** @test */
  public function it_can_calculate_new_posts_count(): void
  {
	// Some testing logic...
  }
  
  /** @test */
  public function it_can_calculate_original_posts_count(): void
  {
	// Some testing logic...
  }
  
  /** @test */
  public function it_can_calculate_all_posts_count(): void
  {
	// Some testing logic...
  }
}

Conclusion

I've been using this method in real life projects for a while now, and although I don't find myself testing every private method I have in my app, it sure helped me simplify some tests.

If you're interested in additional assertions for your Laravel application that simplify the entire testing process, I recommend that you use JMac's laravel-test-assertions package, which provides additional assertions to make sure a controller's action uses a certain form request, middleware, validation rules and useful assertions.

Got some unique and interesting testing techniques that you use in your applications? Hit me up on Twitter and feel free to share.