close icon
Laravel

Testing Laravel APIs Using PHPUnit

Learn how to test Laravel APIs using PHPUnit.

December 17, 2020

TL;DR: In this tutorial, we will learn how to build and test various endpoints of a RESTful API created with Laravel. Our approach to achieving this will include:

  • Setting up a testing environment
  • Writing tests for our application’s endpoints with an expectation that they will fail
  • And structuring and building our API to pass the tests

We will do this gradually in chronological order for each defined API endpoint. The complete source code for this tutorial can be found here on GitHub.

As a software engineer who is considering taking development a little step further (and you should be doing that), test-driven development, often referred to as TDD, is one of the best practices you can add to your arsenal. To expressively test your applications, Laravel comes installed with PHPUnit, a testing framework for PHP. It contains convenient helper methods useful for creating and running test scripts specifically written for different units within your application.

Prerequisites

A reasonable knowledge of object-oriented programming with PHP will help you get the best out of this tutorial. Throughout the tutorial, I will do my best to explain the Laravel concepts I raise and provide links for further research. Please feel free to pause and go through them if you’re lost at any point — I promise this tutorial will still be here when you come back.

Your development environment will also need to satisfy the server requirements set out by Laravel - including a global installation of Composer.

You can set up new Laravel projects either using Composer or the Laravel Installer.

In this tutorial, we will be using PHPUnit and Xdebug for testing and coverage analysis. Please make sure you have Xdebug installed on your system.

Importance of Testing

The importance of testing comes down to two things - assurance and maintainability.

The internet is replete with stories of software bugs costing organizations millions in damages. There is one common theme among all these stories — inadequate testing. Either no tests were carried or the tests didn’t sufficiently cover all the potential scenarios the application would encounter. As developers, we tend to model our applications around ideal scenarios but the production environment is anything but ideal. Having our tests cover ALL possible scenarios and ensuring that our application behaves in an expected manner in all of them is of utmost importance.

The issue of maintainability builds on assurance. Have you ever looked at a piece of code and thought to refactor it, but held back because you were afraid of breaking the application? Instead, you duplicate it in another function and then use that instead. This is how the code begins to fester (in the words of Uncle Bob) and bugs get introduced. A breaking change is implemented in one class but not in duplication in another class. The application essentially becomes a time bomb waiting to explode. Also, proper testing makes processes like CI/CD more reliable — making it possible to release updates quickly. Needless to say, one cannot overestimate the importance of testing.

Test Driven Development

Test Driven Development (TDD) is a programming style based on three pillars: testing, coding, and design (refactoring). TDD is somewhat anomalous to the regular developer experience in the sense that we tend to write code first before our test cases. However, this taints the quality of our test cases, as our tests will be guided by our code (which only handles ideal conditions).

Also, in writing code first, we will run the risk of writing more code than necessary to solve the problem which, unchecked, introduces bugs into our application.

TDD is essentially a cycle of 3 events:

  1. Writing a test case. Because there’s no code to run, this test will fail and is also referred to as the RED phase of TDD.
  2. Write code to pass the test (No more, no less). This is known as the GREEN phase of TDD
  3. Refactor the code. This could be in the form of removing duplications or restructuring to conform to best practices (DRY, KISS, SOLID to name a few). This is also referred to as the Blue phase of TDD.

The cycle continues throughout the development of the application and even during the maintenance of the application. For example, if you were to tweak a feature of the application, you start by tweaking the appropriate test case to match the new specifications. This will cause the test to fail. Then you tweak the code to pass the test and finally refactor to ensure that the code is still clean.

Needless to say, it takes time to build this habit - especially when you’ve not been in the habit of testing. However one can build this habit by writing test cases and using coverage reports to fine-tune the code quality.

In this tutorial, we’ll be taking an application with minimal poor coverage and writing test cases to improve the code quality.

Set Up the Demo Project

For this tutorial, we’re going to be working with a hedge fund investment simulator. In our simulator, there are several strategies available for a user to invest in. The user also has a wallet where returns from investments are deposited. For each investment, the return is determined based on whether or not the investment is successful. Every investment strategy has two multipliers - the yield and the relief. If the investment is successful, the returns of the investment are the invested amount multiplied by the yield of the strategy. If not, the invested amount is multiplied by the strategy relief to get the returns of the investment.

