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

December 02, 2021
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 2

Hey there! Welcome back!! In the first part of this series, we took a first look at Test Driven Development (TDD) and explained the need for it. We then got hands-on by setting up a new Symfony project which used Codeception as its testing library.

Then, we used TDD to  start building a P2P (Peer-to-peer) payment application. While building the authentication functionality, we uncovered some TDD gems such as the Red-Green-Refactor cycle and Sliming.

In this part, we'll dive back into TDD and continue building our application, implementing funds transfer functionality. While we do that, we will also see how Codeception makes provisions for us to customise our test suite to add our own helper methods and assertions.

The functionality we will build in this series has three parts:

  1. Authentication: This feature includes login and registration.
  2. Transfers: This feature allows one registered user to send money to another registered user.
  3. Transaction history: This feature allows a registered user to retrieve their recorded transactions.

Prerequisites

To follow this tutorial, you need the following:

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, you can clone the repository to get started.

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

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

composer update

Next, create your local databases. For this project, we're using SQLite for our database. However you're free to choose another database vendor. To do so, make a local copy of the .env file called .env.local and set the DATABASE_URL accordingly.

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

cp .env.test .env.test.local

.env.*.local files are ignored by Git as anaccepted best practice of storing your credentials outside of code to keep them safe.

Next, create the development and test databases.

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

After that, update the schema for the development and test databases using the following command.

symfony console doctrine:schema:update --force
symfony console doctrine:schema:update --force --env=test

Then, ensure that your setup works properly by running a test. Run the following command to ensure everything is in order.

php vendor/bin/codecept run

If everything went well, then you'll see the message below once the tests are completed.

OK (10 tests, 29 assertions)

Add a Wallet entity

P2P payment applications use a wallet system to manage user funds. Think of it as an actual wallet where all your cash goes except this time it's digital, and instead of cash, you have a stored value account.

At the moment we don't have a wallet for our application so that's the next thing we'll add. Your wallet is usually assigned to you when you sign up so a good place to write our test would be in RegistrationCest, which we created previously to test our registration functionality.

Also, wallets would normally start off empty i.e., have a balance of $0.00. However, we'll spice things up a bit here; new users of our application will start off with a balance of $1,000. With this in mind let's write our first test for this feature.

Open tests/api/RegistrationCest.php and update the registerSuccessfully function to match the following code.

public function registerSuccessfully(ApiTester $I)
{
    $firstName = $this->faker->firstName();
    $lastName = $this->faker->lastName();
    $emailAddress = $this->faker->email();
    $I->sendPost(
        '/register',
        [
            'firstName'    => $firstName,
            'lastName'     => $lastName,
            'emailAddress' => $emailAddress,
            'password'     => $this->faker->password(),
        ]
    );
    $I->seeResponseCodeIs(HttpCode::CREATED);
    $I->seeResponseIsJson();
    $I->seeResponseContains('"message":"Account created successfully"');
    $I->seeInRepository(
        User::class,
        [
            'firstName' => $firstName,
            'lastName' => $lastName,
            'email' => $emailAddress,
            'wallet' => [
                'balance' => 1000
            ]
        ]
    );
    $I->seeInRepository(Wallet::class, [
        'balance' => 1000,
        'user' => [
            'email' => $emailAddress,
        ],
    ]);
}

Using the seeInRepository function, we are not only able to check for entities based on primitive values, such as strings, we can also check for entities based on the values of entities they are associated with.

The code above checks for a user with a first and last name and an email address matching the details we provided, as well as a wallet associated with the user with a balance field set to 1000. This is another powerful feature which Codeception makes available by default.

NOTE: The code checks twice for a wallet tied to the registered user with a balance of 1000. One check would be sufficient, but the aim is to show some of the possible ways by which it can be done.

Run the tests using the following command.

php vendor/bin/codecept run api RegistrationCest

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

---------
1) RegistrationCest: Register successfully
 Test  tests/api/RegistrationCest.php:registerSuccessfully                                                                                                                                                               
  [Doctrine\ORM\Query\QueryException] [Semantical Error] line 0, col 98 near 'wallet = ?3': Error: Class App\Entity\User has no field or association named wallet

To fix it, we need to create a Wallet entity using the command below.

symfony console make:entity Wallet

Respond to the CLI prompts as shown below.

Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.

 New property name (press <return> to stop adding fields):
 > balance
 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
 Add another property? Enter the property name (or press <return> to stop adding fields):
 > user
 Field type (enter ? to see all types) [string]:
 > OneToOne
 What class should this entity be related to?:
 > User
 Is the Wallet.user 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 Wallet objects from it - e.g. $user->getWallet()? (yes/no) [no]:
 > yes
 A new property will also be added to the User class so that you can access the related Wallet object from it.
 New field name inside User [wallet]:
 > wallet

Press the Enter key to stop adding fields. Next, open src/Entity/Wallet.php and update its content to match the following code.

<?php

namespace App\Entity;

