A Beginner's Guide to Test Driven Development With Symfony and Codeception - Part 3

February 25, 2022
Written by
Joseph Udonsak
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

A Beginner's Guide to Test Driven Development With Symfony and Codeception - Part 3

Welcome back! It’s been an amazing tour of planet TDD (Test Driven Development) so far. In this series, you’ve learned the benefits of TDD, and gotten your hands dirty building a P2P (Peer-to-peer) payment application.

Using Symfony and Codeception, you’ve worked through the Red-Green-Refactor cycle, gradually implementing new features via Sliming. You've also seen how TDD protects code from regressions.

In this, the third and final part in the series, you'll implement the last feature of the application using TDD, transaction history. In addition to that, you'll learn about the concept of test coverage and how it impacts application reliability.

Prerequisites

To follow this tutorial, you need the following things:

Getting started

If you already have the code from the first part in this series, you can skip this section. However, if you're just joining, run the commands below to clone the repository, change into the cloned directory, and checkout the relevant branch.

git clone https://github.com/ybjozee/tdd-with-symfony-and-codeception.git

cd tdd-with-symfony-and-codeception

git checkout part2

Next, install the project's dependencies using Composer, by running the command below.

composer install

After that, create a local database. The tutorial uses SQLite. However feel free to choose another database vendor, if you prefer. Regardless of the database vendor you choose, copy .env.local and name it .env, then set the DATABASE_URL accordingly.

For testing, Codeception has been configured to work with .env.test.local. Create the file by running the command below.

cp .env.test .env.test.local

.env.*.local files are ignored by Git as an accepted best practice for storing credentials outside of code to keep them safe. You could also store the application credentials in a secrets manager, if you're really keen.

Next, create the development and test databases, by running the commands below.

symfony console doctrine:database:create
symfony console doctrine:database:create --env=test

Now, update the test and development database schemas, by running the following command.

composer schemas:update

Then, ensure that your setup works properly by running the application's test suite. To do this, run the following command.

php vendor/bin/codecept run

Add a TransactionRecord Entity

The transaction history is essentially a collection of transfer records. Whenever a transfer is completed, the requisite records should be created and saved.

The application uses the Double Entry System, where every transfer will have two records; a credit record for the receiver and a debit record for the sender.

To model this approach, the application will use an entity named TransactionRecord. This entity will have the following fields:

  1. The sender
  2. The recipient
  3. The amount
  4. Whether the record is a credit or debit

Just as has been done throughout the series so far, you’ll write a test before implementing this functionality. A good place to start is TransferCest where tests exist for transfer-related features. In addition to the existing conditions, you need to add one to test that two transaction records are added to the database following a successful transfer.

To do this, in tests/api/TransferCest.php, update the makeTransferSuccessfully function to match the following.

public function makeTransferSuccessfully(ApiTester $I) 
{
    $authenticatedUser = $I->grabUser(true);
    $senderWalletBalance = $authenticatedUser->getWallet()->getBalance();
    $recipient = $I->grabUser();
    $recipientWalletBalance = $recipient->getWallet()->getBalance();
    $amountToTransfer = $this->faker->numberBetween(100, 900);

    $I->haveHttpHeader('Authorization', $authenticatedUser->getApiToken());
    $I->sendPost('/transfer', [
        'recipient' => $recipient->getEmail(),
        'amount'    => $amountToTransfer,
    ]);

    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::OK,
        '"message":"Transfer completed successfully"'
    );

    $I->seeInRepository(Wallet::class, [
        'user'    => $authenticatedUser,
        'balance' => $senderWalletBalance - $amountToTransfer,
    ]);

    $I->seeInRepository(Wallet::class, [
        'user'    => $recipient,
        'balance' => $recipientWalletBalance + $amountToTransfer,
    ]);

    $I->seeInRepository(TransactionRecord::class, [
        'sender' => $authenticatedUser,
        'recipient' => $recipient,
        'amount' => $amountToTransfer,
        'isCredit' => true
    ]);

    $I->seeInRepository(TransactionRecord::class, [
        'sender' => $authenticatedUser,
        'recipient' => $recipient,
        'amount' => $amountToTransfer,
        'isCredit' => false
    ]);
}

Using the seeInRepository function provided by Codeception, the test checks that two TransactionRecord entities, one for a credit and one for a debit, are saved in the database.

Run the tests in tests/api/TransferCest.php using the following command.

php vendor/bin/codecept run api TransferCest

Welcome back to the red phase, where the test fails with the error message shown below.

There was 1 error:

---------
1) TransferCest: Make transfer successfully
 Test  tests/api/TransferCest.php:makeTransferSuccessfully
                                                                                                          
  [Doctrine\Persistence\Mapping\MappingException] Class 'App\Tests\api\TransactionRecord' does not exist