Go ahead and clone the application

git clone https://github.com/yemiwebby/laravel-phpunit-starter
cd laravel-phpunit-starter

Take a few minutes to go through the project and get familiar with it. From the project case study, we can identify four resources:

  1. User
  2. Wallet
  3. Strategy
  4. Investment

Except for the Wallet resource, an API route is available to create, update, delete, and find one resource. An endpoint is also provided to get all of each resource. You can view the routes exposed by the API in routes/api.php.

Next, install the application dependencies using the following command:

composer install
cp .env.example .env

Once the process is completed, proceed to create a database for your application and modify the .env file accordingly

DB_CONNECTION=YOUR_DB_CONNECTOR
DB_PORT=3306 # 3306 for MySQL
DB_DATABASE=YOUR_DATABASE_NAME
DB_USERNAME=YOUR_DATABASE_USERNAME
DB_PASSWORD=YOUR_DATABASE_PASSWORD

Swap the YOUR_DATABASE_NAME, YOUR_DATABASE_USERNAME, and YOUR_DATABASE_PASSWORD with the appropriate values for your database.

There is an existing database schema and Seeder classes created for the demo application and located in the database/migrations and database/seeders folders respectively. Issue the following command to update your created database with the schema and seed some test data into it afterward:

php artisan migrate --seed

Serve your application and try making some requests

php artisan serve

Testing

For testing, you will be using PHPUnit. All your tests will be located in the tests directory of the application. The folder structure we will take will be one that somewhat matches the application structure. The code to test your controllers will be in the tests/Controller directory. The names given to your test files will also match the name of the class you are testing. For example, the name of the file containing the test cases for our InvestmentController is InvestmentControllerTests.

Finally, all your test functions will be prefixed with test. For instance, if you want to write a test case to ensure that your InvestmentController is properly creating investments, you could name the function testInvestmentIsCreatedSuccessfully. The function names in your test cases should be descriptive to allow us to understand what the test aims to achieve. It also helps us manage our code (split into different tests if the function name gets too long).

The command to run all our tests is shown below

vendor/bin/phpunit

To make our tests fun faster, we will be SQLite for our testing database. This has already been configured in the phpunit.xml file. Your php node should look like this

<php>
    <server name="APP_ENV" value="testing"/>
    <server name="BCRYPT_ROUNDS" value="4"/>
    <server name="CACHE_DRIVER" value="array"/>
    <server name="DB_CONNECTION" value="sqlite"/>
    <server name="DB_DATABASE" value=":memory:"/>
    <server name="MAIL_MAILER" value="array"/>
    <server name="QUEUE_CONNECTION" value="sync"/>
    <server name="SESSION_DRIVER" value="array"/>
    <server name="TELESCOPE_ENABLED" value="false"/>
</php>

The tests/TestCase.php has been modified to seed the database when setting up the tests. To generate random data, we will use the Faker bundle. A magic getter has also been added so you can easily access the faker when you need to generate random data. Your tests/TestCase.php should look like this:

<?php
    
namespace Tests;
    
use Exception;
use Faker\Factory;
use Faker\Generator;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;
    
abstract class TestCase extends BaseTestCase {
    
    use CreatesApplication, DatabaseMigrations;
    
    private Generator $faker;
    
    public function setUp()
    : void {
    
        parent::setUp();
        $this->faker = Factory::create();
        Artisan::call('migrate:refresh');
    }
    
    public function __get($key) {
    
        if ($key === 'faker')
            return $this->faker;
        throw new Exception('Unknown Key Requested');
    }
}

Code Coverage

In addition to running tests, we also want to monitor code coverage. This lets you know how much of the code you have written is covered by your test cases. PHPUnit can also help with that. To see the coverage report after running the test, add a --coverage-* argument. For example, to see the code coverage report immediately after the tests are completed, use the following command:

vendor/bin/phpunit --coverage-text

