Continuous integration (CI) is the process of integrating changes from multiple contributors to create a single software project. A key component for a smooth CI pipeline is testing. Tests prove that the code does exactly what it says on the tin and that it’s safe to merge the code into the central repository. Tests also anticipate edge cases and ensure that the code handles such cases in a deterministic manner.

In this article, I will show you how to set up continuous integration for a Yii2 API using CircleCI. For testing, we’ll use Codeception. Built on PHPUnit, Codeception is a test framework that can easily carry out unit, functional, and acceptance tests on PHP applications in a unified manner. Yii2 applications come installed with Codeception out of the box, which means we can write our tests without any additional configuration.

Prerequisites

A basic understanding of Yii and PHP will be of help in this tutorial. However, I will provide brief explanations and links to official documentation throughout the tutorial. If you’re unclear on any concept, you can review the linked material before continuing with the tutorial.

Additionally, you will need the following:

  • A PHP version greater than 7 with the PDO extension enabled
  • Composer for dependency management
  • A local database instance (we’ll use MySQL in this tutorial, but you are free to select your preferred database service)
  • A free CircleCI account to set up your testing pipeline

Getting started

In this tutorial, we’ll build an API that handles authentication. The API will handle the process of registration and login. In addition to writing code and tests for each functionality, we’ll set up a CI pipeline using CircleCI to automatically run our tests on every change to the API.

Creating a Yii2 application

To get started, create a new application called auth-api and switch to the project directory using the following commands.

composer create-project --prefer-dist yiisoft/yii2-app-basic auth-api

cd auth-api

Verify that the application was created successfully using the following command.

php yii serve

By default the application will be served on http://localhost:8080/. Navigate to the URL and see the welcome page as shown below.

2021-07-29-yii-default-page

Return to the terminal and press Control + C to quit the server.

Setting up the database

Using your preferred database management application, create a new database called auth_api.

Next, update the database connection parameters to link your application with the newly created database. To do this, open config/db.php and update the returned array to match the one shown below.

return [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=localhost;dbname=auth_api',
'username' => 'root', //Insert your database username here
'password' => '', //Insert your database password here
'charset' => 'utf8',
];

Creating migrations

Next, create the migrations for the database tables. The application will have one entity for the user. The user table will have the following columns:

  1. Username
  2. Password
  3. Access token

To create a migration, use the yii migrate/create command providing the name of the migration to be created. When asked for confirmation, type yes for the migration to be created. For this article, use the command below to create the needed migration.

php yii migrate/create create_user_table

By default, migration files are located in the migrations directory. Migration file names are prefixed with the letter m and the UTC datetime of its creation.

Open migrations/m<YYMMDD_HHMMSS>_create_user_table.php and modify the safeUp and safeDown functions to match the following code.

public function safeUp() {

    $this->createTable(
        'user',
        [
            'id'          => $this->primaryKey(),
            'username'    => $this->string()->notNull(),
            'password'    => $this->string()->notNull(),
            'accessToken' => $this->string()->notNull(),
        ]
    );
}

public function safeDown() {

$this->dropTable('user');
}

Run your migrations to create the tables using the following command:

php yii migrate

Type yes and press Enter to run the migrations.

Open the auth_api database in your preferred application to see the new table structure.

2021-07-29-db-column

Creating a user model

Instead of writing raw SQL queries to interact with the database, we will create Active Records for our user model. This will give us an object-oriented means of accessing and storing data in the database. Create an Active Record class for the User entity using the following command.

php yii gii/model --tableName=user --modelClass=User

Type yes when prompted to override the default implementation of the User entity. Open models/User.php and update it to match the following.

<?php

namespace app\models;

use Yii;
use yii\db\ActiveRecord;
use yii\web\IdentityInterface;

/**
* This is the model class for table "user".
*
* @property int    $id
* @property string $username
* @property string $password
* @property string $accessToken
*/
class User extends ActiveRecord implements IdentityInterface {

/**
 * {@inheritdoc}
 */
public static function tableName() {

    return 'user';
}

/**
 * {@inheritdoc}
 */
public function rules() {

    return [
        [['username', 'password', 'accessToken'], 'required'],
        [['username', 'password', 'accessToken'], 'string', 'max' => 255],
    ];
}

/**
 * {@inheritdoc}
 */
public function attributeLabels() {

    return [
        'id'          => 'ID',
        'username'    => 'Username',
        'password'    => 'Password',
        'accessToken' => 'Access Token',
    ];
}

public static function findIdentity($id) {

    return static::findOne($id);
}

public static function findIdentityByAccessToken($token, $type = null) {

    return static::findOne(['accessToken' => $token]);
}

public function getId() {
}

public function getAuthKey() {
}

public function validateAuthKey($authKey) {
}
}

