I am trying to convert some integration tests into unit tests: The original test code partially worked, but hadn't been run in a long time and the code under test is legacy-but-mostly-working. The test code also made assumptions about the DB and external resources that are not valid in a unit-test environment. Because current test coverage is poor, I'm trying to minimize my edits to the existing code while adding unit tests to verify how I think it's supposed to operate. I appreciate that the code under test might not be following best practices, but if possible would like to have working unit tests before I modify working code.
I want to use mocks to replace the external calls, but am having trouble doing that in PHPUnit. I've seen references to other frameworks like Mockery and Prophecy, but all current tests are written in PHPUnit, so I'd prefer to stick with it. The examples I've found so far either don't work, or don't show the code under test, or seem to make silent assumptions that don't quite hold. In particular:
resolve() function to do dependency injection, and I haven't found any documentation on it at all - and certainly not in the context of mocking unit tests.I'll start with my draft of the TestCase, then show the code under test:
<?php
namespace Tests\Unit;
use App\Services\Implementations\SarahHandler;
use App\Services\Implementations\SarahRequest;
use Tests\TestCase;
class SarahCodeToExamineMocksTest extends TestCase
{
public function testChainHandling()
{
$mockRequest = $this->getMockBuilder(SarahRequest::class)
->setMethods(['sendRequest'])
->getMock();
$mockRequest->expects($this->once()) // assert mock's only be called once
->method('sendRequest')
->will({
$mockRequest->response = 'Mock Request Response';
return $mockRequest;
});
$url = 'https://stackoverflow.com';
// Original source code uses this resolve() built-in.
$handler = resolve(SarahHandler::class);
$result = $handler->handle($url);
// Assert it completed using the Mock
assertEquals($result, 'Mock Request Response');
}
}
The class to be mocked is more complex than this, but this shows the essential pieces. I would like to keep as much of the class in place as possible and only mock the external call ( sendRequest() method) itself:
<?php
namespace App\Services\Implementations;
use App\Services\Interfaces\SarahRequestInterface;
class SarahRequest implements SarahRequestInterface
{
private $url;
private $response;
public function setUrl(string $url): SarahRequestInterface
{
$this->url = $url;
return $this;
}
public function sendRequest(): SarahRequestInterface
{
echo 'Calling the internet...';
$this->response = 'Real external string';
return $this;
}
public function getResponse(): string
{
return $this->response;
}
}
This is the class which provides the top-level function-under test. Again, the real version does more than this, and I want to cover as much of that functionality as I can, but this shows the instantiation of the to-be-mocked class:
<?php
namespace App\Services\Implementations;
use Illuminate\Support\Facades\Log;
use App\Services\Interfaces\SarahRequestInterface;
class SarahHandler
{
public function handle(string $url)
{
return $this->doStuff($url);
}
private function doStuff(string $url): string
{
$request = resolve(SarahRequestInterface::class);
$response = $request->getResponse($url);
return $response;
}
}
Here's the interface which is being used to index the class which needs a good mocking:
<?php
namespace App\Services\Interfaces;
interface SarahRequestInterface
{
public function setUrl(string $url): SarahRequestInterface;
public function sendRequest(): SarahRequestInterface;
public function getResponse(): string;
}
Here's the actual syntax error I get with the code as it appears above:
PHP Fatal error: Uncaught ParseError: syntax error, unexpected token "{" in /app/tests/Unit/SarahCodeToExamineMocksTest.php:21
Stack trace:
#0 /app/vendor/phpunit/phpunit/src/Util/FileLoader.php(49): PHPUnit\Util\FileLoader::load()
#1 /app/vendor/phpunit/phpunit/src/Framework/TestSuite.php(397): PHPUnit\Util\FileLoader::checkAndLoad()
#2 /app/vendor/phpunit/phpunit/src/Framework/TestSuite.php(536): PHPUnit\Framework\TestSuite->addTestFile()
#3 /app/vendor/phpunit/phpunit/src/TextUI/TestSuiteMapper.php(67): PHPUnit\Framework\TestSuite->addTestFiles()
#4 /app/vendor/phpunit/phpunit/src/TextUI/Command.php(391): PHPUnit\TextUI\TestSuiteMapper->map()
#5 /app/vendor/phpunit/phpunit/src/TextUI/Command.php(112): PHPUnit\TextUI\Command->handleArguments()
#6 /app/vendor/phpunit/phpunit/src/TextUI/Command.php(97): PHPUnit\TextUI\Command->run()
#7 /app/vendor/phpunit/phpunit/phpunit(98): PHPUnit\TextUI\Command::main()
#8 {main}
Next PHPUnit\TextUI\RuntimeException: syntax error, unexpected token "{" in /app/vendor/phpunit/phpunit/src/TextUI/Command.php:99
Stack trace:
#0 /app/vendor/phpunit/phpunit/phpunit(98): PHPUnit\TextUI\Command::main()
#1 {main}
thrown in /app/vendor/phpunit/phpunit/src/TextUI/Command.php on line 99
Finally, some references
resolve() I'm seeing, and some of the functions seem to have been deprecated in the decade since it was posted.resolve(), and following the procedure as best I could seemed to disable the ORM.SarahRequest->response), or the resolve() function. Similarly with the other examples I've found.Are there ways to do these mocks? To what extent do I need to refactor the code under test and what's the recommended way of doing that?
@MatthewAnderson's comment on my Q got me looking at ServiceProviders and trying to subclass the code under test, which was a key step in solving this. I had to fix a couple other issues to get a fully-working group of files, so am posting them here for comparison:
The TestCase calls $this->app->bind() to select a mocked subclass of the code containing the method I want to swap out:
<?php
namespace Tests\Unit;
use App\Services\Implementations\SarahHandler;
use App\Services\Implementations\SarahRequest;
use App\Services\Interfaces\SarahRequestInterface;
use Tests\Mocks\MockSarahRequest;
use Tests\TestCase;
class SarahCodeToExamineMocksTest extends TestCase
{
public function testChainHandling()
{
$this->app->bind(
SarahRequestInterface::class,
MockSarahRequest::class
);
$url = 'https://stackoverflow.com';
// Original source code uses this resolve() built-in.
$handler = resolve(SarahHandler::class);
$result = $handler->handle($url);
// Assert it completed using the Mock
$this->assertEquals($result, 'Mock Request Response');
}
}
The variables in the class being mocked needed to be protected rather than private so that my (Mock) subclass could use them:
<?php
namespace App\Services\Implementations;
use App\Services\Interfaces\SarahRequestInterface;
class SarahRequest implements SarahRequestInterface
{
protected string $url;
protected string $response;
public function setUrl(string $url): SarahRequestInterface
{
$this->url = $url;
return $this;
}
public function sendRequest(): SarahRequestInterface
{
echo 'Calling the internet...';
$this->response = 'Real external string';
return $this;
}
public function getResponse(): string
{
return $this->response;
}
}
I was skipping a couple steps in the top-level code under tests. These were in the original code, but I missed them when constructing my example for post:
<?php
namespace App\Services\Implementations;
use Illuminate\Support\Facades\Log;
use App\Services\Interfaces\SarahRequestInterface;
class SarahHandler
{
public function handle(string $url)
{
return $this->doStuff($url);
}
private function doStuff(string $url): string
{
$request = resolve(SarahRequestInterface::class);
$response = $request->setUrl($url)->sendRequest()->getResponse();
return $response;
}
}
And here's the mock class I wrote to replace the target code during the test:
<?php
namespace Tests\Mocks;
use App\Services\Implementations\SarahRequest;
use App\Services\Interfaces\SarahRequestInterface;
class MockSarahRequest extends SarahRequest
{
public function sendRequest(): SarahRequestInterface
{
echo 'No calls allowed';
$this->response = 'Mock Request Response';
return $this;
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With