For this project, you will be generating your reports in HTML format using the --coverage-html argument. This argument requires a directory for the report to be written to. For our project, this will be the tests/coverage directory.

vendor/bin/phpunit --coverage-html tests/coverage

To simplify things, a script named test has been added to the project’s composer.json file. To run the tests and generate the HTML report, type the following:

composer test

This generates an HTML file in the tests/coverage directory. Open the tests/coverage/index.html file in your browser to see your code coverage report.

Note: Make sure you have added xdebug.mode=coverage to your php.ini file.

Laravel Test coverage details

You can also follow the links to see a more detailed report. For example, the coverage report for your Controller folder:

Laravel test coverage links

As you can see, none of the code we’ve written is covered by tests. This means that we cannot predict the behavior of our application. Ideally, we want our code coverage to be as close as possible to 100% to be sure that every line of code we’ve written is properly covered by our test cases. As you will also see later in this tutorial, getting coverage of 100% is not enough to guarantee that our application is properly tested. While it’s a good start, we have to be sure that our test cases cover EVERY eventuality in our application.

Writing the First Test Cases

To start, let’s write some tests for the UserController. For each route in our controller, we’ll check that it is returning the correct response code returned. We’ll also check to make sure our controller is returning the correct data for each request.

In the tests directory, create a new folder called Controllers. Create a file called UserControllerTests.php and add the following:

<?php
    
namespace Tests\Controllers;
    
use Illuminate\Http\Response;
use Tests\TestCase;
    
class UserControllerTests extends TestCase {
    
    public function testIndexReturnsDataInValidFormat() {
    
    $this->json('get', 'api/user')
         ->assertStatus(Response::HTTP_OK)
         ->assertJsonStructure(
             [
                 'data' => [
                     '*' => [
                         'id',
                         'first_name',
                         'last_name',
                         'email',
                         'created_at',
                         'wallet' => [
                             'id',
                             'balance'
                         ]
                     ]
                 ]
             ]
         );
  }
    
}

This function will make a GET HTTP request to the index function of the UserController. It first checks that the response status corresponds to the HTTP_OK response (200). It also checks that the structure of the API response matches that provided in the function. You use '*' => [] to indicate that you expect that node to be repeated or empty.

Next, add a test case to make sure that the store function is working correctly. Add the following function to your test case:

public function testUserIsCreatedSuccessfully() {
    
    $payload = [
        'first_name' => $this->faker->firstName,
        'last_name'  => $this->faker->lastName,
        'email'      => $this->faker->email
    ];
    $this->json('post', 'api/user', $payload)
         ->assertStatus(Response::HTTP_CREATED)
         ->assertJsonStructure(
             [
                 'data' => [
                     'id',
                     'first_name',
                     'last_name',
                     'email',
                     'created_at',
                     'wallet' => [
                         'id',
                         'balance'
                     ]
                 ]
             ]
         );
    $this->assertDatabaseHas('users', $payload);
}

Similar to the first test, you created fake data using the Faker bundle and make a request to the store function of the UserController. You ensured that the HTTP_CREATED response code (201) is returned and checked to ensure it matches the expected response structure. The last assertion is to ensure that the user exists in the database.

In the same vein, you can test your show function as follows:

// ...
// add your import statements to the top
use App\Models\User;
use App\Models\Wallet;

// ...

public function testUserIsShownCorrectly() {
    $user = User::create(
        [
            'first_name' => $this->faker->firstName,
            'last_name'  => $this->faker->lastName,
            'email'      => $this->faker->email
        ]
    );
    Wallet::create(
        [
            'balance' => 0,
            'user_id' => $user->id
        ]
    );
        
    $this->json('get', "api/user/$user->id")
        ->assertStatus(Response::HTTP_OK)
        ->assertExactJson(
            [
                'data' => [
                    'id'         => $user->id,
                    'first_name' => $user->first_name,
                    'last_name'  => $user->last_name,
                    'email'      => $user->email,
                    'created_at' => (string)$user->created_at,
                    'wallet'     => [
                        'id'      => $user->wallet->id,
                        'balance' => $user->wallet->balance
                    ]
                ]
            ]
        );
}

Don't forget to include the import statements at the top.