Because you have not declared a TransactionRecord entity or declared a use statement, the application tries to find the TransactionRecord entity in the tests/api directory, thus triggering the exception.

To fix this, create the TransactionRecord entity by running the following command.

php bin/console make:entity TransactionRecord

Respond to the CLI prompts as shown below.

 New property name (press <return> to stop adding fields):
 > sender

 Field type (enter ? to see all types) [string]:
 > ManyToOne

 What class should this entity be related to?:
 > User

 Is the TransactionRecord.sender property allowed to be null (nullable)? (yes/no) [yes]:
 > no

 Do you want to add a new property to User so that you can access/update TransactionRecord objects from it - e.g. $user->getTransactionRecords()? (yes/no) [yes]:
 > no

 updated: src/Entity/TransactionRecord.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > recipient

 Field type (enter ? to see all types) [string]:
 > ManyToOne

 What class should this entity be related to?:
 > User

 Is the TransactionRecord.recipient property allowed to be null (nullable)? (yes/no) [yes]:
 > no

 Do you want to add a new property to User so that you can access/update TransactionRecord objects from it - e.g. $user->getTransactionRecords()? (yes/no) [yes]:
 > no

 updated: src/Entity/TransactionRecord.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > amount

 Field type (enter ? to see all types) [string]:
 > decimal

 Precision (total number of digits stored: 100.00 would be 5) [10]:
 > 38

 Scale (number of decimals to store: 100.00 would be 2) [0]:
 > 2

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/TransactionRecord.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > isCredit

 Field type (enter ? to see all types) [boolean]:
 > boolean

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/TransactionRecord.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > happenedAt

 Field type (enter ? to see all types) [datetime_immutable]:
 > datetime_immutable

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

At this point, press the Enter key to stop adding fields. Next, open src/Entity/TransactionRecord.php and update its content to match the following code.

<?php

namespace App\Entity;

use App\Repository\TransactionRecordRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=TransactionRecordRepository::class)
 */
class TransactionRecord 
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private ?int $id;

    /**
     * @ORM\ManyToOne(targetEntity=User::class, inversedBy="transactionRecords")
     * @ORM\JoinColumn(nullable=false)
     */
    private User $sender;

    /**
     * @ORM\ManyToOne(targetEntity=User::class, inversedBy="transactionRecords")
     * @ORM\JoinColumn(nullable=false)
     */
    private User $recipient;

    /**
     * @ORM\Column(type="decimal", precision=38, scale=2)
     */
    private float $amount;

    /**
     * @ORM\Column(type="boolean")
     */
    private bool $isCredit;

    /**
     * @ORM\Column(type="datetime_immutable")
     */
    private DateTimeImmutable $happenedAt;

    public function __construct(
        User              $sender,
        User              $recipient,
        float             $amount,
        bool              $isCredit,
        \DateTimeImmutable $happenedAt
    ) {
        $this->sender = $sender;
        $this->recipient = $recipient;
        $this->amount = $amount;
        $this->isCredit = $isCredit;
        $this->happenedAt = $happenedAt;
    }
}

You've made a few changes to the code generated by the Maker bundle. In addition to typing the fields of the TransactionRecord entity, you did the following.

  1. Added a constructor which takes the sender, receiver, amount, transaction date and time as well as whether or not the record is a credit. Using the parameters of the constructor function, we set the entity’s fields.
  2. Removed the setter functions. To protect data integrity, we only want the fields to be set when the entity is initialised. We also removed the getter functions since they are not required at this time.

Next, update the schemas for the development and test databases using the following command.

composer schemas:update

After that, add the following use statement to tests/api/TransferCest.php.

use App\Entity\TransactionRecord;

Then, run the tests for the TransferCest again.

php vendor/bin/codecept run api TransferCest

This time, you'll get a test failure instead of an error.

---------
1) TransferCest: Make transfer successfully
 Test  tests/api/TransferCest.php:makeTransferSuccessfully
 Step  See in repository "App\Entity\TransactionRecord",{"sender":"App\\Entity\\User","recipient":"App\\Entity\\User","amount":458,"isCredit":true}
 Fail  App\Entity\TransactionRecord with {"sender":{},"recipient":{},"amount":458,"isCredit":true}
Failed asserting that false is true.

Even though you’ve declared the entity, you’re still not creating any records on successful transfer, so the "Make transfer successfully" test still fails. To fix this, open the src/Controller/TransferController.php file and update it to match the following.

<?php

namespace App\Controller;

