Testing
New FloCMS projects come with PHPUnit and a set of tests, so you can check your site after every change and before every deployment.
Running the tests
composer test
composer test runs PHPUnit. To run part of the suite:
vendor/bin/phpunit --testsuite unit
vendor/bin/phpunit --testsuite feature
vendor/bin/phpunit tests/Feature/CsrfTest.php
vendor/bin/phpunit --filter testHealthRoute
phpunit.xml.dist defines the two suites:
| Folder | Suite | For |
|---|---|---|
tests/Unit/ |
unit |
One class at a time, no web server, no database |
tests/Feature/ |
feature |
Whole requests and commands, like a visitor or an admin would make them |
Tests use the namespace HostKurd\Flocms\Tests\, mapped to tests/ in composer.json's autoload-dev.
Creating a test
php flo make:test PriceCalculator # tests/Unit/PriceCalculatorTest.php
php flo make:test Contact --feature # tests/Feature/ContactTest.php
A unit test checks one piece of code directly:
<?php
declare(strict_types=1);
namespace HostKurd\Flocms\Tests\Unit;
use FloCMS\Core\ValidationException;
use FloCMS\Core\Validator;
use PHPUnit\Framework\TestCase;
final class ContactRulesTest extends TestCase
{
public function testAnEmptyMessageIsRejected(): void
{
$this->expectException(ValidationException::class);
Validator::validate(['message' => ''], ['message' => 'required|string']);
}
}
Testing pages
The skeleton's feature tests start a throw-away copy of the project on PHP's built-in web server, with a fresh .env made from .env.example, and send real HTTP requests to it. The helper is tests/Support/AppServer.php:
<?php
declare(strict_types=1);
namespace HostKurd\Flocms\Tests\Feature;
use HostKurd\Flocms\Tests\Support\AppServer;
use PHPUnit\Framework\TestCase;
final class BlogTest extends TestCase
{
private static ?AppServer $server = null;
public static function setUpBeforeClass(): void
{
self::$server = AppServer::start(['APP_DEBUG' => 'false']);
}
public static function tearDownAfterClass(): void
{
self::$server?->stop();
self::$server = null;
}
public function testTheBlogPageLoads(): void
{
$response = self::$server->get('/blog');
self::assertSame(200, $response['status'], $response['body']);
self::assertStringContainsString('<h1>Blog</h1>', $response['body']);
}
public function testAnUnknownPageIs404(): void
{
self::assertSame(404, self::$server->get('/no-such-page')['status']);
}
}
| Method | Does |
|---|---|
AppServer::start($env, $prepare) |
Copies the project, applies $env on top of .env, and starts the server. $prepare receives the copy's folder before it starts, to add or change files. |
get($path) |
A GET request |
request($method, $path, $data, $headers) |
Any request; an array $data is sent as form fields, a string as the raw body |
json($method, $path, $data, $headers) |
A request with a JSON body |
flo($arguments, $env, $stdin) |
Runs php flo in the copy and returns exit and output |
forgetCookies() |
Starts a new session |
root(), stop() |
The copy's folder, and stopping the server |
Responses are arrays with status, headers (lowercase names) and body. Cookies are kept between requests, so a test can log in and then open admin pages. The skeleton's CsrfTest and AdminSessionTest show how.
Note
AppServer links vendor/ into the copy and sends the server's output to /dev/null, so the feature tests run on Linux and macOS. On Windows, run vendor/bin/phpunit --testsuite unit.
Tests with a database
Tests that need MySQL or MariaDB are skipped unless you point them at a test server:
FLO_TEST_MYSQL_HOST=127.0.0.1 FLO_TEST_MYSQL_USER=root FLO_TEST_MYSQL_PASS=secret composer test
| Variable | Default |
|---|---|
FLO_TEST_MYSQL_HOST |
none: the tests are skipped |
FLO_TEST_MYSQL_PORT |
3306 |
FLO_TEST_MYSQL_USER |
root |
FLO_TEST_MYSQL_PASS |
empty |
FLO_TEST_MYSQL_NAME |
flocms_test |
Use a separate database for tests; tests empty and fill tables. In your own tests, call MySqlConfig::require() first: it skips the test when no server is configured, and returns the connection details. TestDatabase::connect(), resetUsers() and addUser() set up users quickly.
For tests that only need the migrations, an SQLite file works too, because php flo supports it:
$env = ['DB_TYPE' => 'sqlite', 'DB_NAME' => 'storage/site.sqlite'];
$server = AppServer::start($env);
$server->flo(['migrate'], $env);
The skeleton's FirstAdminTest uses this. Remember that your pages always connect to MySQL or MariaDB, so page tests that read data need the MySQL settings.
Testing commands and the API
- Commands:
ApplicationTesterrunsphp flocommands inside PHPUnit and feeds answers to their questions. See testing commands. - API:
TestClientsends requests straight to the API kernel, without a web server. See testing your API.
Testing services and modules
Use a fresh container in each test and bind fake implementations before resolving services. For API tests, pass that container to the kernel, then assert resource data and metadata through TestClient.
Module discovery/manifest tests can run without a database: create a temporary module root, then construct ModuleDiscovery with its ModulePaths. To test installed/enabled states, supply a fake ModuleStateRepositoryInterface with the states your test needs. Use ModuleSystem::reset() and PageBlockRegistry::clear() between tests that use those static services. The real bundled module migration runner needs a separate MySQL/MariaDB test database; see module migrations.
Test both enabled and disabled module routes. Pass the manager to the API kernel, since loading or tagging a route alone is not its runtime availability check. Private-download tests should check another user's attachment is rejected, and chunk tests should check incorrect offsets and mismatched owners. See storage and uploads.
Continuous integration
The skeleton includes .github/workflows/tests.yml for GitHub Actions. On pushes to main and on pull requests, it:
- validates
composer.jsonandcomposer.lock, and checks every PHP file for syntax errors - runs the whole suite with PHP 8.1, 8.2, 8.3 and 8.4 on Linux, and the unit tests on Windows
- runs the database tests against MariaDB 10.11 and MySQL 8.0
Keep it in your site's repository, so every change is tested before you deploy it.