Notice that this time, we are not only checking that the correct data structure is returned, we are checking to make sure that the data returned is accurate as well.

Let’s add another test for our destroy function:

public function testUserIsDestroyed() {
    
    $userData =
        [
            'first_name' => $this->faker->firstName,
            'last_name'  => $this->faker->lastName,
            'email'      => $this->faker->email
        ];
    $user = User::create(
        $userData
    );
    
    $this->json('delete', "api/user/$user->id")
         ->assertNoContent();
    $this->assertDatabaseMissing('users', $userData);
}

This function checks that the API returns no data in the response. It also checks that the response code is the HTTP_NO_CONTENT response code (204). We also have an assertion to ensure that the user has been deleted from the database.

Let’s write a test for our update function:

public function testUpdateUserReturnsCorrectData() {
    $user = User::create(
        [
            'first_name' => $this->faker->firstName,
            'last_name'  => $this->faker->lastName,
            'email'      => $this->faker->email
        ]
    );
    Wallet::create(
        [
            'balance' => 0,
            'user_id' => $user->id
        ]
    );
        
    $payload = [
        'first_name' => $this->faker->firstName,
        'last_name'  => $this->faker->lastName,
        'email'      => $this->faker->email
    ];
        
    $this->json('put', "api/user/$user->id", $payload)
        ->assertStatus(Response::HTTP_OK)
        ->assertExactJson(
            [
                'data' => [
                    'id'         => $user->id,
                    'first_name' => $payload['first_name'],
                    'last_name'  => $payload['last_name'],
                    'email'      => $payload['email'],
                    'created_at' => (string)$user->created_at,
                    'wallet'     => [
                        'id'      => $user->wallet->id,
                        'balance' => $user->wallet->balance
                    ]
                ]
            ]
        );
}

In this test, we create a User and a Wallet, then we try to update the first name, last name, and email address of the user. In a similar fashion to our previous test cases, we check that the HTTP_OK response (200) is returned and that the content of the response is correct.

Finally, let’s write a test case to ensure that all the investments for a user are loaded correctly:

// ...
// Include import statements at the top
use App\Models\Investment;
use App\Models\Strategy;
// ...

public function testGetInvestmentsForUser() {
    
    $user = User::create([
            'first_name' => $this->faker->firstName,
            'last_name'  => $this->faker->lastName,
            'email'      => $this->faker->email
        ]
    );
    
    $strategy = Strategy::create(
        Strategy::factory()->create()->getAttributes()
    );
    $isSuccessful = $this->faker->boolean;
    $investmentAmount = $this->faker->randomNumber(6);
    $investmentReturns = $isSuccessful ?
        $investmentAmount * $strategy->yield :
        $investmentAmount * $strategy->relief;
    $investment = Investment::create(
        [
            'user_id'     => $user->id,
            'strategy_id' => $strategy->id,
            'successful'  => $isSuccessful,
            'amount'      => $investmentAmount,
            'returns'     => $investmentReturns
        ]
    );
    
    $this->json('get', "api/user/$user->id/investments")
         ->assertStatus(Response::HTTP_OK)
         ->assertJson(
             [
                'data' => [
                    [
                        'id'          => $investment->id,
                        'user_id'     => $investment->user->id,
                        'strategy_id' => $investment->strategy->id,
                        'successful'  => (bool)$investment->successful,
                        'amount'      => $investment->amount,
                        'returns'     => $investment->returns,
                        'created_at'  => (string)$investment->created_at,
                    ]
                ]
             ]
         );
}

Don't forget to include the import statements at the top.

In this function, we create a new User, Strategy, and Investment. We then check to see that it is returned (in the proper structure, with the correct data) when we make a request to the investments function of the UserController.

Run all your tests:

composer test

You should see something similar to the screenshot below when your tests finish running.

Laravel test result passes

Let’s take a look at our coverage report. Open the tests/coverage/index.html in your browser and navigate to your Controllers view.

Laravel test coverage details

Our UserController now has full coverage. However, does this mean that our code is bug-free?