use App\Entity\TransactionRecord;
use App\Exception\InvalidParameterException;
use App\Exception\ParameterNotFoundException;
use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class TransferController extends BaseController 
{
    /**
     * @Route("/transfer", name="transfer", methods={"POST"})
     * @throws ParameterNotFoundException|InvalidParameterException
     */
    public function transfer(
        Request                $request,
        UserRepository         $userRepository,
        EntityManagerInterface $em
    )
    : JsonResponse 
    {
        /** @var $sender \App\Entity\User */
        $sender = $this->getUser();
        $senderWallet = $sender->getWallet();

        $requestBody = $request->request->all();

        $recipientEmailAddress = $this->getRequiredParameter(
            'recipient',
            $requestBody,
            'Recipient is required'
        );

        $transferAmount = $this->getRequiredNonNegativeNumber(
            'amount',
            $requestBody,
        );

        if ($transferAmount > $senderWallet->getBalance()) {
            return new JsonResponse(
                [
                    'error' => 'Insufficient funds available to complete this request',
                ], Response::HTTP_BAD_REQUEST
            );
        }

        $recipient = $userRepository->findOneBy(
            [
                'email' => $recipientEmailAddress,
            ]
        );

        if (is_null($recipient)) {
            return new JsonResponse(
                [
                    'error' => 'Could not find a user with the specified email address',
                ], Response::HTTP_BAD_REQUEST
            );
        }

        $recipientWallet = $recipient->getWallet();

        $transactionDatetime = new DateTimeImmutable();
        $senderWallet->debit($transferAmount);
        $recipientWallet->credit($transferAmount);

        $debitRecord = new TransactionRecord(
            $sender, 
            $recipient, 
            $transferAmount, 
            false, 
            $transactionDatetime
        );
        $creditRecord = new TransactionRecord(
            $sender, 
            $recipient, 
            $transferAmount, 
            true, 
            $transactionDatetime
        );

        $em->persist($debitRecord);
        $em->persist($creditRecord);
        $em->persist($senderWallet);
        $em->persist($recipientWallet);
        $em->flush();

        return $this->json(
            [
                'message' => 'Transfer completed successfully',
            ]
        );
    }
}

Before debiting and crediting wallets, it makes a note of the current date and time. After crediting the recipient wallet, it instantiates two new TransactionRecord entities, one for the debit and another for the credit.

Run the tests for the TransferCest again.

php vendor/bin/codecept run api TransferCest

This time the tests pass, so you can add something new to the application.

Refactor the TransferController

At the moment, the transfer function in the TransferController still does too much. In addition to retrieving the required parameters from the request, it also handles the process of updating wallet balances, generating records, and persisting changes.

For instance, if you decided to write a CLI command to make transfers using the current architecture, you would have to duplicate the transfer functionality in the command since it doesn't have access to the controller. This lack of reusability makes the code difficult to maintain. Consequently, this extra functionality should be refactored into a separate class to make the code reusable.

By doing so, the functionality is accessible from anywhere, such as in controllers and commands. Also, if you needed to modify the transfer functionality, you would only have to make the change in one place.

To do this, I'm going to step you through extracting the transfer functionality into a new service called TransferService. But, before creating that service, create a Cest to handle its tests. To do that, run the following command.

php vendor/bin/codecept generate:cest unit TransferServiceCest

Notice that you're writing tests in a different suite. In this situation, you want to test the functionality of TransferService in isolation, and where there are any dependencies, mock them, instead of using the actual implementation. This is known as unit testing, hence the suite name in our command.

Open the newly created file in tests/unit/TransferServiceCest.php and update the code to match the following.

<?php

namespace App\Tests\unit;

use App\Entity\User;
use App\Service\TransferService;
use App\Tests\UnitTester;
use Codeception\Stub;
use Codeception\Stub\Expected;
use Doctrine\ORM\EntityManagerInterface;
use Faker\Factory;
use Faker\Generator;

class TransferServiceCest 
{
    private Generator $faker;

    public function _before(UnitTester $I) 
    {
        $this->faker = Factory::create();
    }

    public function handleTransferSuccessfully(UnitTester $I) 
    {
        $sender = new User(
            $this->faker->firstName(), 
            $this->faker->lastName(), 
            $this->faker->email()
        );
        $recipient = new User(
            $this->faker->firstName(), 
            $this->faker->lastName(), 
            $this->faker->email()
        );
        $amount = $this->faker->numberBetween(100, 1000);

        $entityManager = Stub::makeEmpty(
            EntityManagerInterface::class,
            [],
            [
                'persist' => Expected::exactly(4),
                'flush'   => Expected::once(),
            ]
        );

        $transferService = new TransferService($entityManager);
        $transferService->transfer($sender, $recipient, $amount);

        $I->assertEquals(1000 - $amount, $sender->getWallet()->getBalance());
        $I->assertEquals(1000 + $amount, $recipient->getWallet()->getBalance());
    }
}

