Adam Quaile
Posted on April 15, 2020
I'd like to share a way I've been organising tests in PHPUnit. Maybe you've tried it before already, and maybe it's a terrible idea. What's worked for me might not work for you.
I'm going to assume you've written tests before and know some PHP. Let's jump in!
The example setup
Let's start with testing a blogging platform like Dev. It has an API and a web interface and sends emails to an author's followers when a new article is posted.
Here's a test where a user is authenticated with an API, publishes an article, but has no followers, so no emails are sent:
public function test_no_emails_sent_for_author_with_no_followers(): void
{
$this->givenApiAuthenticatedAsUser('1');
$this->api->post('/articles', ['content' => '...', 'published' => true]);
$this->emails->assertCountEmails(0);
}
There's a few things missing.
- Where is
$this->givenApiAuthenticatedAsUser
defined? - How is
$this->api
setup? - What about
$this->emails
?
Let's look at $this->api
first. It could be any API client, like Guzzle or a Symfony HttpClient. It's not important which one here, but it'd be defined something like this
public function setUp(): void
{
$this->api = new SomeClient('https://api.blog.example.com', ...);
}
Next, $this->givenApiAuthenticatedAsUser
. For our purposes, this simulates an OAuth token and adds it as a header like this
protected function givenApiAuthenticatedAsUser(string $userId): void
{
$token = 'test token';
$this->api->headers['Authorization'] = "Bearer $token";
}
Finally, $this->emails
. Let's assume this is an assertion library you've found for Mailhog (though I'm not sure one actually exists).
public function setUp(): void
{
$this->emails = new SomeMailhogAssertionClient('http://mailhog.test');
$this->emails->reset();
}
Organisation with traits
I'm sure you can imagine several tests in this fictional setup, e.g. ArticlesApiTest
, UsersApiTest
and UsersWebTest
. The last one might use BrowserKit or Panther but I'll skip the details here since it's the same as the API setup.
Since ArticlesApiTest
needs the setup code for the API and the setup code for emails but we don't want to duplicate it all the time, one solution might to put the setUp
in BaseTest
and extend from that.
This gets a bit tricky if we're already extending from Symfony's WebTestCase or KernelTestCase for example. It's one of the cases where we'd prefer to use composition.
Since we don't have any sort of dependency injection options in PHPUnit which would cover this, and some of our methods are accessed directly via $this
, we'll do it with Traits and PHPUnit annotations.
Since we've seen all the parts individually already, a larger example will be better here:
class ArticlesApiTest
{
use ApiTestTrait;
use EmailsTestTrait;
public function test_no_emails_sent_for_author_with_no_followers(): void
{
$this->givenApiAuthenticatedAsUser('1');
$this->api->post('/articles', ['content' => '...', 'published' => true]);
$this->emails->assertCountEmails(0);
}
}
trait ApiTestTrait
{
/**
* @before
**/
public function setUpApiBeforeTest(): void
{
$this->api = new SomeClient('https://api.blog.example.com', ...);
}
protected function givenApiAuthenticatedAsUser(string $userId): void
{
$token = 'test token';
$this->api->headers['Authorization'] = "Bearer $token";
}
}
trait EmailsTestTrait
{
/**
* @before
**/
public function setUpEmailsBeforeTest(): void
{
$this->emails = new SomeMailhogAssertionClient('http://mailhog.test');
$this->emails->reset();
}
}
By replacing setUp
with uniquely named methods and the @before
annotation, we're able to split the code into separate chunks that we can opt in to.
Now resetting the emails, which makes a slow API call, only happens when we explicitly bring it in to our test. Our IDE autocompletion list is cleaner because it knows when we might or might not use certain features or assertions.
We're also free to extend another class if we have to, e.g. WebTestCase
(If any Symfony contributors are reading, I think it'd be great if we could use WebTestCase
instead).
Extra example Traits
Now that I've shown the concept, I want to show a few other examples and ideas for how you could use it.
Mocking global state, like time?
trait MockedClockTestTrait
{
protected function givenTimeIs(\DateTimeImmutable $time): void
{
// Populate a mocked time object,
// or use Clock Mocking from PHPUnit Bridge
}
}
Have a lot of assertions on JSON?
trait JsonAssertionsTrait
{
public function assertJsonContainsKey(): void;
public function assertJsonHasValueAtPath(string $jsonPath, $expectedValue): void;
}
Reset the database in some tests and persist objects with JSON
trait DatabaseTestTrait
{
/**
* @var \Doctrine\Common\Persistence\ObjectManager
*/
protected $objectManager;
abstract protected static function getContainer(): ContainerInterface;
/**
* @before
*/
public function resetDatabaseBeforeTest(): void
{
$registry = static::getContainer()->get('doctrine');
$connection = $registry->getConnection();
$this->objectManager = $registry->getManager();
$connection->executeUpdate('DELETE FROM articles');
$connection->executeUpdate('DELETE FROM users');
}
}
What do you think? Have you used this method before? Any downsides?
What would you put in a trait?
Posted on April 15, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.