Configuring application components

Next, modify the user application component in your application configuration. This is done in the config/web.php file.

In the config/web.php, a $config array is declared. This array contains a components array, in it update the user array to match the following.

'user' => [
        'identityClass' => 'app\models\User',
        'enableSession' => false,
        'loginUrl' => null
    ],

Here we specify the user identity class which is the recent updated User model. We also set enableSession to false since we are building a stateless REST API. Finally, by setting the loginURl to null an HTTP 403 error will be returned instead of redirecting to the login page.

Next, configure the urlManager component in your application configuration. This is done in the config/web.php file. In the components array, add the following.

'urlManager'   => [
        'enablePrettyUrl'     => true,
        'showScriptName'      => false,
        'enableStrictParsing' => true,
        'rules'               => [
            'POST register' => 'authentication/register',
            'POST login' => 'authentication/login'
        ],
    ],

Here we also specify the rules for the registration and login endpoints.

The components array also contains a request array which holds the configuration for the request Application Component. To enable the API to parse JSON input, add the following to the request array.

'parsers' => [
    'application/json' => 'yii\web\JsonParser',
]

Setting up the test environment

To avoid unwanted side effects as a result of (for example) the test cases interacting with the development database, you can set up a test environment.

Start by creating a test database. Using your preferred database management application, create a new database called auth_api_test.

Next, update config/test_db.php to match the following code.

<?php

$db = require __DIR__ . '/db.php';

$db['dsn'] = 'mysql:host=127.0.0.1;dbname=auth_api_test';
$db['password'] = 'secret';

return $db;

Having connected the application to the test database, create the tables and seed them with data using the following command.

php tests/bin/yii migrate

Type yes and press Enter to run the migrations.

Once completed, you should have a test database with sample data and the same structure as the development database.

Next, update the urlManager, user, and request components in config/test.php to match the following.

'urlManager' => [
        'showScriptName' => true,
        'enablePrettyUrl'     => true,
        'enableStrictParsing' => true,
        'rules'               => [
            'POST register' => 'authentication/register',
            'POST login' => 'authentication/login'
        ],
    ],
'user' => [
        'identityClass' => 'app\models\User',
        'enableSession' => false,
        'loginUrl' => null
    ],
'request' => [
        'cookieValidationKey' => 'test',
        'enableCsrfValidation' => false,
        'parsers' => [
            'application/json' => 'yii\web\JsonParser',
        ]
    ],

It is important to update the routing rules in the test environment to ensure that our requests are handled as they would be in the development environment. This allows us to thoroughly test our application’s functionality during the testing phase and be confident that the results will .

With these in place, you can set up a test suite and write test cases for the API endpoints.

Setting up your Codeception test suite

Yii2 projects are by default bootstrapped with a minimum Codeception configuration, and this makes it easy to get started testing your Yii2 apps quickly.

Test cases and related files are located in the tests directory. Functional tests are located in the tests/functional directory. Unit tests are located in the tests/unit directory while acceptance tests are located in the tests/acceptance directory. You can take a few minutes to look at them before moving on. For a quick refresher on test types, check out Unit testing vs integration testing and Functional vs non-functional software testing.

The tests written in this article will essentially be functional tests. The difference between the tests we will write and those in the tests/functional directory is that instead of testing HTML responses on user actions, they test requests and responses via REST.

Testing a REST API with Codeception requires the codeception/module-rest package. Install it using the following command:

composer req --dev codeception/module-rest:^2.0

Alternatively, you can also run the command shown below:

composer require "codeception/module-rest:^2.0" --dev

Note that we specified a version of the module-rest to be installed. This is because version 2 is the stable version as at the time of writing.

To start writing API tests, create a suite for them using the following command:

php vendor/bin/codecept generate:suite api

