Feature testing with PHPUnit and things to avoid
Published: 2024-06-06 09:45:33

I’ve had the opportunity to see many feature tests in PhpUnit that lack the fundamentals of a proper Unit test. We will discuss how to write tests properly and ensure that our tests are valuable. Before we begin, if you are unfamiliar with PHPUnit or Testing as a concept no worries, I’ll give a brief background.

In today’s changing world, where we have embraced a continuous delivery and integration of our services and products, they often change frequently. Whether changed by the original author/maintainer, or by someone else. It is therefore imperative that we have some kind of safety, some tests that ensure that the behaviour of the service remains the same except intentionally modified.

But there are different types of tests, Unit Tests, Feature Tests, Integration Tests, Performance Tests and so on. In this post, I want to focus on how we should write Feature tests the right way.
Feature tests are types of tests that validate different variations of a feature. Feature tests ensure that users see and experience what you want them to experience. I believe feature tests can also qualify as integration tests.



### Always make sure your tests are independent of each other

let's take a scenario, where a user will call a simple get endpoint that would be handled by a service. Take a look at the test below:

```php
    class OrderServiceTest extends TestCase
    {
        private $user = null;
        private $service = null;
    
        public static function setupBeforeClass(): void
        {
          $this->user = User::factory()->create([
                  'permissions' => []
          ]);
          $this->service = new OrderService($this->user);
        }
    
        public function test_get_order_service_throws_when_user_does_not_have_access()
        {
            $this->expectException(UnauthorizedException::class);
            $this->service->get();
        }
    }
```


As you can see, in the `setupBeforeClass( )` we are instatiating the service which depends on the `$user`.

When you have just 1 test in this file, this is fine, however, this becomes a bad idea when you have multiple tests. The given test does not control the instatiation of the service. Therefore it is possible for other tests to modify the `$this->user` or `$this->service` while this test is running. 

Also, this test does not paint the full picture of an actual user journey when they try to call the order get endpoint. So to decouple the test we can have something like this:

```php
    public function test_get_order_service_throws_when_user_does_not_have_access()
    {
        $user = User::factory()->create([
                'permissions' => []
        ]);
        $service = new OrderService($this->user);
        $this->expectException(UnauthorizedException::class);
        $service->get();
    }
```

As you can see, the test now has the complete setup of what it needs before its assertions. This test does not depend on anything else and is easy to understand what needs to exist before this exception can be thrown.

Note that this does not mean that using `setupBeforeClass( )` is bad, we can always setup things that the entire tests generally need and is guaranteed to be needed in exactly the same way like an instance of a mock.




### Do not test too many things at once

When we have a complex feature that does a number of things. We can easily write 1 test that handles many assertions, this makes it feel like that singular test will cover all the scenarios. - Don’t do this.

Having atomic tests that focus on a single flow with a couple of assertions is more valuable and easier to understand by others.

Typically, the easiest way to divide a huge feature/method to multiple independent tests is to think about all the code paths, if statements, exceptions, and external service calls and try to test them in each test.

Note that the combination of all these tests eventually still gives you a full feature test.



### Only Mock 3rd Party services

Mocking is a process used in testing when the feature being tested has external dependencies. The purpose of mocking is to isolate and focus on the code being tested and not on the behaviour or state of external dependencies.

We should only be mocking 3rd party services in our tests, 3rd party services are external systems like APIs or SDKs that are been used in the test. These tools are what we do not have control over, hence needs mocking.

Avoid mocking the Database, other methods in the application and so on. This is important because again, we are writing a feature test and not a unit test.




### Avoid Huge test files

I know this may be subjective, but it is actually good for our future selves. Imagine having a failing feature test and having to look for the test in a 4K lines of code and wondering if the test is failing because of some flaky test above or below it.

If you have a major feature you can create a folder just for that feature and have different test files which would have similar test scenarios in them and are grouped together. This way - when such a scenario needs to change, it would be very clear which test needs to be included, modified or deleted in context.



### Avoid tests using other features to Setup

Tests typically follow the `GIVEN-WHEN-THEN` approach. It is always easy to see the context given to a test in the first few lines (typically considered setup) for that test.

Setup should be as basic and direct as possible. Do not use other service methods to do a setup, just because the internal implementation for that service is doing the same expected setup that is needed.

The obvious reason for this is still related ot independence, your test should be as independent as possible without been at risk of failing just because a completely unrelated feature was modified. If you have to insert records to the database to set some context, do not use a register( ) method for example even though it does the same insert as you expect. You should always cary out all the setup manually without calling existing methods.



### Avoid Logic / Code Complexities in your assertions

When you have to do assertions that may be alot, lets say asserting that an array in a particular order, it is always a good idea to do this manually. Take a look at the set of assertions below:

```php

    $this->assertEquals('One', $reponse[0]);
    $this->assertEquals('Two', $reponse[1]);
    $this->assertEquals('Three', $reponse[2]);
```

It is very easy to see things like this and say, Oh, I can just do a `foreach( )` and have 1 `$this->assertEquals( )` . Avoid that. This is because you don’t want your test carrying unnecessary logic when it should just focus on behaving as the user and as close to that as possible.
Tests have to be clear, it needs to be as clean, simple and straightforward as possible.




### Do not expose private properties as public just because of the tests

When a class has private properties and you do like to validate their state in a give test, avoid setting them as public. Create a getter instead.

Infact, often times, these kind of properties can be implicity tested, for example, if the value of such property is eventually going to be written to the database, then you can check the database.
When the feature itself is been highly modified just because of the tests, you should already know that something is not right.

In conclusion, feature tests should always focus on ensuring that the feature is used right, it should be as simple, independent and straightforward as possible.