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.
symfony new codeception-tdd
cd codeception-tdd
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.
"php": ">=7.4",
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.
composer require --with-dependencies doctrine security
composer require --dev \
codeception/codeception \
codeception/module-asserts \
codeception/module-doctrine2 \
codeception/module-phpbrowser \
codeception/module-rest \
codeception/module-symfony \
fakerphp/faker \
maker
During the installation, you may see a message similar to the one shown below.
Do you want to execute this recipe?
[y] Yes
[n] No
[a] Yes for all packages, only for the current installation session
[p] Yes permanently, never ask again for this project
(defaults to n):
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.
cp .env .env.local
This file is ignored by Git as it matches an existing pattern in .gitignore (which Symfony generated).
One of the reasons for this file is the accepted best practice of storing your credentials outside of code to keep them safe.
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.
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
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.
symfony serve
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.
As with .env.local, .env.test.local is also not tracked by Git.
cp .env.local .env.test.local
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.
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_test.db"
Next, in the root directory of the project, open codeception.yml and update the params
key so that it matches the example below.
params:
- .env.test.local
If codeception.yml is not present, run the following commands to clear the tests directory and bootstrap Codeception afresh.
rm -rf tests/*
php vendor/bin/codecept bootstrap --namespace=App\\Tests
We specify a namespace that adheres to the Symfony best practices and PSR-4 namespace conventions.
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.
php vendor/bin/codecept generate:suite api
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.
actor: ApiTester
modules:
enabled:
- Symfony:
app_path: 'src'
environment: 'test'
part: services
- REST:
depends: Symfony
part: Json
- Doctrine2:
depends: Symfony
cleanup: true
- \App\Tests\Helper\Api
- Asserts
To be sure everything works, run the test suite with the following command.
php vendor/bin/codecept run
If successful, you will see output similar to the example below in your terminal.
Codeception PHP Testing Framework v4.1.21
Powered by PHPUnit 9.5.6 by Sebastian Bergmann and contributors.
App\Tests.acceptance Tests (0) --------------------------------------------------
---------------------------------------------------------------------------------
App\Tests.api Tests (0) --------------------------------------------------------
---------------------------------------------------------------------------------
App\Tests.functional Tests (0) --------------------------------------------------
---------------------------------------------------------------------------------
App\Tests.unit Tests (0) -------------------------------------------------------
---------------------------------------------------------------------------------
No tests executed!
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.
php vendor/bin/codecept generate:cest api Registration
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.
namespace App\Tests\api;
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.
<?php
namespace App\Tests\api;
use App\Tests\ApiTester;
use Codeception\Util\HttpCode;
use Faker\Factory;
use Faker\Generator;
class RegistrationCest
{
private Generator $faker;
public function _before(ApiTester $I)
{
$this->faker = Factory::create();
}
public function registerSuccessfully(ApiTester $I)
{
$I->sendPost(
'/register',
[
'firstName' => $this->faker->firstName(),
'lastName' => $this->faker->lastName(),
'emailAddress' => $this->faker->email(),
'password' => $this->faker->password()
]
);
$I->seeResponseCodeIs(HttpCode::CREATED);
$I->seeResponseIsJson();
$I->seeResponseContains('"message":"Account created successfully"');
}
}
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.
php vendor/bin/codecept run api
The output from the command should give you an error similar to the one shown below.
App\Tests.api Tests (1) -------------------------------------------------------------
- RegistrationCest: Register successfully[error]
✖ RegistrationCest: Register successfully (0.29s)
There was 1 failure:
---------
1) RegistrationCest: Register successfully
Test tests/api/RegistrationCest.php:registerSuccessfully
Step See response code is 201
Fail Expected HTTP Status Code: 201 (Created). Actual Status Code: 404 (Not Found)
Failed asserting that 404 matches expected 201.
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.
symfony console make:controller AuthenticationController
Then, open src/Controller/AuthenticationController.php and update it to match the following.
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class AuthenticationController extends AbstractController
{
/**
* @Route("/register", name="register")
*/
public function register(): JsonResponse
{
return $this->json(
['message' => 'Account created successfully',],
Response::HTTP_CREATED
);
}
}
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.
public function registerWithoutFirstNameAndFail(ApiTester $I)
{
$I->sendPost(
'/register',
[
'lastName' => $this->faker->lastName(),
'emailAddress' => $this->faker->email(),
'password' => $this->faker->password()
]
);
$I->seeResponseCodeIs(HttpCode::BAD_REQUEST);
$I->seeResponseIsJson();
$I->seeResponseContains('"error":"First name is required"');
}
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.
php vendor/bin/codecept run api
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.
/**
* @Route("/register", name="register")
*/
public function register(Request $request): JsonResponse
{
$firstName = $request->get('firstName');
if (is_null($firstName)) {
return $this->json(
[
'error' => 'First name is required'
],
Response::HTTP_BAD_REQUEST
);
}
return $this->json(
['message' => 'Account created successfully'],
Response::HTTP_CREATED
);
}
Don't forget to add the required use
statement to the top of the class as well.
use Symfony\Component\HttpFoundation\Request;
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.
public function registerWithoutLastNameAndFail(ApiTester $I)
{
$I->sendPost(
'/register',
[
'firstName' => $this->faker->firstName(),
'emailAddress' => $this->faker->email(),
'password' => $this->faker->password()
]
);
$I->seeResponseCodeIs(HttpCode::BAD_REQUEST);
$I->seeResponseIsJson();
$I->seeResponseContains('"error":"Last name is required"');
}
Run the test and watch it fail, then update the register
function in src/Controller/AuthenticationController.php to match the following.
/**
* @Route("/register", name="register")
*/
public function register(Request $request): JsonResponse
{
$firstName = $request->get('firstName');
if (is_null($firstName)) {
return $this->json(
[
'error' => 'First name is required'
],
Response::HTTP_BAD_REQUEST
);
}
$lastName = $request->get('lastName');
if (is_null($lastName)) {
return $this->json(
[
'error' => 'Last name is required'
],
Response::HTTP_BAD_REQUEST
);
}
return $this->json(
['message' => 'Account created successfully'],
Response::HTTP_CREATED
);
}
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.
public function registerWithoutEmailAddressAndFail(ApiTester $I)
{
$I->sendPost(
'/register',
[
'firstName' => $this->faker->firstName(),
'lastName' => $this->faker->lastName(),
'password' => $this->faker->password()
]
);
$I->seeResponseCodeIs(HttpCode::BAD_REQUEST);
$I->seeResponseIsJson();
$I->seeResponseContains('"error":"Email address is required"');
}
Run the test and watch them fail, then update the register
function in src/Controller/AuthenticationController.php to match the following.
/**
* @Route("/register", name="register")
*/
public function register(Request $request): JsonResponse
{
$firstName = $request->get('firstName');
if (is_null($firstName)) {
return $this->json(
[
'error' => 'First name is required'
],
Response::HTTP_BAD_REQUEST
);
}
$lastName = $request->get('lastName');
if (is_null($lastName)) {
return $this->json(
[
'error' => 'Last name is required'
],
Response::HTTP_BAD_REQUEST
);
}
$emailAddress = $request->get('emailAddress');
if (is_null($emailAddress)) {
return $this->json(
[
'error' => 'Email address is required'
],
Response::HTTP_BAD_REQUEST
);
}
return $this->json(
[
'message' => 'Account created successfully'
],
Response::HTTP_CREATED
);
}
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.
public function registerWithoutPasswordAndFail(ApiTester $I)
{
$I->sendPost(
'/register',
[
'firstName' => $this->faker->firstName(),
'lastName' => $this->faker->lastName(),
'emailAddress' => $this->faker->email(),
]
);
$I->seeResponseCodeIs(HttpCode::BAD_REQUEST);
$I->seeResponseIsJson();
$I->seeResponseContains('"error":"Password is required"');
}
Run the tests and watch them fail, then update the register
function in src/Controller/AuthenticationController.php to match the following.
/**
* @Route("/register", name="register")
*/
public function register(Request $request): JsonResponse
{
$firstName = $request->get('firstName');
if (is_null($firstName)) {
return $this->json(
[
'error' => 'First name is required'
],
Response::HTTP_BAD_REQUEST
);
}
$lastName = $request->get('lastName');
if (is_null($lastName)) {
return $this->json(
[
'error' => 'Last name is required'
],
Response::HTTP_BAD_REQUEST
);
}
$emailAddress = $request->get('emailAddress');
if (is_null($emailAddress)) {
return $this->json(
[
'error' => 'Email address is required'
],
Response::HTTP_BAD_REQUEST
);
}
$password = $request->get('password');
if (is_null($password)) {
return $this->json(
[
'error' => 'Password is required'
],
Response::HTTP_BAD_REQUEST
);
}
return $this->json(
['message' => 'Account created successfully'],
Response::HTTP_CREATED
);
}
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.
/**
* @Route("/register", name="register")
*/
public function register(Request $request): JsonResponse
{
$requestBody = $request->request->all();
$requiredParameters = [
'firstName' => 'First name is required',
'lastName' => 'Last name is required',
'emailAddress' => 'Email address is required',
'password' => 'Password is required'
];
foreach ($requiredParameters as $parameter => $errorMessage) {
if (!isset($requestBody[$parameter])) {
return $this->errorResponse($errorMessage);
}
}
return $this->json(
[
'message' => 'Account created successfully',
],
Response::HTTP_CREATED
);
}
Next, add the following function to src/Controller/AuthenticationController.php.
private function errorResponse(string $errorMessage): JsonResponse
{
return $this->json(
[
'error' => $errorMessage
],
Response::HTTP_BAD_REQUEST
);
}
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.
public function registerSuccessfully(ApiTester $I)
{
$firstName = $this->faker->firstName();
$lastName = $this->faker->lastName();
$emailAddress = $this->faker->email();
$I->sendPost(
'/register',
[
'firstName' => $firstName,
'lastName' => $lastName,
'emailAddress' => $emailAddress,
'password' => $this->faker->password()
]
);
$I->seeResponseCodeIs(HttpCode::CREATED);
$I->seeResponseIsJson();
$I->seeResponseContains('"message":"Account created successfully"');
$I->canSeeInRepository(User::class, [
'firstName' => $firstName,
'lastName' => $lastName,
'email' => $emailAddress
]);
}
Run the tests. This time there's one failure.
---------
1) RegistrationCest: Register successfully
Test tests/api/RegistrationCest.php:registerSuccessfully
[Doctrine\Persistence\Mapping\MappingException] Class 'App\Tests\api\User' does not exist
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.
symfony console make:user
Respond to the questions asked by the command as follows.
The name of the security user class (e.g. User) [User]:
> User
Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
> yes
Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
> email
Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).
Does this app need to hash/check user passwords? (yes/no) [yes]:
> yes
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.
symfony console make:entity User
Respond to the questions asked in the terminal as follows:
New property name (press <return> to stop adding fields):
> firstName
Field type (enter ? to see all types) [string]:
> string
Field length [255]:
> 50
Can this field be null in the database (nullable) (yes/no) [no]:
> no
updated: src/Entity/User.php
Add another property? Enter the property name (or press <return> to stop adding fields):
> lastName
Field type (enter ? to see all types) [string]:
> string
Field length [255]:
> 50
Can this field be null in the database (nullable) (yes/no) [no]:
> no
At this point, press the Enter key to stop adding fields. Then, open src/Entity/User.php and add the following constructor.
public function __construct(
string $firstName,
string $lastName,
string $email
) {
$this->email = $email;
$this->firstName = $firstName;
$this->lastName = $lastName;
}
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.
symfony console doctrine:schema:update --force
symfony console doctrine:schema:update --force --env=test
Next, update the register
function in src/Controller/AuthenticationController.php to match the following.
/**
* @Route("/register", name="register")
*/
public function register(Request $request, EntityManagerInterface $em): JsonResponse
{
$requiredParameters = [
'firstName' => 'First name is required',
'lastName' => 'Last name is required',
'emailAddress' => 'Email address is required',
'password' => 'Password is required'
];
$requestBody = $request->request->all();
foreach ($requiredParameters as $parameter => $errorMessage) {
if (!isset($requestBody[$parameter])) {
return $this->errorResponse($errorMessage);
}
}
$user = new User(
$requestBody['firstName'],
$requestBody['lastName'],
$requestBody['emailAddress']
);
$user->setPassword($requestBody['password']);
$em->persist($user);
$em->flush();
return $this->json(
[
'message' => 'Account created successfully',
],
Response::HTTP_CREATED
);
}
After that, add the following use statement to the top of the class.
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
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.
use App\Entity\User;
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.
public function registerUserAndEnsurePasswordIsHashed(ApiTester $I)
{
$emailAddress = $this->faker->email();
$password = $this->faker->password();
$I->sendPost(
'/register',
[
'firstName' => $this->faker->firstName(),
'lastName' => $this->faker->lastName(),
'emailAddress' => $emailAddress,
'password' => $password
]
);
$user = $I->grabEntityFromRepository(
User::class,
[
'email' => $emailAddress
]
);
$hasher = $I->grabService('security.user_password_hasher');
$I->assertTrue($hasher->isPasswordValid($user, $password));
}
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.
/**
* @Route("/register", name="register")
*/
public function register(
Request $request,
EntityManagerInterface $em,
UserPasswordHasherInterface $passwordHasher
): JsonResponse
{
$requiredParameters = [
'firstName' => 'First name is required',
'lastName' => 'Last name is required',
'emailAddress' => 'Email address is required',
'password' => 'Password is required'
];
$requestBody = $request->toArray();
foreach ($requiredParameters as $parameter => $errorMessage) {
if (!isset($requestBody[$parameter])) {
return $this->errorResponse($errorMessage);
}
}
$user = new User(
$requestBody['firstName'],
$requestBody['lastName'],
$requestBody['emailAddress']
);
$hashedPassword = $passwordHasher->hashPassword(
$user,
$requestBody['password']
);
$user->setPassword($hashedPassword);
$em->persist($user);
$em->flush();
return $this->json(
[
'message' => 'Account created successfully',
],
Response::HTTP_CREATED
);
}
Finally, add the following use statement to the class.
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
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.
<?php
namespace App\Exception;
use Exception;
class ParameterNotFoundException extends Exception {}
Next, in src/Controller/AuthenticationController.php, add the following function.
private function getRequiredParameter(
string $parameterName,
array $requestBody,
string $errorMessage
) {
if (!isset($requestBody[$parameterName])) {
throw new ParameterNotFoundException($errorMessage);
}
return $requestBody[$parameterName];
}
Then, add the following use statement to the class.
use App\Exception\ParameterNotFoundException;
Update the register function in src/Controller/AuthenticationController.php to match the following.
/**
* @Route("/register", name="register")
*/
public function register(
Request $request,
EntityManagerInterface $em,
UserPasswordHasherInterface $passwordHasher
) : JsonResponse
{
$requestBody = $request->request->all();
$firstName = $this->getRequiredParameter('firstName', $requestBody, 'First name is required');
$lastName = $this->getRequiredParameter('lastName', $requestBody, 'Last name is required');
$emailAddress = $this->getRequiredParameter('emailAddress', $requestBody, 'Email address is required');
$password = $this->getRequiredParameter('password', $requestBody, 'Password is required');
$user = new User($firstName, $lastName, $emailAddress);
$hashedPassword = $passwordHasher->hashPassword($user, $password);
$user->setPassword($hashedPassword);
$em->persist($user);
$em->flush();
return $this->json(
[
'message' => 'Account created successfully'
],
Response::HTTP_CREATED
);
}
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.
symfony console make:subscriber ParameterNotFoundExceptionSubscriber
Where prompted, respond as shown below.
What event do you want to subscribe to?:
> kernel.exception
Open the newly created file, src/EventSubscriber/ParameterNotFoundExceptionSubscriber.php and update it to match the following.
<?php
namespace App\EventSubscriber;
use App\Exception\ParameterNotFoundException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
class ParameterNotFoundExceptionSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array {
return [
'kernel.exception' => 'onKernelException',
];
}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if ($exception instanceof ParameterNotFoundException) {
$event->setResponse(
new JsonResponse(
[
'error' => $exception->getMessage(),
], Response::HTTP_BAD_REQUEST
)
);
}
}
}
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.
php vendor/bin/codecept generate:cest api Login
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.
namespace App\Tests\api;
Next, add the following fields to the class.
private Generator $faker;
private string $validEmailAddress;
private string $validPassword;
After that, update the _before
function to match the following code.
public function _before(ApiTester $I)
{
$this->faker = Factory::create();
$this->validEmailAddress = $this->faker->email();
$this->validPassword = $this->faker->password();
$hasher = $I->grabService('security.password_hasher');
$I->haveInRepository(
User::class,
[
'firstName' => $this->faker->firstName(),
'lastName' => $this->faker->lastName(),
'email' => $this->validEmailAddress,
'password' => ''
]
);
$user = $I->grabEntityFromRepository(
User::class,
[
'email' => $this->validEmailAddress
]
);
$user->setPassword($hasher->hashPassword($user, $this->validPassword));
}
Finally, add the following use
statements to the top of the file.
use App\Entity\User;
use Faker\Factory;
use Faker\Generator;
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.
public function loginSuccessfully(ApiTester $I)
{
$I->sendPost(
'/login',
[
'emailAddress' => $this->validEmailAddress,
'password' => $this->validPassword
]
);
$I->seeResponseCodeIs(HttpCode::OK);
$I->seeResponseMatchesJsonType(
[
'token' => 'string:!empty'
]
);
}
Then, add the following use statement to the class.
use Codeception\Util\HttpCode;
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.
php vendor/bin/codecept run api tests/api/LoginCest.php
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.
/**
* @Route("/login", name="login")
*/
public function login(): JsonResponse {
return $this->json([
'token' => bin2hex(random_bytes(32))
]);
}
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 .
public function verifyReturnedAPITokenIsValid(ApiTester $I)
{
$I->sendPost(
'/login',
[
'emailAddress' => $this->validEmailAddress,
'password' => $this->validPassword
]
);
$token = $I->grabDataFromResponseByJsonPath('token')[0];
$I->seeInRepository(
User::class,
[
'email' => $this->validEmailAddress,
'apiToken' => $token
]
);
}
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.
symfony console make:entity User
Respond to the CLI prompts as shown below.
Your entity already exists! So let's add some new fields!
New property name (press <return> to stop adding fields):
> apiToken
Field type (enter ? to see all types) [string]:
> string
Field length [255]:
> 255
Can this field be null in the database (nullable) (yes/no) [no]:
> yes
Then, press Enter to stop adding fields.
Next, update your database schemas using the following commands.
symfony console doctrine:schema:update --force
symfony console doctrine:schema:update --force --env=test
Then, update the login
function in src/Controller/AuthenticationController.php to match the following code.
/**
* @Route("/login", name="login")
*/
public function login(
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em
): JsonResponse
{
$requestBody = $request->request->all();
$emailAddress = $requestBody['emailAddress'];
$user = $userRepository->findOneBy(['email' => $emailAddress]);
$apiToken = bin2hex(random_bytes(32));
$user->setApiToken($apiToken);
$em->persist($user);
$em->flush();
return $this->json(
[
'token' => $apiToken
]
);
}
Don't forget to add the following use statement to the top of the class.
use App\Repository\UserRepository;
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.
public function loginWithInvalidPasswordAndFail(ApiTester $I)
{
$I->sendPost(
'/login',
[
'emailAddress' => $this->validEmailAddress,
'password' => 'ThisPasswordIsInvalid...'
]
);
$I->seeResponseCodeIs(HttpCode::UNAUTHORIZED);
$I->seeResponseContains('"error":"Invalid login credentials provided"');
}
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.
/**
* @Route("/login", name="login")
*/
public function login(
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em,
UserPasswordHasherInterface $passwordHasher
): JsonResponse
{
$requestBody = $request->request->all();
$emailAddress = $requestBody['emailAddress'];
$password = $requestBody['password'];
$user = $userRepository->findOneBy(['email' => $emailAddress]);
if (!$passwordHasher->isPasswordValid($user, $password)) {
return $this->json(
[
'error' => 'Invalid login credentials provided'
],
Response::HTTP_UNAUTHORIZED
);
}
$apiToken = bin2hex(random_bytes(32));
$user->setApiToken($apiToken);
$em->persist($user);
$em->flush();
return $this->json(
[
'token' => $apiToken
]
);
}
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.
public function loginWithUnknownEmailAddressAndFail(ApiTester $I)
{
$I->sendPost(
'/login',
[
'emailAddress' => 'unknown@test.com',
'password' => $this->validPassword
]
);
$I->seeResponseCodeIs(HttpCode::UNAUTHORIZED);
$I->seeResponseContains('"error":"Invalid login credentials provided"');
}
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.
/**
* @Route("/login", name="login")
*/
public function login(
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em,
UserPasswordHasherInterface $hasher
): JsonResponse
{
$requestBody = $request->request->all();
$emailAddress = $requestBody['emailAddress'];
$password = $requestBody['password'];
$user = $userRepository->findOneBy(['email' => $emailAddress]);
if (is_null($user)) {
return $this->json(
[
'error' => 'Invalid login credentials provided'
],
Response::HTTP_UNAUTHORIZED
);
}
if (!$hasher->isPasswordValid($user, $password)) {
return $this->json(
[
'error' => 'Invalid login credentials provided'
],
Response::HTTP_UNAUTHORIZED
);
}
$apiToken = bin2hex(random_bytes(32));
$user->setApiToken($apiToken);
$em->persist($user);
$em->flush();
return $this->json(
[
'token' => $apiToken
]
);
}
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.
<?php
namespace App\Exception;
use Exception;
class AuthenticationException extends Exception {}
Next we need to create a Subscriber
for AuthenticationException
where we will return an error response. Create the Subscriber
with the following command.
symfony console make:subscriber AuthenticationExceptionSubscriber
When prompted, respond as shown below.
What event do you want to subscribe to?:
> kernel.exception
This creates a new file, src/EventSubscriber/AuthenticationExceptionSubscriber.php. Open it and update it to match the following code.
<?php
namespace App\EventSubscriber;
use App\Exception\AuthenticationException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
class AuthenticationExceptionSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'kernel.exception' => 'onKernelException',
];
}
public function onKernelException(ExceptionEvent $event)
{
$exception = $event->getThrowable();
if ($exception instanceof AuthenticationException) {
$event->setResponse(
new JsonResponse(
[
'error' => 'Invalid login credentials provided',
],
Response::HTTP_UNAUTHORIZED
)
);
}
}
}
Finally, update the login
function in src/Controller/AuthenticationController.php to match the following, to implement the remaining functionality.
/**
* @Route("/login", name="login")
*/
public function login(
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em,
UserPasswordHasherInterface $passwordHasher
): JsonResponse
{
$requestBody = $request->request->all();
$emailAddress = $requestBody['emailAddress'];
$password = $requestBody['password'];
$user = $userRepository->findOneBy(['email' => $emailAddress]);
if (is_null($user) || !$passwordHasher>isPasswordValid($user, $password)) {
throw new AuthenticationException();
}
$apiToken = bin2hex(random_bytes(32));
$user->setApiToken($apiToken);
$em->persist($user);
$em->flush();
return $this->json(
[
'token' => $apiToken
]
);
}
Plus, add the following use
statement to the existing list at the top of the class.
use App\Exception\AuthenticationException;
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.

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

In this tutorial, you're going to learn how to store files using Google Cloud Storage and PHP's Flysystem package.

In this short tutorial, you will learn how to mock Twilio's Lookup API.

In this tutorial, you will learn the Intervention Image's features, and use it to build a simple meme generator using it with Laravel.

In this short tutorial, you're going to learn how to optimise images in PHP with only a negligible loss of quality — if any.