use App\Repository\WalletRepository;
use Doctrine\ORM\Mapping as ORM;

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

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

    /**
     * @ORM\OneToOne(targetEntity=User::class, inversedBy="wallet", cascade={"persist", "remove"})
     * @ORM\JoinColumn(nullable=false)
     */
    private User $user;

    public function __construct(User $user) 
    {
        $this->balance = 1000;
        $this->user = $user;
    }

    public function getId(): ?int 
    {
        return $this->id;
    }

    public function getBalance(): float 
    {
        return $this->balance;
    }

    public function setBalance(float $balance): void 
    {
        $this->balance = $balance;
    }

    public function getUser(): User 
    {
        return $this->user;
    }
}

We've made a few changes from the code generated for us by the Maker bundle, and in addition to typing the fields of the Wallet entity, we did the following.

  1. We added a constructor which takes a User as its parameter and creates a new Wallet object with a balance of 1000.
  2. We set the return type of setBalance to void as we will not be using fluent setters for this entity.
  3. We deleted the setUser function as we don't want to switch Wallets between users for this application.

The next thing we need to do is update the User entity located in src/Entity/User.php by updating the constructor function to match the following.

public function __construct(
    string $firstName,
    string $lastName,
    string $email
) {
    $this->email = $email;
    $this->firstName = $firstName;
    $this->lastName = $lastName;
    $this->wallet = new Wallet($this);
}

The code sets the user's email address, first name, and last name, and creates a new Wallet, passing to its constructor the newly created User.

Just as we did in the Wallet entity, delete the setWallet function in this class. This is because we don't want to change a user's wallet once it has been created.

Next, update your development and test database schemas using the following command.


symfony console doctrine:schema:update --force
symfony console doctrine:schema:update --force --env=test

Because we'll be running this command frequently, let's add a Composer script for it to save time and effort. Add the following to the scripts key in composer.json.

"schemas:update": [
    "php bin/console doctrine:schema:update --force",
    "php bin/console doctrine:schema:update --force --env=test"
]

Now, we can update both development and test schemas by running the following command.

composer schemas:update

In tests/api/RegistrationCest.php, add an import statement for the Wallet entity as follows.

use App\Entity\Wallet;

Then, run the tests for the API suite again using the following command, where they should all pass.


php vendor/bin/codecept run api

Write a helper assertion

SeeJSONResponseWithCodeAndContent

Before we add more tests, there's an opportunity to write our first helper assertion. In the tests we've written for the API suite, there are 3 closely related checks we carry out:

  1. We check that the response code matches what we expect.
  2. We then check that the response received is valid JSON.
  3. Finally, we check that the response contains some text.

Using Codeception, we can condense these into one check. This prevents us from repeating the checks in all our tests and possibly forgetting one; which is helpful as, in fact, in the LoginCest, we forgot to check that the response is valid JSON.

We can do this using the ApiTester actor that Codeception created for us. Open tests/_support/ApiTester.php and add the following function.

function seeJSONResponseWithCodeAndContent(int $code, string $text)
{
    $this->seeResponseCodeIs($code);
    $this->seeResponseIsJson();
    $this->seeResponseContains($text);
}

Rebuild your suites using the following command.

php vendor/bin/codecept build

The seeJSONResponseWithCodeAndContent custom assertion is now available in the API test suite. Open tests/api/RegistrationCest.php and update the registerWithoutLastNameAndFail method to match the following.

public function registerWithoutLastNameAndFail(ApiTester $I) 
{
    $I->sendPost(
        '/register',
        [
            'firstName'    => $this->faker->firstName(),
            'emailAddress' => $this->faker->email(),
            'password'     => $this->faker->password(),
        ]
    );
    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::BAD_REQUEST,
        '"error":"Last name is required"'
    );
}

Now, run the tests in the RegistrationCest to be sure we haven't broken anything.

php vendor/bin/codecept run api RegistrationCest

If all our tests pass, you can go ahead and modify the other tests to use this assertion. Don't forget to re-run the test suite once you're finished.

Implement transfer functionality

Let's add the transfer functionality. The first thing we need is a route to send transfer requests to. Since we're taking the TDD approach, we'll write a test first before adding any functionality.

Create a new Cest using the following command.

php vendor/bin/codecept generate:cest api TransferCest

Then, open the newly created tests/api/TransferCest.php file and update it to match the following code.

<?php

namespace App\Tests\api;

use App\Tests\ApiTester;
use Codeception\Util\HttpCode;

class TransferCest 
{
    public function _before(ApiTester $I) {}

    public function makeTransferSuccessfully(ApiTester $I) 
    {
        $I->sendPost('/transfer');
        $I->seeJSONResponseWithCodeAndContent(
            HttpCode::OK,
            '"message":"Transfer completed successfully"'
        );
    }
}

This test makes a POST request to the transfer endpoint, /transfer, and checks to see that a JSON response with an HTTP OK status code is returned. It also ensures that a message with the specified content is provided in the response.

Run the test using the following command.

php vendor/bin/codecept run api TransferCest

This time, the test fails because not only do we not have such a route, the error response is returned in HTML format. Let's fix this by creating a controller to handle requests to that route by running the following command.

symfony console make:controller TransferController

Open the newly created controller, src/Controller/TransferController.php, and update it to match the following code.

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class TransferController extends AbstractController 
{
    /**
     * @Route("/transfer", name="transfer", methods={"POST"})
     */
    public function transfer(): JsonResponse 
    {
        return $this->json(
            [
                'message' => 'Transfer completed successfully',
            ]
        );
    }
}

Then, run the test again.

