10 PHPUnit Tips

PHPUnit is the defacto testing framework for PHP. In this post i want to share with you my top 10 tips for PHPUnit. I’m using PHPUnit 9.5, but most of these apply to older versions as well. So, lets get this party started.

1: Stop using assertEquals

One of the most common mistakes is using assertEquals. Instead, you should be using assertSame. When using equals we are doing an == check, instead of ===. Which means that the following test passes.

public function testThatPasses(): void
{
    $this->assertEquals('3', 3);
}

However, if we use assertSame, we are doing === checks, which also take types into account. Meaning the values have to be the same value, and the same type. So the following test would fail, with the following error message: Failed asserting that 3 is identical to '3'.

public function testThatFails(): void
{
    $this->assertSame('3', 3);
}

2: Use a data provider when possible

When you need to test a lot of variations of a test, using a data provider will save you from copy pasting the same set up code everywhere. I wrote a post about this already, so check that out right here.

3: Use createStub instead of createMock

Since PHPUnit 8.4 we have createStub. Which solved one of the issues with mocking in PHPUnit. Before that, everything was a mock. While in general testing terms there are mocks, stubs and spies.

A mock object is an object where you configure expectations on. So you can configure that a method must be called exactly once, and you can configure with what params the method should be called.

A stub on the other hand is an object that is just a placeholder. This can mean that it isn’t actually used in the part you are testing. Or it means that you only configure that a method will return certain values when called. The difference here is that you don’t test how many times a method is called, or with what params.

By using createStub you starting using the correct terminology for your test doubles. This should help other better understand what is going on in your test suite.

4: Inject static classes

Not too long ago we had to test a method that used an external SDK, which was using a lot of global state. A simplified version of our method looked like this:

function sendRequest(array $data): bool
{
    $result = Transaction::start($data);

    return (bool) $result['body']['data']['success'];
}

Transaction::start could throw a specific exception, and we needed to handle that. But we can’t mock transaction like this, so we needed to change this method to make it testable. We changed the method to the following, to allow us to inject transaction, for testing only

function sendRequest(array $data, ?Transaction $transaction = null): bool
{
    $transaction ??= new Transcation();
    $result = $transaction::start($data);

    return (bool) $result['body']['data']['success'];
}

Now, in our test we can inject the Transaction class.

public function testSendRequest(): void
{
    $transaction = new class extends Transaction
    {
        public static function start(array $data)
        {
            throw new ApiException();
        }
    };

    $this->assertFalse(sendRequest([], $transaction));
}

This test now fails, as we get the exception instead of returning false on this exception. But now we can modify our code to make the test pass.

5: Make your tests fail

You want to make sure your tests are doing their job. So you want to be sure that they are actually testing what you think they are. So lets take a look at the following basic validation function. It checks that the two inputs are both integers.

function validateInput($one, $two): void
{
  if (!is_int($one)) {
    throw new InvalidArgumentException('one must be an int');
  }

  if (!is_int($two)) {
    throw new InvalidArgumentException('two must be an int');
  }
}

We could then create a test for this method like so:

public function testBothMustBeIntegers(): void
{
  $this->expectException(InvalidArgumentException::class);
  validateInput('1', '2');
}

But, if we remove the first (or the second) check, the test still passes. This test isn’t strict enough. Instead, we need more test cases.

function validateInput($one, $two): void
{
-  if (!is_int($one)) {
-    throw new InvalidArgumentException('one must be an int');
-  }

  if (!is_int($two)) {
    throw new InvalidArgumentException('two must be an int');
  }
}
/** 
 * @dataprovider provideNonIntegersCases
 */
public function testBothMustBeIntegers($one, $two): void
{
  $this->expectException(InvalidArgumentException::class);
  validateInput($one, $two);
}

public function provideNonIntegersCases(): Generator
{
  yield ['1', '2'];
  yield [2, '2'];
  yield ['1', 4];
  yield [2, 3.5];
}

Now, we use a data provider to test different cases. And if we remove one of the checks now, a test will fail.

We can do this manually, or we can use a mutation testing framework, like infection to help us with ths.

6: Use static analysis on your tests.

You should be using static analysis on your source code. But you should also use it on your tests. It will help you catch bugs in your tests, just like it does in normal code.

But, it will also make sure you aren’t testing too much. For example if you have the following function that sends an api request.

/**
 * @param string|resource $data
 */
function sendApiRequest($data): void
{
  $this->client->post([
    'body' => $data
  ]);
}

This method takes either a string, or a resource, and uses that as the data to send. If, in your tests you pass a class to this method, just to see what happens, that would give you an error in your static analysis tool. So, since you won’t be able to do that in your normal code, you don’t have to worry about it in your tests.

An important thing to note here, is that you should be running your test suite on the same level as your ‘normal’ code. If your test suite is stricter, then the error you get there may not show up in your normal code. Ideally both the source and test folder should be checked in the same process.

7: Test the simple stuff

To people who don’t like tests, they always fall in one of two categories. The code is either impossible to test, or it is so simple that it is not worth testing. I’d say, write the simple tests. If the code is worth writing, its worth testing. Generally the time you spend on writing the simple tests isn’t what is going to slow you down.

Even a test like this is worth writing. You wouldn’t be the first (or the last) to accidentally assign the wrong property in the constructor, or read the wrong property in a getter.

Especially if it is a large pull request, these are the kind of thing that get looked over.

public function testItCanBeCreated(): void
{
    $link = new Link('name', '/url');

    $this->assertSame('name', $link->getName());
    $this->assertSame('/url', $link->getUrl());
}

8: Do a sanity check

Be kind to yourself, and do a sanity check before test. Especially something that interacts with a global state of any kind. If you do a quick check before hand, you make sure your test is still doing what it is supposed to do.

For example, we wanted to make sure our SessionFactory did not start a php session when creating the session object. Here we check that we do not have a session id, before we ever start the test. I always add a sanity check comment, so that everyone understands this is not what is actually tested, but a prerequisite.

/**
 * @runInSeparateProcess 
 */
public function testNewSessionDoesNotStartAPhpSession(): void
{
    // sanity check
     $this->assertSame(
        '',
        session_id(),
        'A session was already started before this test ran.'
    );

    $factory = new SessionFactory();
    $session = $factory->newSession();

    $this->assertSame('', session_id());
    $this->assertFalse($session->isStarted());
}

9: Test 3 (or more) variants of your loops

Whenever you have a loop in your code, whether it’s a for, foreach, while or do while loop. Try to test at least 3 versions of it.

  • No loops
  • 1 loop
  • 3 loops

This depends on your code though. If the loop is never reached if the input array is empty, then that it not something you have to consider. But by testing these 3 versions you account for a few potential bugs.

You may be using variables form inside the loop after it. Since these variables don’t exists if you never loop, this should error in your tests. Or when a variable is reused in the next loop by accident, for example if it is declared within an if. When you do multiple loops, this should give you a wrong result if you do multiple loops.

10: Just start writing the tests

A mediocre test is better then no tests. You may write test that aren’t perfect. But when you start writing code it’s generally not perfect either. The only real way to improve the tests you write is by writing more of them.

Avatar
Gert de Pagter
Software Engineer

My interests include software development, math and magic.