The _before function instantiates a Faker object, then declares a function named handleTransferSuccessfully. This function creates two objects, a sender and a recipient, and declares an amount to transfer.

Next, it mocks the EntityManagerInterface which is responsible for saving objects to, and fetching objects from, the database. Codeception allows you to not only mock objects but also interfaces (as is the case in this instance) using the makeEmpty function.

In addition to mocking the interface, it specifies that some functions on the interface will be called, such as the persist function to be called four times, and the flush function to be called once.

Next, it instantiates a TransferService which is passed the mocked EntityManager and calls the transfer() function on the service. Finally, it asserts that the wallet balances match what is expected upon successful completion of the transfer.

Run the test using the following command.

php vendor/bin/codecept run unit TransferServiceCest

The test fails as expected with the following result.

1) TransferServiceCest: Handle transfer successfully
 Test  tests/unit/TransferServiceCest.php:handleTransferSuccessfully
                                                            
  [Error] Class "App\Tests\unit\TransferService" not found

For the test to pass, you need to create the TransferService along with the transfer function. In the src folder, create a new folder named Service. Then, in the src/Service folder, create a new file named TransferService.php and add the following code to it.

<?php

namespace App\Service;

use App\Entity\TransactionRecord;
use App\Entity\User;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;

class TransferService 
{
    private EntityManagerInterface $em;

    public function __construct(EntityManagerInterface $em) 
    {
        $this->em = $em;
    }

    public function transfer(User $sender, User $recipient, float $amount): void 
    {
        $senderWallet = $sender->getWallet();
        $recipientWallet = $recipient->getWallet();

        $transactionDatetime = new DateTimeImmutable();

        $senderWallet->debit($amount);
        $recipientWallet->credit($amount);

        $debitRecord = new TransactionRecord(
            $sender, 
            $recipient, 
            $amount, 
            false, 
            $transactionDatetime
        );
        $creditRecord = new TransactionRecord(
            $sender, 
            $recipient, 
            $amount, 
            true, 
            $transactionDatetime
        );

        $this->em->persist($debitRecord);
        $this->em->persist($creditRecord);

        $this->em->persist($senderWallet);
        $this->em->persist($recipientWallet);

        $this->em->flush();
    }
}

In the service, it declares the EntityManagerInterface as a field and initialises it in the class constructor. Next, it declares the transfer() function which takes the sender, recipient, and amount as arguments. Using these, it debits the sender, credits the recipient, and generates the requisite transaction records. Finally it persists the changes and flushes them to the database.

Run the tests again using the following command.

php vendor/bin/codecept run unit TransferServiceCest

This time, the test passes.

The next thing the service has to catch is users trying to transfer more funds than they have in their wallet. So add a test for that. Add the following function to tests/unit/TransferServiceCest.php.

public function makeTransferOfAmountExceedingWalletBalanceAndFail(UnitTester $I)
{
    $I->expectThrowable(InsufficientFundsException::class, function() {
        $sender = new User($this->faker->firstName(), $this->faker->lastName(), $this->faker->email());
        $recipient = new User($this->faker->firstName(), $this->faker->lastName(), $this->faker->email());
        $amount = $this->faker->numberBetween(10000, 100000);

        $entityManager = Stub::makeEmpty(EntityManagerInterface::class);

        $transferService = new TransferService($entityManager);
        $transferService->transfer($sender, $recipient, $amount);
    });
}

This test uses a new function, expectThrowable, because it expects an Exception (which is a child of the Throwable class) to be thrown when trying to send an amount greater than the wallet balance.

The first parameter passed to the expectThrowable function is the exception you are looking for, in this case InsufficientFundsException. The second parameter is a callback which details the steps to be taken for the exception to occur.

Run the tests again using the following command.

php vendor/bin/codecept run unit TransferServiceCest

This time, the test fails.

There was 1 failure:

---------
1) TransferServiceCest: Make transfer of amount exceeding wallet balance and fail
 Test  tests/unit/TransferServiceCest.php:makeTransferOfAmountExceedingWalletBalanceAndFail
 Step  Expect throwable "App\Tests\unit\InsufficientFundsException","Closure"
 Fail  Expected throwable of class 'App\Tests\unit\InsufficientFundsException' to be thrown, but nothing was caught

Next, add a check and throw an exception if the transfer amount is more than the sender’s wallet balance. Update the transfer function in src/Service/TransferService.php to match the following.