php vendor/bin/codecept run api TransferCest

Everything works this time, but we still have some ways to go before our feature is usable. However, just as we learnt in the previous edition, we only add enough code to make the tests pass.

Secure the transfer endpoint

Ideally, the user has to be authenticated in order to make transfers. Not only do we need this to know who is making the request, we also need to be sure that whoever is making the request has gone through the login process and is not impersonating the owner of the account.

To protect against that, we need to restrict the transfer endpoint and make sure it is only available to authenticated users. So let's write another test to check for this. Open tests/api/TransferCest.php and add the following function.

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

In this test, we expect that the API should return an HTTP 401 Unauthorized status code for the response along with an error message.

Run your tests again, by using the command below.

php vendor/bin/codecept run api TransferCest

This time our test fails because our API returns a successful response. In order to secure our endpoint, we need to do two things:

  1. Add a custom authenticator
  2. Add an access control rule for the 'transfer' endpoint.

Add custom authenticator

Create a new authenticator using the following command.

symfony console make:auth

Then, respond to the prompts as shown below.

What style of authentication do you want? [Empty authenticator]:
  [0] Empty authenticator
  [1] Login form authenticator
 > 0

 The class name of the authenticator to create (e.g. AppCustomAuthenticator):
 > APITokenAuthenticator

The newly created authenticator is located in the new src/Security folder, named APITokenAuthenticator.php. An additional entry has also been created in our security configuration, located in config/packages/security.yaml, to direct all requests in the main firewall to our authenticator.

Open src/Security/APITokenAuthenticator.php and update it to match the following.

<?php

namespace App\Security;

use App\Repository\UserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

class APITokenAuthenticator extends AbstractAuthenticator 
{
    private UserRepository $userRepository;

    public function __construct(UserRepository $userRepository) 
    {
        $this->userRepository = $userRepository;
    }

    public function supports(Request $request): ?bool 
    {
        return true;
    }

    public function authenticate(Request $request): PassportInterface 
    {
        $apiToken = $request->headers->get('Authorization');
        if (null === $apiToken) {
            throw new CustomUserMessageAuthenticationException(
                'Authentication required to complete this request'
            );
        }

        return new SelfValidatingPassport(
            new UserBadge(
                $apiToken,
                fn($userIdentifier) => $this->userRepository->findOneBy(['apiToken' => $userIdentifier])
            )
        );
    }

    public function onAuthenticationSuccess(
        Request $request, 
        TokenInterface $token, 
        string $firewallName
    ) : ?Response 
    {
        return null;
    }

    public function onAuthenticationFailure(
        Request $request, 
        AuthenticationException $exception
    ) : ?Response 
    {
        return new JsonResponse(
            ['error' => strtr($exception->getMessageKey(), $exception->getMessageData()),], 
            Response::HTTP_UNAUTHORIZED
        );
    }
}

We start by injecting a UserRepository into the authenticator via the constructor. We then implement the abstract functions declared in the AuthenticatorInterface which are as follows:

  1. supports: This function is used to determine whether the authenticator supports the request. We want this authenticator to handle authentication for all requests to the main firewall, so it returns true. If we had multiple authenticators, we could then check for the presence of a header key (in this case Authorization) to decide whether or not this authenticator supports the request.
  2. authenticate: The authenticate() method is the most important method of the authenticator. Its job is to extract the API token from the Request object and transform it into a security Passport.
  3. onAuthenticationSuccess: In our case, we want the request to continue as normal, i.e., the controller matching the login route is called, hence we return null.
  4. onAuthenticationFailure: When authentication fails, we return a JSON response with the 401 Unauthorized status code and an error message.

If this looks unfamiliar to you, have a read about some changes made to Symfony Security.

 

Add access control to the transfers endpoint

To ensure that only authenticated requests are handled by the /transfer endpoint, let's update our security configuration. Open config/packages/security.yaml and update the access_control configuration to match the following.

access_control:
    - { path: ^/transfer, roles: IS_AUTHENTICATED_FULLY }

Run the tests again. This time they will still fail, but the errors look different. We're getting a 401 Unauthorized response as expected, but it's in HTML format (which we don't want) and it doesn't have the error message we want.

Because we aren't passing an Authorization key in the header, the request isn't being handled properly by our authenticator. However, since we've specified that full authentication is required to access this route, Symfony threw an AccessDeniedException.

We can customise the response to match our requirements. To do that, in the src/Security folder, create a new file called AuthenticationEntryPoint.php, and add the following code to it.

<?php

namespace App\Security;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;

class AuthenticationEntryPoint implements AuthenticationEntryPointInterface 
{
    public function start(Request $request, AuthenticationException $authException = null) 
    {
        if (!is_null($authException)) {
            return new JsonResponse(
                ['error' => 'Authentication required to complete this request'],
                Response::HTTP_UNAUTHORIZED
            );
        }
    }
}

This class will be used as an entry point for our firewall hence it must implement the AuthenticationEntryPointInterface, which requires our class to have a start method. This method receives the request. If an AuthenticationException was thrown it will be passed to the start method. In our case, we check if the exception is not equal to null and return our preferred JsonResponse object.

Next, we need to hook this up to our firewall. Open config/packages/security.yaml and update the main firewall configuration setting to match the following.


main:
     lazy: true
     provider: users_in_memory
     entry_point: App\Security\AuthenticationEntryPoint
     custom_authenticator:
         - App\Security\APITokenAuthenticator

You can read more about customising access denied responses here.

Run the tests in TransferCest again. This time, the "Make transfer without authorization header and fail" test passes, but the "Make transfer successfully" test fails. This is because we have not provided an Authorization header in our request.

To make both tests pass, we need to have an authenticated user in the database and pass the user's API token accordingly. To do that, update the makeTransferSuccessfully function in tests/api/TransferCest.php to match the following.

public function makeTransferSuccessfully(ApiTester $I)
{
    $email = $this->faker->email();
    $I->haveInRepository(
        User::class,
        [
            'firstName' => $this->faker->firstName(),
            'lastName'  => $this->faker->lastName(),
            'email'     => $email,
            'password'  => $this->faker->password(),
            'apiToken'  => bin2hex(random_bytes(32)),
        ]
    );

    $user = $I->grabEntityFromRepository(
        User::class,
        ['email' => $email]
    );

    $I->haveHttpHeader('Authorization', $user->getApiToken());
    $I->sendPost('/transfer');
    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::OK,
        '"message":"Transfer completed successfully"'
    );
}