What if we try to get a user that doesn’t exist? Or what if some required fields are missing when we try to create a new user? What is the wallet balance when we create a new user? Let’s write some test cases with our expectations for each scenario and see what the application returns.

Let’s add test cases for when we try to show, update or delete a missing user. In each case, we want to return an HTTP_NOT_FOUND error code (404 ) and ensure that our response has an error entry.

public function testShowForMissingUser() {
    
    $this->json('get', "api/user/0")
         ->assertStatus(Response::HTTP_NOT_FOUND)
         ->assertJsonStructure(['error']);
    
}
    
public function testUpdateForMissingUser() {
    
    $payload = [
        'first_name' => $this->faker->firstName,
        'last_name'  => $this->faker->lastName,
        'email'      => $this->faker->email
    ];
    
    $this->json('put', 'api/user/0', $payload)
         ->assertStatus(Response::HTTP_NOT_FOUND)
         ->assertJsonStructure(['error']);
}
    
public function testDestroyForMissingUser() {
    
    $this->json('delete', 'api/user/0')
         ->assertStatus(Response::HTTP_NOT_FOUND)
         ->assertJsonStructure(['error']);
}

Let’s also add a test case to check that when the request sent to the store function is missing some required data, an HTTP_BAD_REQUEST response code (400) is returned to the user.

public function testStoreWithMissingData() {
    
    $payload = [
        'first_name' => $this->faker->firstName,
        'last_name'  => $this->faker->lastName
        //email address is missing
    ];
    $this->json('post', 'api/user', $payload)
         ->assertStatus(Response::HTTP_BAD_REQUEST)
         ->assertJsonStructure(['error']);
}

Finally, let’s add a test case to ensure that the stored user is always created with an empty wallet.

public function testStoredUserHasEmptyWallet() {
    
    $payload = [
        'first_name' => $this->faker->firstName,
        'last_name'  => $this->faker->lastName,
        'email'      => $this->faker->email
    ];
    
    $apiResponse = $this
        ->json('post', 'api/user', $payload)
        ->getContent();
    
    $userData = json_decode($apiResponse, true)['data'];
    $walletDetails = $userData['wallet'];
    
    $this->assertEquals(0, $walletDetails['balance']);
}

Run your tests again and check out the results:

Laravel test result failure from terminal

Interestingly, our application could not handle any of the edge cases we conceived — even though we initially had 100% code coverage.

Update Application to Pass Tests

Before updating the code, let’s see what’s going on. Start your server and either using your browser or Postman, navigate to http://localhost:8000/api/user/0. You’ll see that the application returns a HTML response when the user could not be found. You need to override the default response for when a model with the specified ID could not be found.

To do this open the app/Exceptions/Handler.php file and update the render function to match the following:

use Illuminate\Http\Response;

// ...

public function render($request, Throwable $exception) {
    
    if ($exception instanceof ModelNotFoundException) {
        return response()->json(
            [
                'error' => 'Resource not found'
            ],
            Response::HTTP_NOT_FOUND
        );
    }
    
    return parent::render($request, $exception);
}

This means that every time this exception occurs, a JSON response will be returned with an entry describing the error.

Run your tests again. You’ll see that handling the ModelNotFoundException has resolved 3 of our previous 5 failures.

Next, let’s try to fix our code so that is passes the testStoredUserHasEmptyWallet tests. Open the app/Http/Controllers/UserController.php file and go to the store function. You’ll see that the user’s wallet is created with a balance of 100 instead of 0. Mistakes like this are common in the industry and this is why we have tests to make sure that we catch them before they go into production. Change it to 0 and run your test again.

Finally, let’s fix our code to pass the testStoreWithMissingData test. Open the app/Http/Controllers/UserController.php file and go to the store function. We’ll check if either the first_name, last_name, or email request inputs are null and return an error response if so. Update your store function to match the following:

use Illuminate\Http\Response;

// ...

