-
-
Notifications
You must be signed in to change notification settings - Fork 6
Writing Tests
Some sort of test concept is needed to have a clear idea of what should be tested and how.
Depending on the requirements and the complexity of the project, the test concept should be more or less detailed.
If there are no clear directives that say otherwise, I find that the easiest and most
forward way is to make a bullet
point list of exactly what should be tested for each page and the expected behaviour.
If use-cases have similar testing requirements and expected behaviour,
they can be grouped together.
Instead of putting a lot of effort in trying to think of all possible cases in advance, I would write down the ones that come to mind and then, while implementing add the new ones that may arise.
An example of such a list for this project can be found in the testing cheatsheet.
The tests are written with the PHPUnit testing framework which is the de-facto standard for unit testing in PHP.
For mocking, database handling, http requests, email assertions and more, the helper traits
selective/test-traits
are used.
composer require --dev phpunit/phpunit selective/test-traits
The phpunit.xml
file in the root directory contains the configuration for PHPUnit.
See here.
The environment configuration
with test-specific values is located
in config/env.test.php
:
// Enable ErrorException for notices and warnings
$settings['error']['display_error_details'] = true;
// ...
Before the tests that involve the database are run,
an empty test database must be created and the name
added to the env.test.php
file:
// ...
// Database for integration testing must include the word "test"
$settings['db']['database'] = 'slim_example_project_test';
The tables are created during the first setUp()
call when running the test suite.
Once they're created, they are truncated for each further test.
Tables are created with the schema.sql
file which is generated to reflect the current database
by the
SqlSchemaGenerator
via the specific composer command
composer schema:generate
or when the migration files are generated.
Before each test function, the application must be
bootstrapped
and the database cleared if required.
This is done with the setUp()
function which is called automatically by PHPUnit before each
test function. The setup logic is the same for all tests, which means it can be extracted into a
trait. The same goes for the tearDown()
function which is called after each test function.
The AppTestTrait
located in tests/Traits
contains the setUp()
and tearDown()
functions.
The setUp()
function below also sets the session to a memory session and inserts
the user roles into the database by default.
File: tests/Traits/AppTestTrait.php
<?php
namespace App\Test\Traits;
use App\Test\Fixture\UserRoleFixture;
use Cake\Database\Connection;
use DI\Container;
use Odan\Session\MemorySession;
use Odan\Session\SessionInterface;
use Psr\Container\ContainerInterface;
use Slim\App;
use UnexpectedValueException;
trait AppTestTrait
{
protected ContainerInterface $container;
protected App $app;
protected function setUp(): void
{
// Start slim app
$this->app = require __DIR__ . '/../../config/bootstrap.php';
// Set $this->container to container instance
$container = $this->app->getContainer();
if ($container === null) {
throw new UnexpectedValueException('Container must be initialized');
}
$this->container = $container;
// Set memory sessions
$this->container->set(SessionInterface::class, new MemorySession());
// If setUp() is called in a testClass that uses DatabaseTestTrait, the method setUpDatabase() exists
if (method_exists($this, 'setUpDatabase')) {
// Check that database name from config contains the word "test"
// This is a double security check to prevent unwanted use of dev db for testing
if (!str_contains($container->get('settings')['db']['database'], 'test')) {
throw new UnexpectedValueException('Test database name MUST contain the word "test"');
}
// Create tables
$this->setUpDatabase($container->get('settings')['root_dir'] . '/resources/schema/schema.sql');
if (method_exists($this, 'insertFixtures')) {
// Automatically insert user roles
$this->insertFixtures([UserRoleFixture::class]);
}
}
}
protected function tearDown(): void
{
if (method_exists($this, 'setUpDatabase')) {
$connection = $this->container->get(Connection::class);
$connection->rollback();
$connection->getDriver()->disconnect();
if ($this->container instanceof Container) {
$this->container->set(Connection::class, null);
$this->container->set(\PDO::class, null);
}
}
}
}
In each test class, the AppTestTrait
is included with the use
keyword
at the top of the class.
// ...
class ExampleTest extends TestCase
{
use AppTestTrait;
// ...
}
Unit tests are located in the tests/Unit
directory.
To test individual units of code (e.g. functions, classes, modules) in isolation, the parts
around the tested unit are replaced by test doubles (mocks, stubs, etc.) with predefined
return values. They are fake objects that need to be configured to contain specific
methods and return values.
Mocks, unlike stubs, can be programmed to expect specific method calls and parameters to
verify that the tested unit interacts with the mock as expected.
The MockTestTrait.php
provides the mock()
function which returns a mock object of the
given class and automatically adds it to the
container.
Now instead of the real class, the mock object is injected and used by tested class.
The detailed documentation on how the stubs and mock can be created and configured can be found here.
Here is a list of some of the frequently used functions:
-
method($name)
- Sets the name of the mocked method -
willReturn($value)
- Sets the return value of the mocked method -
willReturnOnConsecutiveCalls($value1, $value2, ...)
- Sets the return value of the mocked method to the given values in the given order -
with($value)
- Sets the expected parameter value of the mocked method -
expects($count)
- Sets the expected number of times the mocked method is called.$count
can be one of the following:-
never()
- The mocked method is expected to be called never -
once()
- The mocked method is expected to be called once -
exactly($count)
- The mocked method is expected to be called exactly (int)$count
times
-
Assuming exampleFunction()
is the function to test in the ExampleClass
class and
its logic is that it retrieves a string from the database with the PDO query
function
and returns this string after transforming it to uppercase. The test could look like this:
File: tests/Unit/ExampleClassTest.php
<?php
namespace App\Test\Unit;
use App\Test\Traits\AppTestTrait;
use Selective\TestTrait\Traits\MockTestTrait;
use PHPUnit\Framework\TestCase;
class ExampleClassTest extends TestCase
{
use AppTestTrait;
use MockTestTrait;
public function testExample(): void
{
// Mock the PDO class and add it to the container
$pdoMock = $this->mock(\PDO::class);
// Configure the mock to return "hello world" when the query() function is called
// and expect the function to be called once
$pdoMock->method('query')
->willReturn('hello world')
->expects(self::once());
// Get the real instance of the class to test
$exampleClass = $this->container->get(ExampleClass::class);
// Call the function to test
$result = $exampleClass->exampleFunction();
// Assert that the result is the expected value
$this->assertSame('HELLO WORLD', $result);
}
}
The folder tests/Integration
contains the integration test cases.
To test the overall behavior of the application, an HTTP request to a route is made with a specific request method and request body that will traverse all the layers of the application.
Requests can be created with HttpTestTrait.php
and HttpJsonTestTrait.php
. They provide
the following functions:
-
createRequest()
- Creates a request object and accepts the parameters$method
,$uri
and$serverParams
-
createFormRequest()
- Creates a request object, adds the form data to the request body and sets theContent-Type
header toapplication/x-www-form-urlencoded
-
HttpJsonTestTrait
:createJsonRequest()
- Creates a request object, adds the JSON data to the request body and sets theContent-Type
header toapplication/json
.
Note: theHttpTestTrait
must be included as well to use this function.
The functions above expect the $uri
which is the full url to the route.
To reference routes by their name, the urlFor()
function from the RouteTestTrait
can be used.
To make the request and get the response, $this->app->handle()
is called with the request as argument.
$app
is the instance of the application that is bootstrapped in the AppTestTrait
.
// ...
class TestActionTest extends TestCase
{
use AppTestTrait;
use HttpTestTrait;
use RouteTestTrait;
// ...
public function testAction(): void
{
$request = $this->createRequest('GET', $this->urlFor('routeName'))
$response = $this->app->handle($request);
// ...
}
}
After the request is made, the response can be tested with assertions.
The status code can be retrieved with $response->getStatusCode()
which can be
verified with assertSame
and the expected code.
self::assertSame(200, $response->getStatusCode());
The response header can be accessed with $response->getHeaderLine($headerName)
and then
compared with an expected value.
// Assert redirection to login page
self::assertSame($this->urlFor('login-page'), $response->getHeaderLine('Location'));
To assert that the response body contains a specific string, the assertResponseContains()
function of the HttpTestTrait
can be used.
$this->assertResponseContains('Hello World', (string)$response->getBody());
To verify that the returned JSON data is an exact match to an expected array,
the HttpJsonTestTrait
provides the assertJsonData()
function.
$this->assertJsonData(['key' => 'value'], $response);
For more advanced assertions, the JSON data from the response can be accessed
as an array with $this->getJsonData($response)
.
The HttpJsonTestTrait
also provides a function assertJsonContentType
to assert
the response content type header.
To test a use-case under different conditions, the same test logic can be run with different data. Each run comes with a different set of data and possibly a different expected result.
Data providers are static functions in a "Provider" class that return an array of arrays with the different data that should be used for the different iterations of the test function.
The @dataProvider
annotation can be used to specify the function that will
provide the data in the parameters of the test function.
File: tests/Provider/Example/ExampleProvider.php
<?php
namespace App\Test\Provider\Example;
class ExampleProvider
{
public static function provideExampleData(): array
{
return [
// Provides 0 in the first parameter and 1 in the second
[0, 1],
// The data sets can be named with string keys for a more verbose output as
// it will contain the name of the dataset that breaks a test
'one' => [1, 2],
// The array values can also have string keys, but they don't affect
// the test function parameters
'two' => ['input' => 2, 'expected' => 3],
];
}
}
The following test function will be run 3 times.
In the first iteration, the $input
parameter will be 0
and $expected
1
,
in the second 1
and 2
and in the third 2
and 3
.
File: tests/Integration/Example/ExampleTest.php
<?php
namespace App\Test\Integration\Example;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* @dataProvider \App\Test\Provider\Example\ExampleProvider::provideExampleData
*/
public function testExample(int $input, int $expected): void
{
$this->assertSame($expected, $input + 1);
}
}
For a lot of requests, pre-existing data is required in the database.
For example, to test the modification of a resource, it has to exist prior to making the update request.
An authenticated user is also required to get past the authentication middleware.
Fixtures are classes that hold example data that can be inserted into the database.
Each table has its own fixture class that implements the FixtureInterface
.
The fixtures are located in the tests/Fixture
directory.
Each fixture has a property $table
with the table name and an array $records
with the
default data to insert as well as getters for both properties.
File: tests/Fixture/ExampleFixture.php
<?php
namespace App\Test\Fixture;
class ExampleFixture implements FixtureInterface
{
// Table name
public string $table = 'example';
// Database records
public array $records = [
[
'id' => 1,
'field_1' => 'value_1',
'field_2' => 'value_2',
],
[
'id' => 2,
'field_1' => 'value_1',
'field_2' => 'value_2',
],
];
public function getTable(): string
{
return $this->table;
}
public function getRecords(): array
{
return $this->records;
}
}
Different use cases require different data.
To define custom data to be inserted along with the default data of the fixture, the
FixtureTestTrait
provides the insertFixturesWithAttributes()
function.
Parameters are the instance of the fixture class and optionally an array of attributes.
The attribute array can either contain fields and values to insert for one row
(e.g. ['field_name' => 'expected_value', ]
) or an array of such arrays
(e.g. [['field_name' => 'expected_value'], ['field_name' => 'expected_value'], ]
)
which will insert multiple rows.
Not all fields of the table need to be specified. The default values of the fixture will be used for the unspecified fields.
The function returns an array with the inserted data including the auto-incremented id or a 2-dimensional array if more than one row was inserted.
// ...
use FixtureTestTrait;
public function testAction(): void
{
// Insert the fixture with the default values
$rowData = $this->insertFixtureWithAttributes(new ExampleFixture());
// Insert the fixture with the given attributes
$rowData = $this->insertFixtureWithAttributes(new ExampleFixture(), ['field_1' => 'value_1', ]);
// Insert 2 rows with the given attributes
$rowsData = $this->insertFixtureWithAttributes(
new ExampleFixture(), [['field_1' => 'value_1'], ['field_1' => 'value_2']]
);
// ...
}
}
The FixtureTestTrait
uses the DatabaseTestTrait
for the interaction with the database.
User roles are inserted by default in AppTestTrait
if the DatabaseTestTrait
is used in a test class.
The ids of the user roles are not known in the data providers or test functions.
Instead of hard coding them, the AuthorizationTestTrait
allows the user_role_id
to
be referenced as an Enum case and converted to its id in the test function with
one of the following functions:
-
getUserRoleId(UserRole $userRole)
- returns the id of the given user role enum case. -
addUserRoleId(array $userAttr)
- converts user role enum to the id. It accepts an array of attributes and replaces theuser_role_id
value if it's an enum case with the corresponding id and returns the same array. -
insertUserFixturesWithAttributes(array &$authenticatedUserAttr, ?array &$userAttr)
- inserts up to two user fixtures with the given attributes replacing theuser_role_id
value if it's an enum case with the corresponding id.
This function is most useful when testing authorization cases that require an authenticated user
and another user, e.g. that is linked to the ressource (owner).
It might be the authenticated user itself or another one.
If the authenticated user attributes are the same as the other user, only one user is inserted.
For cleaner code, the function accepts the user attribute parameters as
&
references which means that the
original variable from the calling function is modified without them having to be returned.
The function insertUserFixturesWithAttributes()
replaces the user attributes from the
parameters with the inserted user row data.
// ...
use DatabaseTestTrait;
use FixtureTestTrait;
use AuthorizationTestTrait;
/**
* @dataProvider \App\Test\Provider\Example\ExampleProvider::provideExampleData
*
* @param array $userLinkedToResourceRow e.g. ['user_role_id' => UserRole::ADMIN]
* @param array $authenticatedUserRow e.g. ['user_role_id' => UserRole::ADVISOR]
* @return void
*/
public function testAction(array $userLinkedToResourceRow, array $authenticatedUserRow): void
{
// Insert authenticated user and user linked to resource with given attributes containing the user role
$this->insertUserFixturesWithAttributes($userLinkedToResourceRow, $authenticatedUserRow);
// $userLinkedToResourceRow and $authenticatedUserRow now contain the inserted user data
// including the auto-incremented id
$authenticatedUserId = $authenticatedUserRow['id'];
// ...
}
}
To verify that the data in the database is changed or inserted as expected after a request,
the DatabaseTestTrait
from the
test-traits
package provides practical functions to assert the database content.
-
assertTableRow()
- Asserts that a row in the database contains the expected values -
assertTableRowEquals()
- Asserts that a row in the database contains the expected values without type checking -
assertTableRowValue()
- Asserts that a specific field of a row has the expected value -
assertTableRowCount()
- Asserts that a table has a specific number of rows -
getTableRowCount()
- Returns the number of rows in a table -
getTableRowById()
- Returns the row with the given id from the given table or throws an exception if it does not exist -
findTableRowById()
- Returns the row with the given id from the given table or an empty array if it does not exist
To assert that a row in the database contains the expected values, the function
assertTableRow()
or assertTableRowEquals()
can be used.
The first parameter is the array of expected fields and values, the second one
is the table name and the third the id of the row to check.
With the code below, the function asserts that the row with id 1 in the table example
has the
value value_1
in the field_1
and 42
in the field_2
without considering the other fields.
// Only passes if the values in the database have the same type as the expected values
$this->assertTableRow(['field_1' => 'value_1', 'field_2' => 42], 'example', 1);
// Type of the value is not considered
$this->assertTableRowEquals(['field_1' => 'value_1', 'field_2' => '42'], 'example', 1);
The DatabaseExtensionTestTrait
provides an additional set of functions to further
retrieve and assert data from the database.
It contains the following functions:
-
findTableRowsByColumn()
- Returns an array of rows from a table where the specified column has the given value -
findTableRowsWhere()
- Returns an array of rows from the given table with a custom where clause -
findLastInsertedTableRow()
- Returns the last inserted row from the given table -
assertTableRowsByColumn()
- Asserts that the rows have the expected values where the given column has a certain value
Slim app basics
- Composer
- Web Server config and Bootstrapping
- Dependency Injection
- Configuration
- Routing
- Middleware
- Architecture
- Single Responsibility Principle
- Action
- Domain
- Repository and Query Builder
Features
- Logging
- Validation
- Session and Flash
- Authentication
- Authorization
- Translations
- Mailing
- Console commands
- Database migrations
- Error handling
- Security
- API endpoint
- GitHub Actions
- Scrutinizer
- Coding standards fixer
- PHPStan static code analysis
Testing
Frontend
Other