Since we're faking data, let's also add a private field called faker and instantiate it in the _before function as shown below.

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

Don't forget to import the required classes.

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

Run the tests for the transfer endpoint again, using the following command.

php vendor/bin/codecept run api TransferCest

 This time both tests pass.

Write a helper action

In the tests covering transfers, we have to repeatedly simulate users in the database, either for us to have a valid API token, or for us to have a valid recipient when we make a transfer.

Instead of repeating this process and bloating our code, let's write a custom helper action, named grabUser, to do this for us. The helper action will take an optional argument for whether or not the user should be authenticated and returns a User object.

Open tests/_support/Helper/Api.php and update it to match the following code.

<?php

namespace App\Tests\Helper;

// here you can define custom actions
// all public methods declared in helper class will be available in $I

use App\Entity\User;
use Codeception\Module;
use Faker\Factory;

class Api extends Module 
{
    public function grabUser(bool $isAuthenticated = false): User 
    {
        $faker = Factory::create();
        $email = $faker->email();
        $IDoctrine = $this->getModule('Doctrine2');
        $IDoctrine->haveInRepository(
            User::class,
            [
                'firstName' => $faker->firstName(),
                'lastName'  => $faker->lastName(),
                'email'     => $email,
                'password'  => $faker->password(),
                'apiToken'  => $isAuthenticated ?
                    bin2hex(random_bytes(32)) :
                    null,
            ]
        );

        return $IDoctrine->grabEntityFromRepository(
            User::class,
            ['email' => $email]
        );
    }
}

In this function, we use the getModule function to retrieve the Doctrine2 Codeception module, the same module which gives us access to the haveInRepository and grabEntityFromRepository functions in our Cests. Using the returned module, we simulate a user in the database, setting an API token if the user is authenticated and then returning the user.

Rebuild your test suites using the following command.

php vendor/bin/codecept build

Next, update the makeTransferSuccessfully function in tests/api/TransferCest.php to match the following.

public function makeTransferSuccessfully(ApiTester $I) 
{
    $authenticatedUser = $I->grabUser(true);
    $I->haveHttpHeader('Authorization', $authenticatedUser->getApiToken());
    $I->sendPost('/transfer');
    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::OK,
        '"message":"Transfer completed successfully"'
    );
}

Run the tests for the transfer endpoint again, using the following command.

php vendor/bin/codecept run api TransferCest

All the tests pass, so let’s add something new.

Refactor to ensure wallet balances are updated

At the moment, there’s still no exchange of funds between users. Let’s update the makeTransferSuccessfully() function to check that the wallet balances of the users are also updated once the action is completed.

Also, to make a transfer we need to pass the email address of the recipient and the amount to be transferred. To do that, update the makeTransferSuccessfully function in tests/api/TransferCest.php 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,
    ]);
}

Remember to import the Wallet entity.

use App\Entity\Wallet;

This time, we grab another user who will receive the funds, and pass that user's email address and the amount to be transferred. In addition to checking the response code and content, the code now has two checks to ensure that the sender's wallet is debited while the recipient's is credited accordingly.

Run the tests using the following command.

php vendor/bin/codecept run api TransferCest

This time, the test fails because we aren't updating wallet balances. To make the tests pass, open src/Controller/TransferController.php and update the code to match the following.

<?php

namespace App\Controller;