public function transfer(User $sender, User $recipient, float $amount): void 
{
        $senderWallet = $sender->getWallet();

        if ($senderWallet->getBalance() < $amount) {
            throw new InsufficientFundsException;
        }

        $recipientWallet = $recipient->getWallet();

        $transactionDatetime = new DateTimeImmutable();

        $senderWallet->debit($amount);
        $recipientWallet->credit($amount);

        $debitRecord = new TransactionRecord($sender, $recipient, $amount, false, $transactionDatetime);
        $creditRecord = new TransactionRecord($sender, $recipient, $amount, true, $transactionDatetime);

        $this->em->persist($debitRecord);
        $this->em->persist($creditRecord);

        $this->em->persist($senderWallet);
        $this->em->persist($recipientWallet);

        $this->em->flush();
}

Run the tests again using the following command.

php vendor/bin/codecept run unit TransferServiceCest

The test fails this time, albeit different from the last one.

1) TransferServiceCest: Make transfer of amount exceeding wallet balance and fail
 Test  tests/unit/TransferServiceCest.php:makeTransferOfAmountExceedingWalletBalanceAndFail
 Step  Expect throwable "App\Tests\unit\InsufficientFundsException","Closure"
 Fail  Exception of class 'App\Tests\unit\InsufficientFundsException' expected to be thrown, but class 'ParseError' was caught 

Because InsufficientFundsException isn't declared yet, the expected exception isn't thrown. To fix this, create it in the src/Exception folder, in a new file called InsufficientFundsException.php. Then, add the following code to the file.

<?php

namespace App\Exception;

use Exception;

class InsufficientFundsException extends Exception 
{
    public function __construct() 
    {
        parent::__construct("Insufficient funds available to complete this request");
    }
}

Next, in the src/Service/TransferService.php and tests/unit/TransferServiceCest.php files, add the following use statement.

use App\Exception\InsufficientFundsException;

Run the tests again using the following command.

php vendor/bin/codecept run unit TransferServiceCest

This time, all the tests pass.

Now that you have a service to handle transfers, refactor the TransferController to use the service instead of updating the wallet balances. To do that, update the src/Controller/TransferController.php file to match the following.

<?php

namespace App\Controller;

use App\Exception\InsufficientFundsException;
use App\Exception\InvalidParameterException;
use App\Exception\ParameterNotFoundException;
use App\Repository\UserRepository;
use App\Service\TransferService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class TransferController extends BaseController 
{
    /**
     * @Route("/transfer", name="transfer", methods={"POST"})
     * @throws ParameterNotFoundException|InvalidParameterException|InsufficientFundsException
     */
    public function transfer(
        Request         $request,
        UserRepository  $userRepository,
        TransferService $transferService
    ): JsonResponse 
    {
        $sender = $this->getUser();

        $requestBody = $request->request->all();

        $recipientEmailAddress = $this->getRequiredParameter(
            'recipient',
            $requestBody,
            'Recipient is required'
        );

        $transferAmount = $this->getRequiredNonNegativeNumber(
            'amount',
            $requestBody,
        );

        $recipient = $userRepository->findOneBy(
            [
                'email' => $recipientEmailAddress,
            ]
        );

        if (is_null($recipient)) {
            return new JsonResponse(
                [
                    'error' => 'Could not find a user with the specified email address',
                ], Response::HTTP_BAD_REQUEST
            );
        }

        $transferService->transfer($sender, $recipient, $transferAmount);

        return $this->json(
            [
                'message' => 'Transfer completed successfully',
            ]
        );
    }
}

Using dependency injection, TransferService is passed into the transfer function and calls the transfer function to handle the transfer process.

To make sure everything is in order, run the following command.

php vendor/bin/codecept run

This runs the entire test suite to make sure everything is in order. You should see one failure this time.

There was 1 failure:

---------
1) TransferCest: Make transfer of amount exceeding wallet balance and fail
 Test  tests/api/TransferCest.php:makeTransferOfAmountExceedingWalletBalanceAndFail
 Step  See response code is 400
 Fail  Expected HTTP Status Code: 400 (Bad Request). Actual Status Code: 500 (Internal Server Error)
Failed asserting that 500 matches expected 400.

Because it doesn’t handle the InsufficientFundsException, the API returns an HTTP 500 code. To fix that, add an event subscriber to handle the exception and return an HTTP 400 code instead. To do this, create a new subscriber using the following command.

symfony console make:subscriber InsufficientFundsExceptionSubscriber

Respond to the CLI command as follows.

What event do you want to subscribe to?:
 > kernel.exception

Open the newly created src/EventSubscriber/InsufficientFundsExceptionSubscriber.php and update its content to match the following.

<?php

namespace App\EventSubscriber;

use App\Exception\InsufficientFundsException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;

class InsufficientFundsExceptionSubscriber implements EventSubscriberInterface 
{
    public static function getSubscribedEvents(): array 
    {
        return [
            'kernel.exception' => 'onKernelException',
        ];
    }

