Partial mocks with dependencies in Laravel

If you're been writing tests with Laravel (you have been writing tests, right?) then you may have come across partial mocks. They're useful in various scenarios, such as a class that connects to an API:

class Api
{

	public function fetchThing(int $id): array
	{
		return $this->request('GET', 'https://api.com/things/' . $id);
	}

	public function request(string $method, string $url): array
	{
		// make an HTTP request...
	}
}

We want to be certain that calling fetchThing() triggers an API request, without actually performing those requests in the test environment (ignore Laravel's Http::fake() for this example). We can partially mock the Api class such that only request() is mocked, while the other methods are called normally:

public function testFetchThingMakesRequest()
{
	$partial = $this->partialMock(Api::class, function($mock) {

		$mock->shouldReceive('request')
			->with('GET', 123)
			->once()
			->andReturn(['id' => 123]);

	});

	$partial->fetchThing(123);
}

So far so good. However, imagine the API constructor has a dependency:

class Api
{

	public function __construct(
		protected string $secret_key
	) {
	}

	public function request(string $method, string $url): array
	{
		$this->secret_key; // Do something with the key
	}
}

The partialMock() method returns a mocked instance of the class, but calling fetchThing() now will throw a $secret_key must not be accessed before initialization exception, because the constructor hasn't been used.

Laravel's partialMock() is a convenience wrapper around Mockery's mock() and makePartial() methods. Mockery does allow you to pass arguments that should be injected into the constructor, but Laravel's corresponding methods do not 1. So what can we do?

The solution is quite simple due how partialMock() works. The mocked instance is created, then bound to the service container before being returned. This means we can ignore the return value from partialMock() and instead use the application's makeWith() method to resolve a new partial - with the required arguments - from the container:

$this->partialMock(Api::class, function($mock) {
	// ... 
});

$partial = $this->app->makeWith(Api::class, ['secret_key' => $secret_key]);

Of course there's nothing stopping you from using the Mockery methods directly, but this just keeps things Laravel-y.