DEV Community

Adam Quaile
Adam Quaile

Posted on

Using Traits to Organise PHPUnit Tests

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?

Latest comments (1)

Collapse
 
courtneymiles profile image
Courtney Miles • Edited

I avoid traits in favour of public static methods, factories classes and builders, fluent asserters and a service container (Pimple) exclusively for tests.

All these tools allows for reusability, but also a LOT of flexibility where each test may need to deviate slightly.

For your example:

  • I would define a builder for the API client and use it in each test method. (E.g. $api = ApiBuilder::create()->setUrl('...')->setHeader(...)->build();)
  • I would make JsonAssertionsTrait a class with public static assertion methods.
  • I would make MockedClockTestTrait a class with a public static factory method.
  • I have a service that provides convenient methods to managing the database. (E.g. $dbTestService->truncateTables(['articles', 'users'])).
  • I keep the ::setup method defined in each test class so other developers can see what I'm doing.