    public function onKernelException(ExceptionEvent $event) 
    {
        $exception = $event->getThrowable();
        if ($exception instanceof InsufficientFundsException) {
            $event->setResponse(
                new JsonResponse(
                    [
                        'error' => $exception->getMessage(),
                    ], 
                    Response::HTTP_BAD_REQUEST
                )
            );
        }
    }
}

Run the tests again to make sure everything is in order using the following command.

php vendor/bin/codecept run

This time our tests pass.

Implementing the transaction history feature

It’s time to add the functionality to retrieve a user’s transaction history. Before writing the controller to handle requests, write some tests for what is expected.

Create a new Cest using the following command.

php vendor/bin/codecept generate:cest api TransactionHistoryCest

Open the newly created tests/api/TransactionHistoryCest.php file and update it to match the following.

​​<?php

namespace App\Tests\api;

use App\Entity\User;
use App\Tests\ApiTester;
use Codeception\Util\HttpCode;
use Faker\Factory;

class TransactionHistoryCest 
{
    private User $authenticatedUser;

    public function _before(ApiTester $I) 
    {
        $this->authenticatedUser = $I->grabUser(true);
    }

    public function getTransactionHistorySuccessfully(ApiTester $I) 
    {
        $this->fakeTransfers($I);
        $I->haveHttpHeader('Authorization', $this->authenticatedUser->getApiToken());
        $I->sendGet('transactions');
        $I->seeResponseCodeIs(HttpCode::OK);
        $I->seeResponseIsJson();

        $I->seeResponseMatchesJsonType(
            [
                'credits' => 'array',
                'debits'  => 'array',
            ]
        );

        $debits = $I->grabDataFromResponseByJsonPath('debits')[0];
        $credits = $I->grabDataFromResponseByJsonPath('credits')[0];

        $I->assertEquals(1, count($debits));
        $I->assertEquals(1, count($credits));
    }

    private function fakeTransfers(ApiTester $I)
    {
        $transferService = $I->grabService('App\Service\TransferService');
        $faker = Factory::create();
        $randomUser = $I->grabUser();
        $transferService->transfer($randomUser, $this->authenticatedUser, $faker->numberBetween(100, 500));
        $transferService->transfer($this->authenticatedUser, $randomUser, $faker->numberBetween(200, 300));
    }
}

In this Cest, a credit and debit transaction are simulated for an authenticated user, a get request is sent to the transactions route, and some assertions are carried out on the response.

In the fakeTransfers function, notice how the grabService function is used to retrieve the TransferService and simulate transfers. Because the classes in this project are automatically registered as services and autowired, passing the namespace of the TransferService is all that is needed to access it in the test.

In the getTransactionHistorySuccessfully function, an authenticated GET request is sent to the transactions route. The expectation is an HTTP 200 response code. In addition, the response is expected to contain two arrays: one for credit transfers (transfers to the authenticated user) and another for debit transfers (transfers from the authenticated user). The content of the response is retrieved using the grabDataFromResponseByJsonPath function.

Run the test using the following command.

php vendor/bin/codecept run api TransactionHistoryCest

The test fails with the following message.

There was 1 failure:

---------
1) TransactionHistoryCest: Get transaction history successfully
 Test  tests/api/TransactionHistoryCest.php:getTransactionHistorySuccessfully
 Step  See response code is 200
 Fail  Expected HTTP Status Code: 200 (OK). Actual Status Code: 404 (Not Found)
Failed asserting that 404 matches expected 200.

To fix this, add a new controller to retrieve the transaction history for a user, by running the following command.

symfony console make:controller TransactionHistoryController

Open the newly created src/Controller/TransactionHistoryController.php file and update its content to match the following.

<?php

namespace App\Controller;

use App\Repository\TransactionRecordRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class TransactionHistoryController extends AbstractController 
{
    /**
     * @Route("/transactions", name="get_transaction_history", methods={"GET"})
     */
    public function getTransactionHistory(TransactionRecordRepository $transactionRecordRepository): Response 
    {
        $user = $this->getUser();

        return $this->json(
            [
                'debits'  => $transactionRecordRepository->getDebitTransactions($user),
                'credits' => $transactionRecordRepository->getCreditTransactions($user),
            ]
        );
    }
}

In the getTransactionHistory function, the TransactionRecordRepository is injected via a function argument and used to retrieve the debit and credit transactions for the authenticated user.

getTransactionHistory calls several functions that haven’t been declared in the TransactionRecordRepository, yet. To add them, open src/Repository/TransactionRecordRepository.php and update the content to match the following.

<?php

namespace App\Repository;