public function store(Request $request) {
    
    $firstName = $request->input('first_name');
    $lastName = $request->input('last_name');
    $email = $request->input('email');
    
    if (is_null($firstName) || is_null($lastName) || is_null($email)) {
        return $this->errorResponse(
            'First Name, Last Name and Email are required',
            Response::HTTP_BAD_REQUEST
        );
    }
    
    $user = User::create(
        [
            'first_name' => $firstName,
            'last_name'  => $lastName,
            'email'      => $email
        ]
    );
    
    Wallet::create(
        [
            'balance' => 0,
            'user_id' => $user->id
        ]
    );
    
    return $this->successResponse(
        new UserResource($user),
        true
    );
}

Run your tests again. This time, our code passes all the tests and we also have a 100% code coverage for our UserController and User model.

Testing Other Endpoints

Having completed the tests for the UserController, let’s proceed to do the same for our other controllers. In the tests/Controllers directory, create a new file called InvestmentControllerTests.php. In your tests/Controllers/InvestmentControllerTests.php, add the following:

<?php
    
namespace Tests\Controllers;
    
use App\Models\Investment;
use App\Models\Strategy;
use App\Models\User;
use Illuminate\Http\Response;
use Tests\TestCase;
    
class InvestmentControllerTests extends TestCase {
    
    public function testIndexReturnsDataInValidFormat() {
        $this->json('get', 'api/investment')
            ->assertStatus(Response::HTTP_OK)
            ->assertJsonStructure(
            [
                'data' => [
                    '*' => [
                        'id',
                        'user_id',
                        'strategy_id',
                        'successful',
                        'amount',
                        'returns',
                        'created_at',
                    ]
                ]
            ]
            );
    }
    
    public function testInvestmentIsCreatedSuccessfully() {
        $user = User::create(User::factory()->make()->getAttributes());
        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
        $payload = [
            'user_id'     => $user->id,
            'strategy_id' => $strategy->id,
            'amount'      => $this->faker->randomNumber(4)
        ];
        $this->json('post', 'api/investment', $payload)
             ->assertStatus(Response::HTTP_CREATED)
             ->assertJsonStructure(
                 [
                     'data' => [
                         'id',
                         'user_id',
                         'strategy_id',
                         'successful',
                         'amount',
                         'returns',
                         'created_at',
                     ]
                 ]
             );
        $this->assertDatabaseHas('investments', $payload);
    }
    
    public function testStoreWithMissingData() {
        $payload = [
            'amount' => $this->faker->randomNumber(4)
        ];
        $this->json('post', 'api/investment', $payload)
            ->assertStatus(Response::HTTP_BAD_REQUEST)
            ->assertJsonStructure(['error']);
    }
    
    public function testStoreWithMissingUserAndStrategy() {
    
        $payload = [
            'user_id'     => 0,
            'strategy_id' => 0,
            'amount'      => $this->faker->randomNumber(4)
        ];
        $this->json('post', 'api/investment', $payload)
            ->assertStatus(Response::HTTP_NOT_FOUND)
            ->assertJsonStructure(['error']);
    }
    
    public function testInvestmentIsShownCorrectly() {

        $user = User::create(User::factory()->make()->getAttributes());
        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
        $isSuccessful = $this->faker->boolean;
        $investmentAmount = $this->faker->randomNumber(6);
        $investmentReturns = $isSuccessful ?
            $investmentAmount * $strategy->yield :
            $investmentAmount * $strategy->relief;
        $investment = Investment::create(
            [
                'user_id'     => $user->id,
                'strategy_id' => $strategy->id,
                'successful'  => $isSuccessful,
                'amount'      => $investmentAmount,
                'returns'     => $investmentReturns
            ]
        );
    
        $this->json('get', "api/investment/$investment->id")
             ->assertStatus(Response::HTTP_OK)
             ->assertExactJson(
                [
                    'data' => [
                        'id'          => $investment->id,
                        'user_id'     => $investment->user->id,
                        'strategy_id' => $investment->strategy->id,
                        'successful'  => $isSuccessful,
                        'amount'      => round($investment->amount, 2, PHP_ROUND_HALF_UP),
                        'returns'     => round($investment->returns, 2, PHP_ROUND_HALF_UP),
                        'created_at'  => (string)$investment->created_at,
                    ]
                ]
             );
    }
    
