How and why you should separate your PHP test suites.
I recently attempted to answer a question on stack overflow in regards to PHPUnit testing. Although I feel I wasn't able to convey my answer in a understandable way. So what better way than to try again here...
Unit tests vs Functional Tests
A unit test is a test written that checks the behavior of a specific isolated block of code, without depending on the real input from external dependencies. So what are external dependencies? Anything outside of the method/function under test that said method/function relies upon. I.e. a different class method within the codebase, an API response from another server, etc... In unit testing, mocking dependencies allows us to test a code block without having to rely upon it's dependencies.
A functional test on the other hand is a test written to ensure that all of the code within a code base interacts with each other and its dependencies as expected. When writing functional tests, you don't usually mock your dependencies. More on this below.
Unit Tests
Look at the following two classes. In all, \Store::class
has one dependency,\FruitBasket
, and \FruitBasket
has a dependency of it's own,\Connection::class
, which provides a connection to a "server far away".
class FruitBasket
{
public $serverFarAway;
public $fruitArray = ['apple', 'orange']
public function __construct(\Connection $serverFarAwayConnection)
{
$this-serverFarAway = $serverFarAwayConnection
}
public function getByType(string $type): string
{
$key = in_array($type, $this->fruitArray);
if (!$key) {
return $this-fruit[$key];
}
return $this->getFruitByTypeFromServerFarAway($type);
}
protected function getFruitByTypeFromServerFarAway(string $type): string
{
return $this-serverFarAway->getFruit($type);
}
}
class Store
{
public $basket;
public function __construct(\FruitBasket $basket)
{
$this->basket = $basket;
}
public function getFruit(string $typeOfFruit): string
{
return $this-basket->getByType($typeOfFruit);
}
}
To unit test \Store::class
, we must mock \FruitBasket
before testing the\Store::getFruit()
method.
//UnitTest.php
use PHPUnit\Framework\TestCase;
class UnitTest extends TestCase
{
public function testGetFruitReturnsOrangeWhenOrangeIsTheParam(): void
{
$mockBasket = $this-createMock(\FruitBasket::class);
$mockBasket->expects($this-once())
->method('getByType')
->with('orange')
->willReturn('Orange')
;
$store = new Store($mockBasket);
$result = $store->getFruit('orange');
self::assertSame('Orange', $result);
}
}
The above test is actually performing 2 assertions. The first, self::assertSame
is obvious. We are testing that $store->getFruit()
returns the result from\FruitBasket::getByType()
. The 2nd assertion is that \FruitBasket::getByType()
is actually being called by \Store::getFruit()
exactly 1 time. We are also ensuring that when we call \Store::getFruit('orange')
, our 'orange' parameter is being passed to \FruitBasket::getByType()
. We are accomplishing this without actually using a real \FruitBasket
object in the test.
The next unit tests we would write would be for \FruitBasket::class
;
//UnitTest2.php
use PHPUnit\Framework\TestCase;
class UnitTest2 extends TestCase
{
public $mockConnection;
protected function setUp(): void
{
$this-mockConnection = $this-createMock(\Connection::class);
}
public function testGetByTypeReturnsFruitInFruitAway(): void
{
$expectedResult = 'apple';
$basket = new \FruitBasket($this-mockConnection);
$result = $basket->getByType('apple');
self::assertSame($expectedResult, $result);
}
}
We are essentially doing the same as before, expect this time we are using the inheritedTestCase::setUp()
method. setUp()
allows us to create a fresh mock of the\Connection::class
before each test is run. Of course we need the \Connection::class
to create a new \FruitBasket::class
instance.
Now we could write more tests for the different edge cases that are possible for\FruitBasket
. We could also write assertions for the \Connection:class
mock to ensure that if a fruit is not found in $fruitArray
,\Connection::getFruitByTypeFromServerFarAway()
is called with the $type
parameter. You would write those assertions just as I did in the previous example.
Functional Tests
After we have written our unit tests to ensure each individual method performs as expected, it's time to test if both classes work together as expected. There are 2 ways to go about this. The first, in true functional test form, would be to use a real \Connection::class
to connect us to a "server far way."
The upside to this, is we don't have to write mocks out for \Connection::class
and we will be absolutely sure that all the code act's as expected in a perfect world. However, for every upside there's a downside. First, if \Connection::class
is providing us a connection to a MySQL server or API connection that we can't control, which is often the case in the real world; We wouldn't want to be hitting those resources over and over again with our test suite. Think rate limiting for API's.
Second, who knows what data exists on the "server far away" servers. What if 6 months from now, there were no more bananas left, and we needed to test if we called\Store::getFruit('banana)
, \FruitBasket::getFruitByTypeFromServerFarAway
returned us a banana in our functional test.
How do we overcome this? My approach is to use a modified version of Functional Testing. If possible, I will replicate the external resource. I.e. create a MySQL docker container that has bananas in a table. Or, just use a mock \Connection::class
object for all but a few of my functional test methods. You could also mock\Connection::class
for all of your Functional tests and utilize a real connection in a separate Functional Test suite or even use a real connection only in your acceptance tests. How you do it all depends on your code base and what external dependencies you are relying on. There is no single "right way" to do it.
But for this example, I'm going to mock our \Connection::class
in the functional tests because \Connection::class
provides a connection to a server which I don't control and has rate limits.
//FunctionalTest.php
use PHPUnit\Framework\TestCase;
class FunctionalTest extends TestCase
{
public $mockConnection;
protected function setUp(): void
{
$this-mockConnection = $this-createMock(\Connection::class);
}
public function testGetFruitReturnsFruit(): void
{
$fruitBasket = new \FruitBasket($this-mockConnection);
$store = new \Store($fruitBasket);
$result = $store->getFruit('apple');
self::assertSame('apple', $result);
}
}
In the example above, we are testing the \Store::getFruit()
and \FruitBasket::getByType()
interact with each other as expected. But what happens when \FruitBasked->$fruitArray
doesn't contain the desired fruit?
//FunctionTest.php
use PHPUnit\Framework\TestCase;
class FunctionalTest extends TestCase
{
public $mockConnection;
protected function setUp(): void
{
$this-mockConnection = $this-createMock(\Connection::class);
}
public function testGetFruitReturnsFruit(): void
{
....
}
public function testGetFruitReturnsBananas(): void
{
$this-mockConnection->method('getFruit')
->willReturn('Banana')
->with('banana')
$fruitBasket = new \FruitBasket($this-mockConnection);
$store = new \Store($fruitBasket);
$result = $store->getFruit('banana');
self::assertSame('Banana', $result);
}
}
We are still using a mock connection, but we a doing it while running a functional test. As \Store::class
is utilizing a real \FruitBasket::class
instance within the test.
Final thoughts
The key take away is unit tests should not interact with any real dependencies where as functional tests can and should where possible.
A few may argue that you shouldn't use mocks at all in functional tests. And that is true to a point. But in the examples above, \Connection::class
is an external dependency that should be tested in it's own code base. And as such, mocking it is safe in this use case. As the developer, you should pick external dependencies that are thoroughly tested and maintained regularly before implementing them in your own code base.
I hope this helps. As always, comments, rants, suggestions, and constructive criticism is always welcome.
- Jesse Rushlow
jr (at) rushlow (dot) dev
Top comments (0)