use App\Entity\TransactionRecord;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @method TransactionRecord|null find($id, $lockMode = null, $lockVersion = null)
 * @method TransactionRecord|null findOneBy(array $criteria, array $orderBy = null)
 * @method TransactionRecord[]    findAll()
 * @method TransactionRecord[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class TransactionRecordRepository extends ServiceEntityRepository 
{
    public function __construct(ManagerRegistry $registry) 
    {
        parent::__construct($registry, TransactionRecord::class);
    }

    public function getCreditTransactions(User $user): array 
    {
        return $this->findBy(
            [
                'recipient' => $user,
                'isCredit'  => true,
            ]
        );
    }

    public function getDebitTransactions(User $user): array 
    {
        return $this->findBy(
            [
                'sender' => $user,
                'isCredit' => false,
            ]
        );
    }
}

Run the test using the following command.

php vendor/bin/codecept run api TransactionHistoryCest

This time the test passes.

Secure the transfer history endpoint

The user should be authenticated before the transfer history can be retrieved. The next test should be one to ensure that if no authentication is present in the request headers, then a response with an HTTP 401 Unauthorized status code is returned along with an error message.

Add the following function to tests/api/TransactionHistoryCest.php.

public function getTransactionHistoryWithoutAuthorizationAndFail(ApiTester $I) 
{
    $I->sendGet('/transactions');
    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::UNAUTHORIZED,
        '"error":"Authentication required to complete this request"'
    );
}

Run the test using the following command.

php vendor/bin/codecept run api TransactionHistoryCest

The test fails with the following message.

There was 1 error:

---------
1) TransactionHistoryCest: Get transaction history without authorization and fail
 Test  tests/api/TransactionHistoryCest.php:getTransactionHistoryWithoutAuthorizationAndFail
                                                                                                                                                                                                                                                                                                    
  [TypeError] App\Repository\TransactionRecordRepository::getDebitTransactions(): 
Argument #1 ($user) must be of type App\Entity\User, null given, called in tdd-with-symfony-and-codeception/src/Controller/TransactionHistoryController.php on line 22

Because there’s no authenticated user, the getDebitTransactions receives null instead of a User entity, hence the error. To fix this, you need to modify the access control configuration. Open config/packages/security.yaml and update the access_control configuration to match the following.


