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:
- A basic understanding of PHP and Symfony
- Composer globally installed
- Git
- PHP 7.4
- The Symfony CLI
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:
- The sender
- The recipient
- The amount
- 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.
- 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.
- 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 Xdebug, phpdbg, 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 asclasses
.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.
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:
- Write a test to trigger the code
- 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.

In the article, you will learn how to create a drag-and-drop file upload system in CakePHP using Dropzone.js, which leverages AJAX to upload files without requiring a page refresh.

This tutorial will teach you the essentials of implementing CRUD operations in CakePHP. It illustrates how users can create, read, update, and delete records, thus providing a guide to managing data in your application in CakePHP.

In this tutorial, you will learn how to export data from a MySQL database to a CSV file with the CakePHP framework.

In this tutorial, you will get an in-depth exploration of Docker — in the context of Laravel. Then, rather than relying on Laravel Sail's pre-configured environment, you will learn how to run Laravel inside a Docker and deploy it with Docker Compose.

In this tutorial, you will learn how you can use the Mercure protocol in your Symfony applications to broadcast updates to the frontend.

In this tutorial, you will learn how to upload files in CakePHP