use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class TransferController extends AbstractController 
{
    /**
     * @Route("/transfer", name="transfer", methods={"POST"})
     */
    public function transfer(
        Request $request,
        UserRepository $userRepository,
        EntityManagerInterface $em
    ) : JsonResponse 
    {
        $sender = $this->getUser();
        $senderWallet = $sender->getWallet();
        $senderWalletBalance = $senderWallet->getBalance();

        $requestBody = $request->request->all();
        $recipientEmailAddress = $requestBody['recipient'];
        $transferAmount = $requestBody['amount'];

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

        $senderWallet->setBalance($senderWalletBalance - $transferAmount);
        $recipientWallet->setBalance($recipientWalletBalance + $transferAmount);

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

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

Here, the Request is injected into the transfer function along with the UserRepository and Doctrine 2 EntityManager. From the Request object, we retrieve the recipient's email and the amount to be transferred. From the UserRepository, we retrieve the recipient user, and from the recipient we retrieve their wallet.

Since the sender is already authenticated, we can get the user via the getUser function found in the AbstractController, which our controller extends. Once we have all the wallets and the amount to be transferred, we update both wallet balances accordingly. Then, before returning a success message, we persist the updates to both wallets, and flush the changes to the database.

WIth the changes made, run the TransferCest tests again.

php vendor/bin/codecept run api TransferCest

This time everything passes. Before we move on to the next set of tests, however, let's see if we can do some refactoring.

Updating wallet balances from the controller looks a bit weird. Why don't we move that functionality into the Wallet entity? That way, instead of retrieving the old wallet balance and setting the new one, we can call a function to credit the recipient's wallet and another to debit the sender's wallet.

Open src/Entity/Wallet.php and add the following functions.

public function credit(float $amount): void 
{
    $this->balance += $amount;
}

public function debit(float $amount): void 
{
    $this->balance -= $amount;
}

With this, we can update the transfer function in src/Controller/TransferController.php to match the following.

/**
 * @Route("/transfer", name="transfer", methods={"POST"})
 */
public function transfer(
    Request                $request,
    UserRepository         $userRepository,
    EntityManagerInterface $em
) : JsonResponse
{
    $sender = $this->getUser();
    $senderWallet = $sender->getWallet();

    $requestBody = $request->request->all();
    $recipientEmailAddress = $requestBody['recipient'];
    $transferAmount = $requestBody['amount'];

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

    $recipientWallet = $recipient->getWallet();

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

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

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

Run the tests in TransferCest again to be sure we didn't break anything.

Test to ensure required parameters are provided

Next, just like we did for authentication, we want to be sure that the required parameters are provided, so let's add a test to ensure that an error response is received, should we fail to provide a recipient or an amount.

Open tests/api/TransferCest.php and add the following test.

public function makeTransferWithoutRecipientAndFail(ApiTester $I) 
{
    $authenticatedUser = $I->grabUser(true);
    $I->haveHttpHeader('Authorization', $authenticatedUser->getApiToken());
    $I->sendPost('/transfer', [
        'amount' => $this->faker->numberBetween(0, 900),
    ]);

    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::BAD_REQUEST,
        '"error":"Recipient is required"'
    );
}

Run the tests in TransferCest again and this time the test fails. So let's make our code pass the tests.

We already declared a getRequiredParameter function in AuthenticationController. For now, we're just going to copy that function and paste it in TransferController.

Again, resist the urge to refactor when you have a failing test. Once all of our tests pass, then we can refactor to remove any duplication.

Update src/Controller/TransferController.php to match the following code.

<?php

namespace App\Controller;

use App\Exception\ParameterNotFoundException;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class TransferController extends AbstractController 
{
    /**
     * @Route("/transfer", name="transfer", methods={"POST"})
     */
    public function transfer(
        Request                $request,
        UserRepository         $userRepository,
        EntityManagerInterface $em
    ) : JsonResponse 
    {
        $sender = $this->getUser();
        $senderWallet = $sender->getWallet();

        $requestBody = $request->request->all();
        $recipientEmailAddress = $this->getRequiredParameter(
            'recipient',
            $requestBody,
            'Recipient is required'
        );
        
        $transferAmount = $requestBody['amount'];

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

        $recipientWallet = $recipient->getWallet();

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

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

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

    private function getRequiredParameter(
        string $parameterName,
        array  $requestBody,
        string $errorMessage
    ) {
        if (!isset($requestBody[$parameterName])) {
            throw new ParameterNotFoundException($errorMessage);
        }

        return $requestBody[$parameterName];
    }
}

Run the tests again for the /transfer endpoint using the following command.

php vendor/bin/codecept run api TransferCest

Our code passes all the tests in TransferCest, however, let's repeat the process for the transfer amount before we refactor our code.

Add the following test to tests/api/TransferCest.php.

public function makeTransferWithoutAmountAndFail(ApiTester $I) 
{
    $authenticatedUser = $I->grabUser(true);
    $I->haveHttpHeader('Authorization', $authenticatedUser->getApiToken());
    $recipient = $I->grabUser();
    $I->sendPost('/transfer', [
        'recipient' => $recipient->getEmail(),
    ]);

    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::BAD_REQUEST,
        '"error":"Amount is required"'
    );
}

Run the tests. This time we get an error because we are trying to access an undefined key in the request body array. Fix the code by updating the transfer() function in src/Controller/TransferController.php to match the following code.

/**
 * @Route("/transfer", name="transfer", methods={"POST"})
 * @throws ParameterNotFoundException
 */
public function transfer(
    Request                $request,
    UserRepository         $userRepository,
    EntityManagerInterface $em
): Response 
{
    $sender = $this->getUser();
    $senderWallet = $sender->getWallet();

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

    $transferAmount = $this->getRequiredParameter(
        'amount',
        $requestBody,
        'Amount is required'
    );

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

    $recipientWallet = $recipient->getWallet();

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

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

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

Then, add the following use statements to the class.

use Symfony\Component\HttpFoundation\Response;

Run the tests in TransferCest again. This time everything passes. Now we can refactor to remove duplication.

Refactor to remove duplication in getRequiredParameter

There are many ways to share logic between controllers, such as traits, services, or even a parent controller. We'll be using a parent controller to share logic.

Essentially, we will create a controller that extends AbstractController, and in it declare all the common functionality that any controller we create may need. Then we'll extend it in all the controllers which we have created (and those which we will create in the future).

In the src/Controller folder, create a new file called BaseController.php and add the following code to it.

<?php

namespace App\Controller;

use App\Exception\ParameterNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

abstract class BaseController extends AbstractController 
{
    protected function getRequiredParameter(
        string $parameterName,
        array  $requestBody,
        string $errorMessage
    ) {
        if (!isset($requestBody[$parameterName])) {
            throw new ParameterNotFoundException($errorMessage);
        }

        return $requestBody[$parameterName];
    }
}

We declare BaseController as abstract because we don't want it to be instantiated.

Next, update the code in src/Controller/TransferController.php to match the following.

<?php

namespace App\Controller;

use App\Exception\ParameterNotFoundException;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
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
     */
    public function transfer(
        Request                $request,
        UserRepository         $userRepository,
        EntityManagerInterface $em
    ) : Response 
    {
        $sender = $this->getUser();
        $senderWallet = $sender->getWallet();

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

        $transferAmount = $this->getRequiredParameter(
            'amount',
            $requestBody,
            'Amount is required'
        );

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

        $recipientWallet = $recipient->getWallet();

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

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

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

In the same vein, we can update the code in src/Controller/AuthenticationController.php to match the following code.

<?php

namespace App\Controller;

use App\Entity\User;
use App\Exception\AuthenticationException;
use App\Exception\ParameterNotFoundException;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;

class AuthenticationController extends BaseController 
{
    /**
     * @Route("/register", name="register", methods={"POST"})
     * @throws ParameterNotFoundException
     */
    public function register(
        Request                     $request,
        EntityManagerInterface      $em,
        UserPasswordHasherInterface $passwordHasher
    ) : JsonResponse 
    {
        $requestBody = $request->request->all();

        $firstName = $this->getRequiredParameter(
            'firstName', 
            $requestBody, 
            'First name is required'
        );

        $lastName = $this->getRequiredParameter(
            'lastName', 
            $requestBody, 
            'Last name is required'
        );

        $emailAddress = $this->getRequiredParameter(
            'emailAddress', 
            $requestBody, 
            'Email address is required'
        );

        $password = $this->getRequiredParameter(
            'password', 
            $requestBody, 
            'Password is required'
        );

        $user = new User($firstName, $lastName, $emailAddress);
        $hashedPassword = $passwordHasher->hashPassword($user, $password);
        $user->setPassword($hashedPassword);

        $em->persist($user);
        $em->flush();

        return $this->json(
            [
                'message' => 'Account created successfully',
            ],
            Response::HTTP_CREATED
        );
    }

    /**
     * @Route("/login", name="login", methods={"POST"})
     * @throws ParameterNotFoundException|AuthenticationException
     */
    public function login(
        Request $request,
        UserRepository $userRepository,
        EntityManagerInterface $em,
        UserPasswordHasherInterface $passwordHasher
    ) : JsonResponse 
    {
        $requestBody = $request->request->all();

        $emailAddress = $this->getRequiredParameter(
            'emailAddress', 
            $requestBody, 
            'Email address is required'
        );
        $password = $this->getRequiredParameter(
            'password', 
            $requestBody, 
            'Password is required'
        );

        $user = $userRepository->findOneBy(['email' => $emailAddress]);

        if (is_null($user) || !$passwordHasher->isPasswordValid($user, $password)) {
            throw new AuthenticationException();
        }

        $apiToken = bin2hex(random_bytes(32));
        $user->setApiToken($apiToken);
        $em->persist($user);
        $em->flush();

        return $this->json(
            [
                'token' => $apiToken,
            ]
        );
    }
}

Since we've made changes to the AuthenticationController, we also need tests to ensure we haven't broken something by doing so. So this time, let's run all the tests in the API suite by using the following command.

php vendor/bin/codecept run api

Fix the regressions

This time it looks like we've broken something. Looking through the error messages, it looks like we're not being allowed to register or log in because the API expects us to be authenticated to perform those actions.

This is because the custom authenticator expects an authorization header in every request passing through the main firewall. At the login or registration stage, a user isn't authorized, yet we didn't add exceptions for the login and registration endpoints.

This is what we refer to as a regression, and highlights another key importance of testing. By building a test suite, as we have been doing, if we unknowingly break something, we don't have to wait until a client complains for us to find out.

Now, let's fix the bug and get our code to work again. As users don't need to be authenticated before they can log in or register, we need to find a way of letting our security system know this. Instead of our custom authenticator validating every request, it should only validate requests with an authorization header.

Open src/Security/APITokenAuthenticator.php and update the supports function as shown below.

public function supports(Request $request): ?bool 
{
    return $request->headers->has('Authorization');
}

Run all the tests in the api suite again.

php vendor/bin/codecept run api

This time they pass, and we can continue adding features to our API.

Test to ensure the wallet amount is always numeric

At the moment, we're only ensuring that an amount to be transferred is present, but what if letters were provided instead of numbers. We want the system to reject such requests, so let's add another test.

Open tests/api/TransferCest.php and add the following test.

public function makeTransferWithNonNumericAmountAndFail(ApiTester $I) 
{
    $authenticatedUser = $I->grabUser(true);
    $recipient = $I->grabUser();

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

    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::BAD_REQUEST,
        '"error":"Amount must be a number"'
    );
}

Then, run the tests in TransferCest using the following command.

php vendor/bin/codecept run api TransferCest

This time our code throws an exception, because we specified that the parameters for the credit() and debit() wallet functions must be of type float. To fix this, update the transfer() function in src/Controller/TransferController.php to match the following.

/**
 * @Route("/transfer", name="transfer", methods={"POST"})
 * @throws ParameterNotFoundException
 */
public function transfer(
    Request $request,
    UserRepository $userRepository,
    EntityManagerInterface $em
): Response 
{
    $sender = $this->getUser();
    $senderWallet = $sender->getWallet();

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

    $transferAmount = $this->getRequiredParameter(
        'amount',
        $requestBody,
        'Amount is required'
    );

    if (!is_numeric($transferAmount)) {
        return new JsonResponse(
            ['error' => 'Amount must be a number',], 
            Response::HTTP_BAD_REQUEST
        );
    }

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

    $recipientWallet = $recipient->getWallet();

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

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

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

Then, make sure you have the following use statement at the top of the class.

use Symfony\Component\HttpFoundation\JsonResponse;

With the changes made, run the tests again. This time everything passes.

Test to ensure that the transfer amount cannot be negative

Now, there might be a security problem with our system. Specifically, it may be possible to steal from another user. By passing a negative transfer amount, a user would be able to subtract a negative amount from their wallet balance, which essentially increases its value, adding a negative amount to the receiver's wallet, which reduces it. That doesn't sound like something the customer will be too happy about. So let's make sure that doesn't happen.

Open tests/api/TransferCest.php and add the following test.

​​public function makeTransferWithNegativeAmountAndFail(ApiTester $I)
{
    $authenticatedUser = $I->grabUser(true);
    $recipient = $I->grabUser();

    $I->haveHttpHeader('Authorization', $authenticatedUser->getApiToken());
    $I->sendPost('/transfer', [
        'recipient' => $recipient->getEmail(),
        'amount'    => $this->faker->numberBetween() * -1,
    ]);

    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::BAD_REQUEST,
        '"error":"Amount cannot be negative"'
    );
}

Run the TransferCest tests again. This time they fail. To fix them, update the transfer function in src/Controller/TransferController.php to match the following.

/**
 * @Route("/transfer", name="transfer", methods={"POST"})
 * @throws ParameterNotFoundException
 */
public function transfer(
    Request $request,
    UserRepository $userRepository,
    EntityManagerInterface $em
) : Response
{
    $sender = $this->getUser();
    $senderWallet = $sender->getWallet();

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

    $transferAmount = $this->getRequiredParameter(
        'amount',
        $requestBody,
        'Amount is required'
    );

    if (!is_numeric($transferAmount)) {
        return new JsonResponse(
            ['error' => 'Amount must be a number',],
            Response::HTTP_BAD_REQUEST
        );
    }

    if ($transferAmount < 0) {
        return new JsonResponse(
            ['error' => 'Amount cannot be negative',],
            Response::HTTP_BAD_REQUEST
        );
    }

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

    $recipientWallet = $recipient->getWallet();

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

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

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

Run the tests again, and this time everything passes.

Refactor to remove error response duplication

Before we write further tests, let's see if we can refactor the transfer function. We are duplicating the response for when invalid data is provided. Just as we did in getRequiredParameter, we:

  • Throw an exception for when an invalid parameter is provided
  • Subscribe to that exception
  • Return the error response

To do that, in the Exception folder, create a new file called InvalidParameterException.php and add the following code to it.

<?php

namespace App\Exception;

use Exception;

class InvalidParameterException extends Exception {}

Next, create an event subscriber using the following command.

symfony console make:subscriber InvalidParameterExceptionSubscriber

When prompted by the CLI, respond as follows.

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

A new event subscriber will be created  at src/EventSubscriber/InvalidParameterExceptionSubscriber.php. Open the file and update it to match the following code.

<?php

namespace App\EventSubscriber;

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

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

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

Next, update the transfer function in src/Controller/TransferController.php to match the following.

/**
 * @Route("/transfer", name="transfer", methods={"POST"})
 * @throws ParameterNotFoundException
 */
public function transfer(
    Request $request,
    UserRepository $userRepository,
    EntityManagerInterface $em
): Response 
{
    $sender = $this->getUser();
    $senderWallet = $sender->getWallet();

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

    $transferAmount = $this->getRequiredParameter(
        'amount',
        $requestBody,
        'Amount is required'
    );

    if (!is_numeric($transferAmount)) {
        throw new InvalidParameterException('Amount must be a number');
    }

    if ($transferAmount < 0) {
        throw new InvalidParameterException('Amount cannot be negative');
    }

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

    $recipientWallet = $recipient->getWallet();

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

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

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

Remember to import InvalidParameterException, by including the use statement below.

use App\Exception\InvalidParameterException;

Run the API test suite again to be sure everything works as expected.

There's one more thing we can do, just to make it easier to follow what is going on in the transfer function. In the BaseController, add another function to help us retrieve a non-negative number.

To do that, open src/Controller/BaseController.php and add the following function.

/**
 * @throws ParameterNotFoundException|InvalidParameterException
 */
protected function getRequiredNonNegativeNumber(string $parameterName, array $requestBody) 
{
    $formattedParameterName = ucfirst($parameterName);
    $requiredParameter = $this->getRequiredParameter(
        $parameterName,
        $requestBody,
        "$formattedParameterName is required"
    );

    if (!is_numeric($requiredParameter)) {
        throw new InvalidParameterException(
            "$formattedParameterName must be a number"
        );
    }

    if ($requiredParameter < 0) {
        throw new InvalidParameterException(
            "$formattedParameterName cannot be negative"
        );
    }

    return $requiredParameter;
}

Remember to import InvalidParameterException by adding the following use statement to the top of the class.

use App\Exception\InvalidParameterException;

Next, update the transfer function in src/Controller/TransferController.php to match the following.

/**
 * @Route("/transfer", name="transfer", methods={"POST"})
 * @throws ParameterNotFoundException|InvalidParameterException
 */
public function transfer(
    Request $request,
    UserRepository $userRepository,
    EntityManagerInterface $em
): Response
{
    $sender = $this->getUser();
    $senderWallet = $sender->getWallet();

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

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

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

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

    $recipientWallet = $recipient->getWallet();

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

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

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

Run the TransferCest tests again. Everything works properly, so we're ready to add something new.

Test to ensure user cannot send more funds than their available wallet balance

In a similar vein to the test that we just wrote, we need to ensure that the user cannot transfer an amount exceeding their wallet's balance. To do that, add the following function to tests/api/TransferCest.php.

public function makeTransferOfAmountExceedingWalletBalanceAndFail(ApiTester $I) 
{
    $authenticatedUser = $I->grabUser(true);
    $recipient = $I->grabUser();

    $I->haveHttpHeader('Authorization', $authenticatedUser->getApiToken());
    $I->sendPost('/transfer', [
        'recipient' => $recipient->getEmail(),
        'amount'    => $this->faker->numberBetween(2000, 100000),
    ]);

    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::BAD_REQUEST,
        '"error":"Insufficient funds available to complete this request"'
    );
}

Run the TransferCest tests again, this time, understandably, they fail. To fix them, we head back to the transfer function and update it to match the following.

/**
 * @Route("/transfer", name="transfer", methods={"POST"})
 * @throws ParameterNotFoundException|InvalidParameterException
 */
public function transfer(
    Request $request,
    UserRepository $userRepository,
    EntityManagerInterface $em
): Response
{
    $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,]
    );

    $recipientWallet = $recipient->getWallet();
    $senderWallet->debit($transferAmount);
    $recipientWallet->credit($transferAmount);

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

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

Run the TransferCest tests again, where each of them passes.

Test to ensure user cannot send funds to a non-existent user

Let's write one more test, shall we? We've covered most of the bases, but we still don't know what will happen if we try to send money to a user that doesn't exist in our system. Like we've done for previous scenarios, we will write a test to ensure that the response is in an expected format.

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

public function makeTransferToNonExistentUserAndFail(ApiTester $I)
{
    $authenticatedUser = $I->grabUser(true);
    $I->haveHttpHeader('Authorization', $authenticatedUser->getApiToken());
    $I->sendPost('/transfer', [
        'recipient' => $this->faker->email(),
        'amount'    => $this->faker->numberBetween(100, 900),
    ]);

    $I->seeJSONResponseWithCodeAndContent(
        HttpCode::BAD_REQUEST,
        '"error":"Could not find a user with the specified email address"'
    );
}

Run the TransferCest tests again. This time the "Make transfer to non-existent user and fail" test fails because the system encounters an error trying to get the wallet of a non-existent user in the system.

Update the transfer function, one last time, to match the following.

/**
 * @Route("/transfer", name="transfer", methods={"POST"})
 * @throws ParameterNotFoundException|InvalidParameterException
 */
public function transfer(
    Request $request,
    UserRepository $userRepository,
    EntityManagerInterface $em
): Response
{
    $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();

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

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

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

Run the TransferCest tests one more time and make sure that everything passes. Then, just to be sure, run the entire test suite, by running the command below.

php vendor/bin/codecept run

Conclusion

That brings us to the end of the second part in the series, where we continued to acclimatise ourselves to the Red-Green-Refactor cycle while learning some new things.

We learned how to build our own authenticator using the Symfony Security component as well as how to write custom assertions and helpers in our Codeception test suites.

In addition to learning more about the Codeception framework, we experienced something that is quite common in the software industry, when we introduced some regression into our application. However, we also saw how TDD protects us from its adverse effects.

There are still a few things to learn and some improvements that can be made to our code, so stick around for the third and final part in the series, where we take another dive into TDD and some other key aspects of testing.

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

Author's Bio

Joseph Udonsak is a software engineer with a passion for solving challenges – be it building applications, or conquering new frontiers on Candy Crush. When he’s not staring at his screens, he enjoys a cold beer and laughs with his family and friends.