A Beginner's Guide to Test Driven Development With Symfony and Codeception
Time to read: 15 minutes
Have you ever been afraid of your own code? Afraid to review it? Afraid to present it to clients or management? Afraid to explain it because, unwittingly, you have created a digital Jekyll and Hyde?
That used to be me once upon a time until I took testing more seriously.
Let's be honest, testing doesn't quite have the allure of writing production code, and it isn't as glamorous as writing complex data structures and algorithms. Are you excited to write a test case for code that you “know” works?
While this doesn't make testing any less important, it has resulted in testing often being seen as an afterthought by so many; including managers, other developers-even me!
In addition, testing didn't help me allay my fears because, somehow, all the nasty bugs were never exposed by my test cases. Okay, it didn't help that I never went back and updated my tests after a feature change either.
Because of this, I started diving deep and researching software testing. I learned that writing tests on their own was not enough. I learned that I needed to review my development process and do things differently
What's more, I learned about Test Driven Development (TDD)!
TDD places testing firmly at the core of the development process — you can't even refactor the code unless covering tests exist for said code.
In the beginning, it can be slow and arduous—especially when you're used to churning out code. However, with time and dedication the returns are astounding. For example:
- Because you're only writing just enough code to pass a test, there's less room for bugs to hide.
- Having a solid suite of tests provides the foundation for a CI/CD (Continuous Integration/Continuous Development) pipeline.
- Test cases can be used to automatically generate documentation.
- That fear of the unknown is gone which means you can finally unleash that code ninja inside you.
This is the first in a series of articles where I will show you how to build an API for a P2P (Peer-to-peer) payment application using TDD, Codeception, and Symfony. While its functionality will be limited, it will provide ample opportunity to introduce you to TDD, walking you through the Red-Green-Refactor cycle.
Codeception is a PHP testing framework that builds on PHPUnit, providing a next-level testing experience. I've chosen Codeception over other frameworks, as I have found its descriptive nature makes it less painful to embrace TDD. You can easily describe what you expect before even thinking about how to meet that expectation.
In addition to scaffolding a Symfony application and setting up Codeception for testing, we’ll build the authentication functionality for the API and take a first look at some TDD concepts.
Prerequisites
To get the most out of this tutorial, you need the following:
- A basic understanding of PHP
- Previous experience with developing applications using both Symfony and Doctrine
- PHP 7.4 or higher (ideally PHP 8)
- Git
- Composer
- The Symfony CLI
Create the base Symfony application
To get started, create a new Symfony project named codeception-tdd and navigate into it by running the commands below.
Next, as we're using at least PHP 7.4, open composer.json and make sure that the require
section requires PHP to be version 7.4 or higher, as in the example below.
Install the required dependencies
After that, install the project’s dependencies. For this project we will use:
- Doctrine: To help with managing the application's database.
- Faker: To generate fake data for our application.
- Symfony's Maker Bundle: To help create controllers, entities, and the likes.
- Symfony's Security system: To help with authentication and access control in the application.
- Codeception
- Codeception Asserts: This module provides helpful assertion methods to use in tests
- Codeception Doctrine2: This module provides helpers to access the database using Doctrine. It also helps us test for the presence or absence of entities in repositories.
- Codeception PHPBrowser: This module is required by Codeception. It helps with performing web acceptance tests.
- Codeception Rest: This module simplifies the process of testing REST web services.
- Codeception Symfony: This module uses Symfony’s DomCrawler and HttpKernel Components to emulate requests and test responses. It also provides access to the dependency injection container, enabling us to grab services when necessary.
Install them using the commands below.
During the installation, you may see a message similar to the one shown below.
When you do, press y and then press Enter to complete the installation process.
Update the application's configuration
Next, create a .env.local file from the .env file, which Symfony generated during the creation of the project, by running the command below.
This file is ignored by Git as it matches an existing pattern in .gitignore (which Symfony generated).
Next, update the DATABASE_URL
parameter in .env.local so that the app uses an SQLite database instead of the PostgreSQL default. To do that, comment out the existing DATABASE_URL
entry and uncomment the SQLite option so that it matches the example below.
The database will be created in the var directory in the project's root directory and be named data.db.
With those changes made, start the application to be sure everything is working properly, by running the command below.
By default, Symfony projects listen on port 8000, so navigating to https://localhost:8000/ will show the default Symfony welcome page, similar to the image below.
After confirming that the application works, stop it by pressing ctrl + c.
With that done, create a .env.test.local file from the .env.local file using the command below. It will be used by Codeception to provide a separate, local test environment configuration, preventing unexpected behavior.
Update the DATABASE_URL
parameter in .env.test.local to match the example below. This change avoids using the same database in both the test and development environments.
Next, in the root directory of the project, open codeception.yml and update the params
key so that it matches the example below.
Generate a test suite
The last thing we will do is to create a new test suite for API testing. Do that with the following command.
This command creates a Helper and an Actor for the test suite. A Helper is a class provided by Codeception where we can write custom assertions for our test suite. These custom assertions along with the assertions made available by the enabled codeception modules are attached to an object (the Actor of the test suite) which runs through the scenarios described in the tests and makes sure the results pan out.
The command also creates a configuration file named api.suite.yml which allows us to specify the enabled modules for the suite and their dependencies, if they have any.
Open tests/api.suite.yml and update it to match the following.
To be sure everything works, run the test suite with the following command.
If successful, you will see output similar to the example below in your terminal.
This is expected since we haven't written any tests yet. It also lets us know that there are no issues with our configuration.
Write functional tests
For this tutorial, we'll take a top-down approach to build the application, starting by writing tests for the high-level functionality of the API. We'll then drill down and generate unit tests as we refactor and better abstract the code.
While we will use functional tests to ensure that the various modules in the application are interacting with each other and producing expected results, unit tests will be used to test these modules in isolation and ensure that they are providing the correct result in various scenarios.
The application we will build for this series has three features:
- 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 transactions recorded on the system
Add registration functionality
We'll start with tests for registration. These tests will be written in a file known in Codeception as a Cest. Since we're testing the functionality of an API endpoint, the Cest will be stored in the API suite.
Create the Cest using the following command.
It will be created in the tests/api directory and be named RegistrationCest.php. With the file created, update its namespace to match the following.
Next, pay attention to the _before
function. Similar to setUp
in PHPUnit, this is where we can perform common operations before each test is run. For example, we can seed records in the database which will be used by the Cest's test cases. For this Cest, we'll use this function to initialize a Faker instance to generate fake data in each test case.
Write the first test
The first test we write will ensure that when the required details are provided, the API returns an appropriate response. To register with the application, the API's registration endpoint must receive a first name, last name, email address, and password with which to create a new user. If so, the API will return a response message confirming successful registration.
Let's write some tests for that functionality. Update the code in tests/api/RegistrationCest.php to match the following.
The sendPost
function is a helper function provided by the Codeception REST module which allows us to send POST requests to a specified route (register
in this case) along with an array containing the values to be specified in the request body.
Since we only have tests in the API suite, let's run those by running the following command.
The output from the command should give you an error similar to the one shown below.
The reason for this is that we don't have an endpoint to handle registration requests, yet. This is the "Red" phase of TDD, where we write a test we know will fail. The next step is to write the necessary code to make the test pass.
To do that, create a controller (src/Controller/AuthenticationController.php) using the following command.
Then, open src/Controller/AuthenticationController.php and update it to match the following.
Then, run the test suite again. This time you will see that your code passes the test.
This is the "Green'' phase of TDD where we write the code to make the failing test pass. The first few times you do this, it may feel awkward because you know the controller should be doing more, and you may want to add all the necessary code at once.
But a golden rule of TDD is:
Do not write more code than is required for the failing test to pass
To fully embrace TDD, you must resist the urge for your code to get ahead of your tests. What we have done is known in TDD as Sliming.
Write the second test
There's not much to refactor at the moment, so we'll move back to the "Red" phase, by writing another test for the registration process. This test will ensure that the first name is always present in the registration request.
Add the following code to tests/api/RegistrationCest.php.
In this test, we use the sendPost
method to send a request to the API without providing a first name, in the expectation that an error response will be returned with an HTTP 400 response code.
Run the tests again using the following command.
Refactor to check for a first name
This time the tests fail because our code doesn't recognize that the first name wasn't provided, so it returns a success message. To fix this, we need to add a first name check in our controller and return an error response when a first name isn't provided.
To do this, update the register
function in src/Controller/AuthenticationController.php to match the following code.
Run the tests again and watch them pass.
Next, repeat the cycle for a last name by adding the following code to tests/api/RegistrationCest.php.
Run the test and watch it fail, then update the register
function in src/Controller/AuthenticationController.php to match the following.
Run the tests again. This time they will pass.
Refactor to check for an email address
Next, repeat the cycle for the email address by adding the following code to tests/api/RegistrationCest.php.
Run the test and watch them fail, then update the register
function in src/Controller/AuthenticationController.php to match the following.
Run the tests. This time they pass.
Refactor to check for a password
Next, repeat the cycle for the password. Add the following code to tests/api/RegistrationCest.php.
Run the tests and watch them fail, then update the register
function in src/Controller/AuthenticationController.php to match the following.
Run the code again and watch all the tests pass.
Refactor to reduce code duplication
Looking at the code, our changes have introduced significant code duplication. This means we should refactor the code to remove them.
Update the register
function in src/Controller/AuthenticationController.php to match the following.
Next, add the following function to src/Controller/AuthenticationController.php.
Then run the tests again and see that they all pass.
Refactor to persist a user
At this point, we have an endpoint which ensures that required parameters are provided, however it doesn't create a user. So we need to refactor it to check that a user is saved to the database when the appropriate parameters are provided.
To do that, update the registerSuccessfully
function in tests/api/RegistrationCest.php to match the following code.
Run the tests. This time there's one failure.
This error occurs because we don't, yet, have a User
entity. Since we didn't add a use
statement for the User
class, PHP expects the User
class to be found in the same directory as the Cest; hence the reference to App\Tests\api\User
in the error message.
To fix the error, let's create a User
entity. We can do that with Symfony's MakerBundle using the following command.
Respond to the questions asked by the command as follows.
A User
entity has been created that has an email and a password property. However, we still need fields for the first name and last name. Let's add those by running the following command.
Respond to the questions asked in the terminal as follows:
At this point, press the Enter key to stop adding fields. Then, open src/Entity/User.php and add the following constructor.
After that, run schema updates for development and test environments using the following commands. This adds columns in the database for the newly added properties.
Next, update the register
function in src/Controller/AuthenticationController.php to match the following.
After that, add the following use statement to the top of the class.
Here, we create a new User
using the provided parameters and save it to the database using the Entity Manager that was passed to the function.
Add the following use statement to the top of tests/api/RegistrationCest.php.
Run the tests and this time all tests pass.
Refactor to encrypt the password on persist
Next, observe that the user's password is saved in plain text, which isn't what we want - in the event that our database gets compromised and someone is able to see user passwords. This makes our application more secure and also helps protect our users as they tend to reuse passwords on different applications.
To start refactoring this behavior, let's add an assertion to ensure that when the user is saved to the database, the password is encrypted.
Add the following test to tests/api/RegistrationCest.php.
In this test case, we register a new user and then retrieve the user from the database using the grabFromRepository
function.
Using the grabService
function we get the password hasher service from the container and use it to validate the user's hashed password against the password provided during registration. If the generated hash is valid (as it should be) then the isPasswordValid
function returns true
.
Run the tests again. This time they fail because the password is stored in plain text. Fix it by updating the register
function in src/Controller/AuthenticationController.php to match the following.
Finally, add the following use statement to the class.
Refactor to simplify the code
This time the tests pass. However, it looks like we can still do a bit more refactoring. Let's add a function that retrieves a required parameter from the request body. If the parameter is not provided, the function should throw an exception with the provided error message.
Create a new directory called Exception in the src directory. In this directory, using your preferred IDE or text editor, create a new file called ParameterNotFoundException.php and add the following code to it.
Next, in src/Controller/AuthenticationController.php, add the following function.
Then, add the following use statement to the class.
Update the register function in src/Controller/AuthenticationController.php to match the following.
You can also delete the errorResponse
function, as the returning of error responses will be handled elsewhere.
Next, we need to create a Subscriber for the ParameterNotFoundException
where we will return an error response. Do this by running the following command.
Where prompted, respond as shown below.
Open the newly created file, src/EventSubscriber/ParameterNotFoundExceptionSubscriber.php and update it to match the following.
Run the tests again to make sure everything is in order.
Add login functionality
Next we'll handle the login functionality. Create a new Cest for test cases related to login functionality using the following command.
This creates a file in the tests/api directory named LoginCest.php. Once created, update the namespace in tests/api/LoginCest.php to match the following.
Next, add the following fields to the class.
After that, update the _before
function to match the following code.
Finally, add the following use
statements to the top of the file.
The haveInRepository
and grabEntityFromRepository
are helper functions provided by the Codeception Doctrine2 module. They allow us to insert and retrieve entities in our test database respectively.
In this function, we set an email address and password that will be used for valid login simulations. We also fake a user in the database with the haveInRepository
function, before setting the fake user's password to a hash of the fake password which we declared earlier.
Refactor to check that a valid API token is returned after login
With that done, let's write a test to ensure that when the correct email address and password are provided that the API returns an appropriate response: an API token that can be used to make authenticated requests.
Add the following function tests/api/LoginCest.php.
Then, add the following use statement to the class.
Use the seeResponseMatchesJsonType
to express the expected structure of the JSON response from the login
endpoint. What we want is a token with a non-empty string value.
Run the tests and watch Login successfully
fail.
NOTE: you can run just the tests in LoginCest by running the following command.
By doing so, you can save a bit of time and only focus on tests in one class, if you prefer. Make sure you run all the tests at the end, however.
Just like we did with registration, let's slime an endpoint to pass the test, by adding the following to src/Controller/AuthenticationController.php.
For this tutorial, we'll use a small authentication strategy. When the user logs in, we generate an API token and save it to the database. This token will be added to the header of requests to secured endpoints to identify the authenticated user.
Refactor to check that the API in the response matches the one in the database
The next test we write will grab the token from the API response and check to see that it corresponds with the token saved to the user in the database.
Add the following function to tests/api/LoginCest.php .
Run the tests and watch Verify returned api token is valid
fail. This is because, while we return the token it's not assigned to the user nor saved to the database. At the moment, the User
entity doesn't even have a field to store the API token.
To pass this test, let's start by adding a new field to the User
entity by running the following command.
Respond to the CLI prompts as shown below.
Then, press Enter to stop adding fields.
Next, update your database schemas using the following commands.
Then, update the login
function in src/Controller/AuthenticationController.php to match the following code.
Don't forget to add the following use statement to the top of the class.
This time we do the following things:
- Retrieve the user from the database
- Generate a new token
- Set the
apiToken
field for the user - Save the user to the database
- Return the token in the JSON response
Run the tests and watch them all pass.
Refactor to ensure access is granted only to valid users with valid tokens
We've been able to generate a token, however we haven't done any validation, which lets any user generate a token without providing a valid password. To fix that, let's add a test case which expects an error response when an incorrect password is provided.
Add the following function to tests/api/LoginCest.php.
Run the tests and watch Login with invalid password and fail
fail.
Next, update the login function in src/Controller/AuthenticationController.php to match the following code so that the test will pass.
Refactor to ensure that an email address exists in the database
Finally, we have to consider the possibility that the email address provided does not correspond to any existing user's email address. In such an event, an error response should also be returned by the API.
To test for this, add the following function to tests/api/LoginCest.php.
Run the tests and watch Login with unknown email address and fail
fail. This is because the findOne
function called on the UserRepository
returns null
, which in turn causes the password hash check to throw an exception.
Update the login
function in src/Controller/AuthenticationController.php to match the following code to implement this functionality.
Run the tests and watch them pass.
Refactor to abstract the error response away from the controller
At this point, we can refactor the login
function and abstract the error response away from the controller.
To do that, in the Exception directory, create a new file called AuthenticationException.php, and add the following code to it.
Next we need to create a Subscriber
for AuthenticationException
where we will return an error response. Create the Subscriber
with the following command.
When prompted, respond as shown below.
This creates a new file, src/EventSubscriber/AuthenticationExceptionSubscriber.php. Open it and update it to match the following code.
Finally, update the login
function in src/Controller/AuthenticationController.php to match the following, to implement the remaining functionality.
Plus, add the following use
statement to the existing list at the top of the class.
Then, run the tests one last time to see that they all pass.
Conclusion
Not only does testing boost confidence in the application being built, it can also be a measure of proof that the application meets the specifications set out for it during the conception stage.
By putting testing at the core of our development process, we are able to move quickly by making small, incremental changes to create a fully tested feature. We are also able to provide a safeguard against unforeseen bugs when we refactor our code. By doing so, we create applications that can be trusted by both developers and users.
In this article, we took our first step into TDD by using the Red-Green-Refactor cycle to build the authentication feature of our application. We also looked at the concept of "sliming" which we used to make our code specific at first, but more generic as we added more tests and discovered patterns.
However, the journey doesn’t end here! We will continue to build more features and uncover more testing gems in part two!!
You can review the final codebase on GitHub. Until next time, bye for now.
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.