This command creates a Helper and an Actor to help with testing the endpoints. The suite configuration is also created and saved in the tests directory.

Next, update the api suite configuration. Open tests/api.suite.yml and update it to match the following:

actor: ApiTester
modules:
  enabled:
    - REST:
        url: http://localhost:8080/index-test.php/
        depends: Yii2
        part: JSON
        configFile: "config/test.php"
    - \Helper\Api
    - Asserts
    - Yii2

Next, build the test suite by running the following command:

php vendor/bin/codecept build

With that in place, let’s create our test classes. The test format commonly used by Codeception is called (Cests). It is a class-based format that supports multiple tests per file. To get started, we’ll write a Cest to ensure our suite configuration works.


php vendor/bin/codecept generate:cest api Setup

Next, update tests/api/SetupCest.php to match the following code.

<?php

class SetupCest {

public function testCodeceptionSetup(ApiTester $I) {

    $I->assertTrue(true);
}
}

To run the tests in the api suite, run the following command:

php vendor/bin/codecept run api

The output should be similar to that in the screenshot below.

2021-07-29-local-test-result

Configuring CircleCI

Now that you have a suite of tests, you can save yourself from manual repetitive work and enable faster, more efficient development cycles by automating your test runs with continuous integration. Below, we’ll set up our project on CircleCI to trigger our tests on every change to the application.

In your project root directory, create a folder named .circleci. Add a file called config.yml to that directory.

mkdir .circleci

touch .circleci/config.yml

In .circleci/config.yml add the following.

version: 2.1
jobs:
  build:
    docker:
      - image: cimg/php:7.4-browsers
      - image: mysql:8.0
        command: --default-authentication-plugin=mysql_native_password
        environment:
          MYSQL_ROOT_PASSWORD: secret
          MYSQL_DATABASE: auth_api_test

    steps:
      - checkout

      - run: sudo apt update
      - run: sudo apt install php7.4-mysql

      - run:
          name: install dockerize
          command: wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
          environment:
            DOCKERIZE_VERSION: v0.3.0
      - run:
          name: Wait for db
          command: dockerize -wait tcp://localhost:3306 -timeout 1m

      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "composer.json" }}
            - v1-dependencies-

      - run:
          name: "Install dependencies"
          command: composer install

      - save_cache:
          key: v1-dependencies-{{ checksum "composer.json" }}
          paths:
            - ./vendor

      - run:
          name: "Prime test database"
          command: php tests/bin/yii migrate --interactive=0

      - run:
          name: "Run tests"
          command: php vendor/bin/codecept run api

In this configuration file, you specify two Docker images to use for your build job:

  • cimg/php:7.4-browsers: This image contains PHP 7.4 and browser-related tools.
  • mysql:8.0: This image is for MySQL 8.0 and is used for your database.

The build job contains the following steps:

  • checkout: This step checks out your source code.
  • sudo apt update and sudo apt install php7.4-mysql: These steps update the package cache and install the PHP MySQL extension.
  • install dockerize: This step downloads and installs the “dockerize” tool, which is used to wait for the MySQL database to become available.
  • Wait for db: This step uses dockerize to wait for the MySQL database to become available on localhost:3306.
  • restore_cache and save_cache: These steps are for caching Composer dependencies to speed up builds.
  • Install dependencies: This step installs Composer dependencies.
  • Prime test database: This step sets up the test database using Yii migrations.
  • Run tests: This step runs tests using Codeception for the API.

Next, we need to set up a repository on GitHub and link the project to CircleCI. See this post for help: Pushing your project to GitHub.

Adding the project to CircleCI

Log into your CircleCI account. If you signed up with your GitHub account, all your repositories will be displayed on your project’s dashboard.

Next to your auth-api project click on Set Up Project.

CircleCI will detect the config.yml file within the project. Click Use Existing Config and then Start Building. Your first build process will start running and complete successfully!

Click build. You will see the job steps and the status of each job as shown in the screenshot below.

2021-07-29-workflow-build

Creating an authentication controller

Next, we will create a controller to handle authentication requests using the following command.

php yii gii/controller --controllerClass=app\\controllers\\AuthenticationController

Type yes and press Enter where prompted.

The controllerClass argument specifies the name of the controller to be created. You are expected to provide a Fully Qualified Namespaced Class.

