A Beginner's Guide to Test Driven Development With Symfony and Codeception - Part 3
Time to read: 12 minutes
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.
Next, install the project's dependencies using Composer, by running the command below.
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.
Next, create the development and test databases, by running the commands below.
Now, update the test and development database schemas, by running the following command.
Then, ensure that your setup works properly by running the application's test suite. To do this, run the following command.
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.
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.
Welcome back to the red phase, where the test fails with the error message shown below.
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.
Respond to the CLI prompts as shown below.
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.
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.
After that, add the following use
statement to tests/api/TransferCest.php.
Then, run the tests for the TransferCest again.
This time, you'll get a test failure instead of an error.
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.
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.
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.
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.
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.
The test fails as expected with the following result.
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.
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.
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.
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.
This time, the test fails.
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.
Run the tests again using the following command.
The test fails this time, albeit different from the last one.
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.
Next, in the src/Service/TransferService.php and tests/unit/TransferServiceCest.php files, add the following use
statement.
Run the tests again using the following command.
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.
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.
This runs the entire test suite to make sure everything is in order. You should see one failure this time.
Respond to the CLI command as follows.
Open the newly created src/EventSubscriber/InsufficientFundsExceptionSubscriber.php and update its content to match the following.
Run the tests again to make sure everything is in order using the following command.
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.
Open the newly created tests/api/TransactionHistoryCest.php file and update it to match the following.
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.
The test fails with the following message.
To fix this, add a new controller to retrieve the transaction history for a user, by running the following command.
Open the newly created src/Controller/TransactionHistoryController.php file and update its content to match the following.
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.
Run the test using the following command.
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.
Run the test using the following command.
The test fails with the following message.
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.
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.
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.
Everything works and you get the following message.
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
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.
With this in place, you can run your tests and generate a code coverage report on completion using the following command.
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.
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.
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.
Related Posts
Related Resources
Twilio Docs
From APIs to SDKs to sample apps
API reference documentation, SDKs, helper libraries, quickstarts, and tutorials for your language and platform.
Resource Center
The latest ebooks, industry reports, and webinars
Learn from customer engagement experts to improve your own communication.
Ahoy
Twilio's developer community hub
Best practices, code samples, and inspiration to build communications and digital engagement experiences.