Continuous integration for Yii2 APIs with Codeception
Fullstack Developer and Tech Author
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.
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:
- Username
- Password
- 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.
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.
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
andsudo 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
andsave_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.
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.
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!