Notice the \\ used in specifying the namespace. This is to escape the \ character.

The controller classes are stored in the controllers directory. Open controllers/AuthenticationController.php and edit the content to match the following.

<?php

namespace app\controllers;

use app\models\User;
use Yii;
use yii\web\Controller;

class AuthenticationController extends Controller {

	public $enableCsrfValidation = false;

	public function actionRegister() {

	    $request = Yii::$app->request;

	    $username = $request->post('username');
	    $password = $request->post('password');

	    if (is_null($username)) {
	        return $this->errorResponse("Required field 'username' not provided");
	    }
	    if (is_null($password)) {
	        return $this->errorResponse("Required field 'password' not provided");
	    }

	    $user = new User();
	    $user->setAttributes(
	        [
	            'username'    => $username,
	            'password'    => Yii::$app->getSecurity()->generatePasswordHash($password),
	            'accessToken' => Yii::$app->security->generateRandomString(120)
	        ]
	    );

	    $user->save();

	    Yii::$app->response->statusCode = 201;

	    return $this->asJson(
	        [
	            'message' => 'Registration completed successfully'
	        ]
	    );
	}

	private function errorResponse($message, $code = 400) {

	    Yii::$app->response->statusCode = $code;

	    return $this->asJson(['error' => $message]);
	}

	public function actionLogin() {

	    $request = Yii::$app->request;

	    $username = $request->post('username');
	    $password = $request->post('password');

	    $user = User::find()->where(['username' => $username])->one();

	    if (is_null($user)) {
	        return $this->errorResponse('Invalid login credentials provided', 401);
	    }

	    if (Yii::$app->getSecurity()->validatePassword($password, $user->password)) {
	        Yii::$app->response->statusCode = 200;

	        return $this->asJson(['token' => $user->accessToken]);

	    }
	    else {
	        return $this->errorResponse('Invalid login credentials provided', 401);
	    }

	}
}

In this controller, we define methods to handle the process of registering a user and authenticating a registered user. The authentication strategy has been simplified to allow us to focus more on the other key concepts of this tutorial.

For this article, when a valid registration request is received, an authentication token is generated, which is saved alongside the username and hashed password. This token is returned when an authentication request is made with the saved username and the correct password.

Writing tests for authentication endpoints

Create a new Cest to contain code related to testing the authentication endpoints.


php vendor/bin/codecept generate:cest api Authentication

Next, update tests/api/AuthenticationCest.php to match the following code.

<?php

use app\models\User;
use Codeception\Util\HttpCode;
use Faker\Factory;

class AuthenticationCest {

    private $faker;

    public function _before(ApiTester $I) {

        $this->faker = Factory::create();
    }

    public function registerSuccessfully(ApiTester $I) {

        $username = $this->faker->username();

        $I->sendPost(
            'register',
            [
                'username' => $username,
                'password' => $this->faker->password()
            ]
        );

        $I->seeResponseCodeIs(HttpCode::CREATED);
        $I->seeResponseContains('"message":"Registration completed successfully"');
        $I->seeRecord(
            User::class,
            [
                'username' => $username
            ]
        );
    }

    public function loginSuccessfully(ApiTester $I) {

        $username = $this->faker->username();
        $password = $this->faker->password();
        $hashedPassword = Yii::$app->getSecurity()->generatePasswordHash($password);
        $accessToken = Yii::$app->security->generateRandomString(120);

        $I->haveRecord(
            User::class,
            [
                'username'    => $username,
                'password'    => $hashedPassword,
                'accessToken' => $accessToken
            ]
        );

        $I->sendPost(
            'login',
            [
                'username' => $username,
                'password' => $password
            ]
        );

        $I->seeResponseCodeIs(HttpCode::OK);
        $I->seeResponseMatchesJsonType(
            [
                'token' => 'string:!empty',
            ]
        );

        $actualToken = $I->grabDataFromResponseByJsonPath('token')[0];
        $I->assertEquals($accessToken, $actualToken);
    }

    public function tryToRegisterWithoutUsernameAndFail(ApiTester $I) {

        $I->sendPost(
            'register',
            [
                'password' => $this->faker->password()
            ]
        );

        $I->seeResponseCodeIs(HttpCode::BAD_REQUEST);
        $I->seeResponseContains('"error":"Required field \'username\' not provided"');
    }