access_control:
    - { path: ^/register, roles: PUBLIC_ACCESS }
    - { path: ^/login, roles: PUBLIC_ACCESS }
    - { path: ^/*, roles: IS_AUTHENTICATED_FULLY }

By using the * wildcard, you are making it known that any route (apart from register and login) requires full authentication. Run the test again after making this change.

php vendor/bin/codecept run api TransactionHistoryCest

This time, all the tests pass and the transaction history feature is complete. To make sure everything is in order, run through the entire suite of tests using the following command.

php vendor/bin/codecept run

Everything works and you get the following message.

OK (22 tests, 83 assertions)

Code coverage

You’re done with the MVP. By now you’ve covered all the major areas of testing, those being mocking, sliming, the red-green cycle, unit testing, and so on. But there’s one more thing to talk about — code coverage.

Testing is a form of guarantee that your code does what it says, so it’s very important that your tests cover as much functionality as possible. To this end, you need some kind of feedback to ensure that all the code you write is covered by at least one test and you get that via code coverage reports.

Codeception comes with the ability to generate code coverage reports which show the ratio between the total lines of code in your application and the total lines of code executed while running your test suite. We’ll take advantage of this functionality to generate a coverage report for our application.

Install a code coverage driver

Before running this, you need to install a code coverage driver. Codeception can work with Xdebugphpdbg, or pcov. If you have one of them installed, you can skip this section.

Otherwise, for this article, PCOV will be used. Because it does not offer debug functionality, PCOV is faster at generating reports than Xdebug without compromising accuracy.

Install PCOV via PECL using the following command

pecl install pcov

Once the installation is complete, you need to enable coverage. To do this, open the codeception.yml file at the root of the project and add the following.

coverage:
    enabled: true

With this in place, you can run your tests and generate a code coverage report on completion using the following command.

php vendor/bin/codecept run --coverage --coverage-html

This is similar to the command you’ve been using to run tests, except that you have added two arguments: --coverage and --coverage-html. The --coverage argument lets Codeception know you want to generate a coverage report. The --coverage-html option specifies that you want an HTML version of the report to be generated as well. Other formats are XML and text.

The tests run successfully and you will see the following message.

OK (22 tests, 83 assertions)

Code Coverage Report:      
  2022-01-02 21:27:06      
                           
 Summary:                  
  Classes: 50.00% (9/18)   
  Methods: 64.29% (36/56)  
  Lines:   82.81% (159/192)

The summary of the report shows the coverage report with the coverage distributed according to

  • classes: These let you know how many classes are completely covered by tests i.e., every line of code is executed by a test.
  • methods: These let you know how many methods are executed by the tests and in the same vein as classes.
  • lines: These let you know how many lines of code are executed by tests.

Overall, you have achieved code coverage of 82.81%! While there’s still room for improvement of our coverage, this is well within (if not above) the recommended range for code coverage. Improving code coverage is discussed later. For now, you can continue analysing the results.

A more detailed breakdown is then provided, showing the coverage statistics for each class in the src directory.

App\Controller\AuthenticationController
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% ( 25/ 25)
App\Controller\BaseController
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% ( 11/ 11)
App\Controller\TransactionHistoryController
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  4/  4)
App\Controller\TransferController
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% ( 16/ 16)
App\Entity\TransactionRecord
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  6/  6)
App\Entity\User
  Methods:  52.63% (10/19)   Lines:  58.06% ( 18/ 31)
App\Entity\Wallet
  Methods:  57.14% ( 4/ 7)   Lines:  66.67% (  8/ 12)
App\EventSubscriber\AuthenticationExceptionSubscriber
  Methods:  50.00% ( 1/ 2)   Lines:  87.50% (  7/  8)
App\EventSubscriber\InsufficientFundsExceptionSubscriber
  Methods:  50.00% ( 1/ 2)   Lines:  87.50% (  7/  8)
App\EventSubscriber\InvalidParameterExceptionSubscriber
  Methods:  50.00% ( 1/ 2)   Lines:  87.50% (  7/  8)
App\EventSubscriber\ParameterNotFoundExceptionSubscriber
  Methods:  50.00% ( 1/ 2)   Lines:  87.50% (  7/  8)
App\Exception\InsufficientFundsException
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  2/  2)
App\Repository\TransactionRecordRepository
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% (  6/  6)
App\Repository\UserRepository
  Methods:  50.00% ( 1/ 2)   Lines:  25.00% (  2/  8)
App\Repository\WalletRepository
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  2/  2)
App\Security\APITokenAuthenticator
  Methods:  60.00% ( 3/ 5)   Lines:  66.67% ( 10/ 15)
App\Security\AuthenticationEntryPoint
  Methods:   0.00% ( 0/ 1)   Lines:  80.00% (  4/  5)
App\Service\TransferService
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% ( 17/ 17)

To view the HTML version of the code coverage report, open tests/_output/coverage/index.html in your browser. By clicking on the links, you can drill down and even view the report for each class.

PCOV code coverage HTML output

When viewing the coverage for a class, the code coverage report distinguishes between executed code, not executed code, and dead code.

Dead code is the part of your application that can never be executed, for example, a condition that can never be reached. Not executed code refers to code that isn’t executed by any test.

Improving code coverage

For code that isn’t executed by tests, we have two options:

  1. Write a test to trigger the code
  2. Delete the code

However, this choice should not be done without consideration. Writing tests simply for the sake of achieving 100% coverage could hide key areas for refactoring in the application. At the same time, deleting code without consideration could introduce regressions into the application.

Looking at the report for the Wallet entity, there are 3 unused functions - getId, setBalance, and getUser. These functions were generated while creating the entity via Maker. We don’t particularly need them at this time and we can safely delete them. This gives a 100% coverage for the Wallet entity and takes the total coverage in the src/Entity namespace from 65.31% to 71.11%. Overall, total coverage rises to 84.57%.

In the same vein, looking at the report for the User entity shows that the getFirstName, setFirstName, getLastName, setLastName, setRoles, getId, setEmail, getUserIdentifier, and getUsername functions are unexecuted.

While you can improve our coverage by deleting these functions, it’s important to bear in mind that in a bid to take advantage of Symfony’s security, the User entity implements the UserInterface and PasswordAuthenticatedUserInterface. As a result, the entity has to implement the getUserIdentifier and getUsername functions.

Deleting the getFirstName, setFirstName, getLastName, setLastName, setRoles, getId, and setEmail functions takes the code coverage for the User entity to 90% and the overall code coverage rises to 89.83%.

Because Symfony components are extensively tested, you don’t need to write test cases for the interface implementations.

Conclusion

That brings us to the end of this series! Building on the Red-Green-Refactor cycle, in this final part in the series, you built the last feature of the application. You learned about unit testing, and how to mock dependencies in your unit tests. And finally, you generated a code coverage report for the application using the PCOV code coverage driver.

You’ve also seen how Codeception makes testing a much more pleasant experience by providing helper functions so that you can focus on the conditions you want to test as opposed to writing boilerplate code for your tests.

Testing is an art, and with consistency you’ll find yourself thinking of solutions in terms of algorithms as well as testability. This new way of thinking also helps in better structuring code, by creating units that are easier and faster to test; as well as reusable.

If you'd like to dive much deeper into TDD, then get a copy of Test-Driven Development By Example, by Kent Beck.

You can review the final codebase on GitHub. Until next time, bye for now.