A Beginner's Guide to Test Driven Development With Symfony and Codeception

August 31, 2021
Written by
Joseph Udonsak
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

A Beginner's Guide to Test Driven Development With Symfony and Codeception

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:

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.

The default Symfony home page

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:

  1. Authentication: This feature includes login and registration.
  2. Transfers: This feature allows one registered user to send money to another registered user.
  3. 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.