    public function tryToRegisterWithoutPasswordAndFail(ApiTester $I) {

        $I->sendPost(
            'register',
            [
                'username' => $this->faker->username(),
            ]
        );

        $I->seeResponseCodeIs(HttpCode::BAD_REQUEST);
        $I->seeResponseContains('"error":"Required field \'password\' not provided"');
    }

    public function tryToLoginWithInvalidPasswordAndFail(ApiTester $I) {

        $username = $this->faker->username();
        $password = $this->faker->password();
        $hashedPassword = Yii::$app->getSecurity()->generatePasswordHash($password);
        $accessToken = Yii::$app->security->generateRandomString(120);

        $I->haveRecord(
            User::class,
            [
                'username'    => $username,
                'password'    => $hashedPassword,
                'accessToken' => $accessToken
            ]
        );

        $I->sendPost(
            'login',
            [
                'username' => $username,
                'password' => "$password _invalid"
            ]
        );

        $I->seeResponseCodeIs(HttpCode::UNAUTHORIZED);
        $I->seeResponseContains('"error":"Invalid login credentials provided"');
    }

    public function tryToLoginWithInvalidUsernameAndFail(ApiTester $I) {

        $username = $this->faker->username();
        $password = $this->faker->password();
        $hashedPassword = Yii::$app->getSecurity()->generatePasswordHash($password);
        $accessToken = Yii::$app->security->generateRandomString(120);

        $I->haveRecord(
            User::class,
            [
                'username'    => $username,
                'password'    => $hashedPassword,
                'accessToken' => $accessToken
            ]
        );

        $I->sendPost(
            'login',
            [
                'username' => "$username _invalid",
                'password' => $password
            ]
        );

        $I->seeResponseCodeIs(HttpCode::UNAUTHORIZED);
        $I->seeResponseContains('"error":"Invalid login credentials provided"');
    }

    public function tryToLoginWithNonExistentUserAndFail(ApiTester $I) {

        $I->sendPost(
            'login',
            [
                'username' => $this->faker->username(),
                'password' => $this->faker->password()
            ]
        );

        $I->seeResponseCodeIs(HttpCode::UNAUTHORIZED);
        $I->seeResponseContains('"error":"Invalid login credentials provided"');
    }
}

In this Cest, we write test cases to ensure the endpoints function properly for the following scenarios:

Scenario Response Code Message
Username and password provided to the registration endpoint 201 Registration completed successfully.
Valid username and password provided to the login endpoint 200 None – access token provided
Registration request made without a username key 400 Required field username not provided.
Registration request made without a password key 400 Required field password not provided.
Login request made with an invalid password 401 Invalid login credentials provided.
Login request made with an invalid username 401 Invalid login credentials provided.
Login request made for a user not in the database 401 Invalid login credentials provided.

Run the tests again using the following command

php vendor/bin/codecept run api

The output should be similar to that in the screenshot below.

2021-07-29-auth-test-result

Update your central repository using the following commands:

git add .

git commit -m "Endpoint and tests for authentication"

git push origin main

Head back to your CircleCI dashboard to see a new build running.

Conclusion

In this article, we built an API to handle user authentication using the Yii2 framework. We used Codeception, a PHP testing framework that makes writing test cases easier by providing high-level assertion functions and an easy to understand naming convention, to set up a robust test suite for our API. Finally, we implemented a CI pipeline using CircleCI to streamline our development cycles, generate fast feedback from our test suite, and make it easier for multiple contributors to maintain our project.

By writing tests to ensure the code meets specification and using CircleCI to manage the build and integration process, it is possible for large teams to not only work on the same project but also develop and release new features efficiently.

The entire codebase for this tutorial is available on GitHub. Feel free to make additional changes to the project and validate them with your CircleCI pipeline. Happy coding!


Oluyemi is a tech enthusiast with a background in Telecommunication Engineering. With a keen interest in solving day-to-day problems encountered by users, he ventured into programming and has since directed his problem solving skills at building software for both web and mobile. A full stack software engineer with a passion for sharing knowledge, Oluyemi has published a good number of technical articles and blog posts on several blogs around the world. Being tech savvy, his hobbies include trying out new programming languages and frameworks.

Read more posts by Olususi Oluyemi