A Beginner's Guide to Test Driven Development With Symfony and Codeception - Part 2
Time to read: 16 minutes
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:
- Authentication: This feature includes login and registration.
- Transfers: This feature allows one registered user to send money to another registered user.
- Transaction history: This feature allows a registered user to retrieve their recorded transactions.
Prerequisites
To follow this tutorial, you need the following:
- A basic understanding of PHP and Symfony
- PHP 7.4
- Git
- Composer globally installed
- 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, you can clone the repository to get started.
Next, install the project's dependencies using Composer, by running the command below.
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.
Next, create the development and test databases.
After that, update the schema for the development and test databases using the following command.
Then, ensure that your setup works properly by running a test. Run the following command to ensure everything is in order.
If everything went well, then you'll see the message below once the tests are completed.
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.
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.
Welcome back to the red phase, where our test fails with an error message as shown below.
To fix it, we need to create a Wallet entity using the command below.
Respond to the CLI prompts as shown below.
Press the Enter key to stop adding fields. Next, open src/Entity/Wallet.php and update its content to match the following code.
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.
- We added a constructor which takes a
User
as its parameter and creates a newWallet
object with a balance of1000
. - We set the return type of
setBalance
tovoid
as we will not be using fluent setters for this entity. - 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.
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.
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.
Now, we can update both development and test schemas by running the following command.
In tests/api/RegistrationCest.php, add an import statement for the Wallet
entity as follows.
Then, run the tests for the API suite again using the following command, where they should all pass.
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:
- We check that the response code matches what we expect.
- We then check that the response received is valid JSON.
- 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.
Rebuild your suites using the following command.
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.
Now, run the tests in the RegistrationCest to be sure we haven't broken anything.
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.
Then, open the newly created tests/api/TransferCest.php file and update it to match the following code.
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.
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.
Open the newly created controller, src/Controller/TransferController.php, and update it to match the following code.
Then, run the test again.
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.
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.
This time our test fails because our API returns a successful response. In order to secure our endpoint, we need to do two things:
- Add a custom authenticator
- Add an access control rule for the 'transfer' endpoint.
Add custom authenticator
Create a new authenticator using the following command.
Then, respond to the prompts as shown below.
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.
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:
supports
: This function is used to determine whether the authenticator supports the request. We want this authenticator to handle authentication for all requests to themain
firewall, so it returnstrue
. If we had multiple authenticators, we could then check for the presence of a header key (in this caseAuthorization
) to decide whether or not this authenticator supports the request.authenticate
: Theauthenticate()
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.onAuthenticationSuccess
: In our case, we want the request to continue as normal, i.e., the controller matching the login route is called, hence we returnnull
.onAuthenticationFailure
: When authentication fails, we return a JSON response with the 401 Unauthorized status code and an error message.
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.
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.
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.
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.
Since we're faking data, let's also add a private field called faker
and instantiate it in the _before
function as shown below.
Don't forget to import the required classes.
Run the tests for the transfer endpoint again, using the following command.
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.
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.
Next, update the makeTransferSuccessfully
function in tests/api/TransferCest.php to match the following.
Run the tests for the transfer endpoint again, using the following command.
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.
Remember to import the Wallet entity.
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.
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.
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.
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.
With this, we can update the transfer
function in src/Controller/TransferController.php to match the following.
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.
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.
Run the tests again for the /transfer
endpoint using the following command.
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.
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.
Then, add the following use statements to the class.
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.
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.
In the same vein, we can update the code in src/Controller/AuthenticationController.php to match the following code.
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.
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.
Run all the tests in the api
suite again.
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.
Then, run the tests in TransferCest using the following command.
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.
Then, make sure you have the following use
statement at the top of the class.
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.
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.
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.
Next, create an event subscriber using the following command.
When prompted by the CLI, respond as follows.
A new event subscriber will be created at src/EventSubscriber/InvalidParameterExceptionSubscriber.php. Open the file and update it to match the following code.
Next, update the transfer function in src/Controller/TransferController.php to match the following.
Remember to import InvalidParameterException
, by including the use
statement below.
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.
Remember to import InvalidParameterException
by adding the following use
statement to the top of the class.
Next, update the transfer
function in src/Controller/TransferController.php to match the following.
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.
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.
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.
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.
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.
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.
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.