This post complements to these ones:
We have been developing an app in raw php since almost 3 years now, and since around a few months it is starting to have some testing. As you can imagine it was pretty though pushing to staging environment, test by hand and afterwards to prod, countless times I've broken something without even realizing. None realized. We wanted some kind of testing but the app was not really suitable to, and we did not have much time, so I kept telling myself eventually we will have tests. And now we have some and I am going to explain the whole process.
Just to get you in the proper context, our app may send requests to third party services and handles database connections. It has REST and SOAP implementations, and we want to test it all.
Deciding technology
My very first search in google was e2e php, and we got codeception, which does not seem bad at all, it has some REST API testing, which has almost everything we needed, and handling dumps for fixtures! So far so good at first sight.
We started writing some dummy tests, but then we realized, looking at this example in their website:
class CreateUserCest
{
// tests
public function createUserViaAPI(\ApiTester $I)
{
$I->amHttpAuthenticated('service_user', '123456');
$I->haveHttpHeader('Content-Type', 'application/x-www-form-urlencoded');
$I->sendPOST('/users', [
'name' => 'davert',
'email' => 'davert@codeception.com'
]);
$I->seeResponseCodeIs(\Codeception\Util\HttpCode::OK); // 200
$I->seeResponseIsJson();
$I->seeResponseContains('{"result":"ok"}');
}
}
It is making a post request, so then, there is no way to mock anything, as soon as the index script is being executed, all mock we could have done it becomes useless since it is running in other proccess, but there must be a way!
We thought about PHP's built-in web server, but we have the same issue then. We can't mock something in our tests and then run php. We must avoid having to execute any script.
Reading this top, you can check PHPUnit is in the first position, but PHPUnit, as its name says, it is ment for unit testing, not for e2e... But then we wondered, why don't we "mock" index.php and from there we call the function called from index.php? That way we run in the same context so we can mock things.
From there, the choice was made, PHPUnit was staying with us and we were willing to give it a try.
Chosen tools
PHP-DI for handling dependencies.
Pixie for handling database connections. It is just a query builder, not to use a PDO because it would slow things down. If you use any other it is completely fine, for sure it offers the same and more things than pixie.
PHPUnit as one and only tool to test with.
Slim for handling requests, paths and responses.
As you see we just have 4 tools involved in testing, everything is going to be handmade.
Writing our first fake e2e test
But why fake e2e test? Just because we are not actually sending any request, we are going to mock index.php and run from there.
Imagine we have something like this as index.php:
<?php
$rootDir = dirname(__DIR__);
require_once "{$rootDir}/src/autoload.php";
require_once "{$rootDir}/src/di.php";
use Modules\Gym\Controllers\GymController;
use Modules\GymTrainers\Controllers\GymTrainersController;
use Modules\Trainer\Controllers\TrainerController;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
$app = AppFactory::create();
$trainerController = $container->get(TrainerController::class);
$gymController = $container->get(GymController::class);
$gymTrainersController = $container->get(GymTrainersController::class);
$app->get('/trainer', fn(Request $request, Response $response, array $args) => $response->getBody()->write($trainerController->getTrainer($request)));
$app->post('/trainer', fn(Request $request, Response $response, array $args) => $response->getBody()->write($trainerController->createTrainer($request)));
$app->get('/gym', fn(Request $request, Response $response, array $args) => $response->getBody()->write($gymController->getGym($request)));
$app->post('/gym', fn(Request $request, Response $response, array $args) => $response->getBody()->write($gymController->createGym($request)));
$app->post('/assign', fn(Request $request, Response $response, array $args) => $response->getBody()->write($gymTrainersController->assign($request)));
$app->run();
What we want to do here is to execute either any method of any controller we have, we skip Request
or Response
methods because we assume they work properly, and we are not supposed to test a third party library.
Being this clear let's have a look at our first e2e test, for getGym
method:
class GetGymTest extends TestCase
{
public static function setUpBeforeClass(): void
{
$fixtures = include(__DIR__ . '/GymFixtures.php');
FixtureLoader::preSuite($fixtures, 'Gym');
}
private Container $container;
private array $request;
public function setUp(): void
{
$containerBuilder = new ContainerBuilder();
$commonDefinitions = include(__DIR__ . '/../../src/diDefinitions.php');
$containerBuilder->addDefinitions(array_merge(
Config::getConfig(),
$commonDefinitions,
[
DatabaseService::class => FixtureLoader::$databaseService,
]));
$this->container = $containerBuilder->build();
FixtureLoader::load(null);
FixtureLoader::$modelsModified = [];
FixtureLoader::postLoad();
$this->request = [
'id' => 1,
];
}
public function tearDown(): void
{
FixtureLoader::reload();
}
public function testAnswersProperly()
{
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getQueryParams')->willReturn($this->request);
$controller = $this->container->get(GymController::class);
$response = json_decode($controller->getGym($request), true);
$this->assertEquals(1, $response['gym']['id']);
$this->assertEquals('https://forgev.com', $response['gym']['endpoint']);
}
public function testFailsWhenSendingUnknownGymId()
{
$this->request['id'] = 31232131;
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getQueryParams')->willReturn($this->request);
$controller = $this->container->get(GymController::class);
$response = json_decode($controller->getGym($request), true);
$this->assertArrayHasKey('error', $response);
}
public static function tearDownAfterClass(): void
{
FixtureLoader::$databaseService->close();
}
As you see we are using a class named FixtureLoader
, which is explained here, but as a summary it just manages the database connection and reloading necessary fixtures after each test.
In setUp
method we define all definitions for our dependency container and we mock our database service to get database service from fixture loader, we need to keep track of running queries, so we know which tables have been altered.
Having said that, we are pretty much done with our first e2e, we just need to look at any of these 2 tests, for example testAnswersProperly
:
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getQueryParams')->willReturn($this->request);
We need our request to be a ServerRequestInterface
instance as defined in getGym
signature:
public function getGym(ServerRequestInterface $request) {}
And later on we just execute the method, just like we would have called it from our index.php:
$controller->getGym($request);
As you saw this kind of tests are relatively easy, as we do not need to mock anything but a request. Now let's imagine you need to mock a third party service, how would it look like:
public function testAnswersProperly()
{
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getBody')->willReturn($this->request);
$httpServiceMock = $this->createStub(HttpService::class);
$httpServiceMock->method('curlPost')->willReturn(['room' => 1]);
$this->container->set(HttpService::class, $httpServiceMock);
$controller = $this->container->get(GymTrainersController::class);
$response = json_decode($controller->assign($request), true);
$this->assertIsNumeric($response['room']);
}
This test is for assigning a trainer to a gym (or the other way around, as you want to think about it). And for that we need to check gym's availability, and they will answer us which room we need to use.
So we create a Stub of our HttpService
and we mock curlPost
method to return an array containing a room id, just like the request went fine.
And we set it in our container:
$this->container->set(HttpService::class, $httpServiceMock);
That step is really important because if we forget to do it it's like we have done nothing, we created our stub but we are not overriding it in our dependency container. And then it is pretty much like we have done previously, get the controller instance, execute the method we want to test and check the request.
You have more examples of these kind of tests in our repository.
Top comments (0)