    public function testShowMissingInvestment() {

        $this->json('get', "api/investment/0")
            ->assertStatus(Response::HTTP_NOT_FOUND)
            ->assertJsonStructure(['error']);
    }
    
    public function testDestroyInvestment() {

        $user = User::create(User::factory()->make()->getAttributes());
        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
        $isSuccessful = $this->faker->boolean;
        $investmentAmount = $this->faker->randomNumber(6);
        $investmentReturns = $isSuccessful ?
            $investmentAmount * $strategy->yield :
            $investmentAmount * $strategy->relief;
        $investment = Investment::create(
            [
                'user_id'     => $user->id,
                'strategy_id' => $strategy->id,
                'successful'  => $isSuccessful,
                'amount'      => $investmentAmount,
                'returns'     => $investmentReturns
            ]
        );
        $this->json('delete', "api/investment/$investment->id")
            ->assertStatus(Response::HTTP_UNAUTHORIZED);
    }
    
    public function testUpdateInvestment() {

        $user = User::create(User::factory()->make()->getAttributes());
        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
    
        $isSuccessful = $this->faker->boolean;
        $investmentAmount = $this->faker->randomNumber(6);
        $investmentReturns = $isSuccessful ?
            $investmentAmount * $strategy->yield :
            $investmentAmount * $strategy->relief;
        $investment = Investment::create(
            [
                'user_id'     => $user->id,
                'strategy_id' => $strategy->id,
                'successful'  => $isSuccessful,
                'amount'      => $investmentAmount,
                'returns'     => $investmentReturns
            ]
        );
        $payload = [
            'id'         => $investment->id,
            'successful' => !$isSuccessful
        ];
    
        $this->json('put', "api/investment/$investment->id", $payload)
             ->assertStatus(Response::HTTP_UNAUTHORIZED);
    }
}

Our InvestmentController passes most of the test cases except the testStoreWithMissingUserAndStrategy and testStoreWithMissingData tests. To fix that, let’s update the store function in our app/Http/Controllers/InvestmentController.php file:

use App\Models\User;

// ...

public function store(Request $request) {
    
    $userId = $request->input('user_id');
    $strategyId = $request->input('strategy_id');
    $amount = $request->input('amount');
    
    if (is_null($userId) || is_null($strategyId) || is_null($amount)) {
        return $this->errorResponse(
            'User ID, Strategy ID and Amount are required',
            Response::HTTP_BAD_REQUEST
        );
    }
    
    $strategy = Strategy::findOrFail($strategyId);
    $user = User::findOrFail($userId);
    
    $investment = [
        'user_id'     => $user->id,
        'strategy_id' => $strategy->id,
        'amount'      => $amount
    ];
    
    $successful = (bool)random_int(0, 1);
    $investment['successful'] = $successful;
    
    $multiplier = $successful ?
        $strategy->yield :
        $strategy->relief;
    $investment['returns'] = $amount * $multiplier;
    $investment = Investment::create($investment);
    
    return $this->successResponse(
        new InvestmentResource($investment),
        true
    );
    
}

Just as we did in the UserController, we check to ensure that all the required fields are provided before we try to create a new Investment model. We also use the findOrFail function to ensure that the userId and strategyId provided actually correspond to a saved user and strategy in our database. If none exists, a ModelNotFound exception will be thrown. Since we already configured our handler to listen for that exception, our API will fail gracefully and return a HTTP_NOT_FOUND (404) error code along with an error message.

Finally, we have the tests for our StrategyController. Create another file in the tests/Controllers directory called StrategyControllerTests.php. In the tests/Controllers/StrategyControllerTests.php add the following:

<?php
    
namespace Tests\Controllers;
    
use App\Models\Strategy;
use Illuminate\Http\Response;
use Tests\TestCase;
    
class StrategyControllerTests extends TestCase {
    
    public function testIndexReturnsDataInValidFormat() {
    
        $this->json('get', 'api/strategy')
             ->assertStatus(Response::HTTP_OK)
             ->assertJsonStructure(
                [
                    'data' => [
                        '*' => [
                            'id',
                            'type',
                            'tenure',
                            'yield',
                            'relief',
                            'investments' => [
                                '*' => [
                                    'id',
                                    'user_id',
                                    'strategy_id',
                                    'successful',
                                    'amount',
                                    'returns',
                                    'created_at',
                                ]
                            ],
                            'created_at',
                        ]
                    ]
                ]
             );
    }
    
    public function testStrategyIsCreatedSuccessfully() {
    
        $payload = Strategy::factory()->make()->getAttributes();
        $this->json('post', 'api/strategy', $payload)
             ->assertStatus(Response::HTTP_CREATED)->assertJsonStructure(
                [
                    'data' => [
                        'id',
                        'type',
                        'tenure',
                        'yield',
                        'relief',
                        'investments',
                        'created_at',
                    ]
                ]
            );
        $this->assertDatabaseHas('strategies', $payload);
    }
    
    public function testStrategyIsShownCorrectly() {

        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
        $this->json('get', "api/strategy/$strategy->id")
            ->assertStatus(Response::HTTP_OK)
            ->assertExactJson(
            [
                'data' => [
                    'id'          => $strategy->id,
                    'type'        => $strategy->type,
                    'tenure'      => $strategy->tenure,
                    'yield'       => round($strategy->yield, 2, PHP_ROUND_HALF_UP),
                    'relief'      => round($strategy->relief, 2, PHP_ROUND_HALF_UP),
                    'investments' => $strategy->investments,
                    'created_at'  => (string)$strategy->created_at,
                ]
            ]
            );
    }
    
    public function testUpdateMissingStrategy() {
    
        $this->json('put', 'api/strategy/0', Strategy::factory()->make()->getAttributes())
            ->assertStatus(Response::HTTP_NOT_FOUND)
            ->assertJsonStructure(['error']);
    }
    
    public function testDestroyMissingStrategy() {
    
        $this->json('delete', 'api/strategy/0')
            ->assertStatus(Response::HTTP_NOT_FOUND)
            ->assertJsonStructure(['error']);
    }
    
    public function testStrategyIsUpdatedSuccessfully() {
    
        $strategy = Strategy::create(Strategy::factory()->make()->getAttributes());
        $payload = Strategy::factory()->make();
    
        $this->json('put', "api/strategy/$strategy->id", $payload->getAttributes())
             ->assertStatus(Response::HTTP_OK)
             ->assertExactJson(
                [
                    'data' => [
                        'id'          => $strategy->id,
                        'type'        => $payload['type'],
                        'tenure'      => $payload['tenure'],
                        'yield'       => round($payload['yield'], 2, PHP_ROUND_HALF_UP),
                        'relief'      => round($payload['relief'], 2, PHP_ROUND_HALF_UP),
                        'investments' => $strategy->investments,
                        'created_at'  => (string)$strategy->created_at,
                    ]
                ]
             );
    }
    
    public function testStrategyIsDestroyedSuccessfully() {
    
        $strategyAttributes = Strategy::factory()->make()->getAttributes();
        $strategy = Strategy::create($strategyAttributes);
        $this->json('delete', "api/strategy/$strategy->id")
            ->assertStatus(Response::HTTP_NO_CONTENT)
            ->assertNoContent();
        $this->assertDatabaseMissing('strategies', $strategyAttributes);
    }
}

With this in place, we have 100% coverage of our controllers, and we have also made provision for edge cases to ensure that our application is able to handle them properly.

Conclusion

In this tutorial, you learnt how to write test cases and measure code coverage using PHPUnit.

You can find the complete codebase (with test cases) here on GitHub. What further improvements can you make to the application? When you find something, instead of just updating the controllers, try and do the following:

  1. Write a test case for your improvement (simulated request and expected response).
  2. Run your tests ( composer test)
  3. If any of your tests fail, write just enough code to make the test pass
  4. See if you can make your code easier to understand
  5. Run your tests again

Testing is a necessity - something we cannot do away with if we are to build reliable applications. Fully embracing TDD requires a change in mindset. Instead of telling yourself “I’ll cross that bridge when I get there”, ask yourself “What bridges am I expected to cross, and how am I expected to cross them?”.

  • Twitter icon
  • LinkedIn icon
  • Faceboook icon