How to write unit tests - Liturgical-Calendar/LiturgicalCalendarAPI GitHub Wiki
The LiturgicalCalendar API uses PHPUnit 12 for testing. All tests live in the phpunit_tests/
directory and are automatically discovered by PHPUnit via the phpunit.xml.dist configuration.
Test directory structure
phpunit_tests/
├── ApiTestCase.php # Abstract base class for integration tests
├── bootstrap.php # Autoloader and environment setup
├── Enum/
│ └── LitMassVariousNeedsTest.php
├── Http/
│ └── NegotiatorTest.php
├── JsonFormatterTest.php
├── LocaleDateFormatterTest.php
├── Methods/
│ └── RouterTest.php
├── Routes/
│ ├── Auth/
│ │ └── LoginRateLimitTest.php
│ ├── Readonly/
│ │ ├── CalendarTest.php
│ │ ├── CalendarsTest.php
│ │ ├── EasterTest.php
│ │ ├── EventsTest.php
│ │ ├── SchemasTest.php
│ │ └── TemporaleTest.php
│ └── ReadWrite/
│ ├── RegionalDataTest.php
│ └── TemporaleTest.php
├── Schemas/
│ ├── PayloadValidationTest.php
│ └── SchemaValidationTest.php
├── Services/
│ └── RateLimiterTest.php
└── fixtures/
└── payloads/ # JSON fixture files for schema tests
├── valid_diocesan_calendar.json
├── valid_national_calendar.json
├── valid_wider_region_calendar.json
├── invalid_litcal_wrapped.json
└── ...
Running tests
# Run all tests
composer test
# Run all tests except slow/integration tests
composer test:quick
# Run a single test file
vendor/bin/phpunit phpunit_tests/Services/RateLimiterTest.php
The test script runs phpunit --testdox --display-warnings. The test:quick script additionally passes
--exclude-group slow to skip tests marked with the #[Group('slow')] attribute.
Two types of tests
The project distinguishes between unit tests and integration tests based on which base class they extend.
Unit tests (extend TestCase)
Unit tests do not require a running API server. They test classes and functions in isolation. Extend the standard
PHPUnit TestCase:
<?php
declare(strict_types=1);
namespace LiturgicalCalendar\Api\Tests\Services;
use PHPUnit\Framework\TestCase;
class RateLimiterTest extends TestCase
{
private RateLimiter $rateLimiter;
protected function setUp(): void
{
parent::setUp();
$this->rateLimiter = new RateLimiter(3, 60, sys_get_temp_dir());
}
public function testNewIdentifierIsNotRateLimited(): void
{
$this->assertFalse($this->rateLimiter->isRateLimited('192.168.1.1'));
}
}
When to use: Testing services, formatters, enums, utilities, content negotiation, schema validation against fixture files, or any logic that does not require HTTP requests to the API.
Integration tests (extend ApiTestCase)
Integration tests make real HTTP requests to a running instance of the API. Extend the abstract ApiTestCase class,
which provides:
- A shared Guzzle HTTP client (
self::$http) configured for HTTP/2 with connection pooling - Automatic IPv4/IPv6 detection and binding
- API availability checking (tests fail with a clear message if the server is not running)
- Authentication helpers:
getJwtToken()andauthHeaders() - Database configuration checking via
isDatabaseConfigured()
<?php
declare(strict_types=1);
namespace LiturgicalCalendar\Tests\Routes\Readonly;
use LiturgicalCalendar\Tests\ApiTestCase;
final class CalendarTest extends ApiTestCase
{
public function testGetCalendarReturnsJson(): void
{
$response = self::$http->get('/calendar', []);
$this->assertSame(200, $response->getStatusCode());
$this->assertStringStartsWith(
'application/json',
$response->getHeaderLine('Content-Type')
);
$data = json_decode((string) $response->getBody());
$this->assertSame(JSON_ERROR_NONE, json_last_error());
$this->assertObjectHasProperty('litcal', $data);
}
}
When to use: Testing API routes, content negotiation over HTTP, authentication flows, rate limiting behavior, or any scenario that exercises the full request/response cycle.
Prerequisite: Start the API server before running integration tests:
composer start
Conventions and patterns
File and class naming
- Test files must end in
*Test.php(PHPUnit convention) - Place tests in a subdirectory that mirrors the source code structure:
Routes/Readonly/for GET-only endpoint testsRoutes/ReadWrite/for endpoints that also support PUT/PATCH/DELETERoutes/Auth/for authentication-related testsSchemas/for JSON schema validation testsServices/for service-layer unit testsEnum/,Http/,Methods/for other categories
- Use
final classfor concrete test classes - Use
declare(strict_types=1)at the top of every test file
Namespace conventions
- Unit tests (extending
TestCase): use the namespaceLiturgicalCalendar\Api\Tests\{Subdirectory} - Integration tests (extending
ApiTestCase): use the namespaceLiturgicalCalendar\Tests\{Subdirectory}
PHPUnit attributes
Use PHP 8 attributes (not annotations) for PHPUnit metadata:
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
#[Group('slow')]
public function testGetCalendarSampleAllCalendars(): void { /* ... */ }
#[DataProvider('validDiocesanPayloadProvider')]
public function testValidDiocesanPayloadPassesSchemaValidation(string $fixtureFile): void { /* ... */ }
Marking slow tests
Tests that take a long time (e.g., testing all calendars across many years) should be marked with
#[Group('slow')] so they can be excluded with composer test:quick:
#[Group('slow')]
public function testGetCalendarSampleAllCalendars(): void
{
// This test iterates over all national and diocesan calendars for years 1970-2050
}
Data providers
Use static data provider methods with the #[DataProvider] attribute to parameterize tests:
public static function validDiocesanPayloadProvider(): array
{
return [
'valid diocesan calendar' => ['valid_diocesan_calendar.json'],
'valid diocesan multi-locale' => ['valid_diocesan_multi_locale.json'],
];
}
#[DataProvider('validDiocesanPayloadProvider')]
public function testValidDiocesanPayloadPassesSchemaValidation(string $fixtureFile): void
{
$payload = self::loadFixture($fixtureFile);
$schema = Schema::import(LitSchema::DIOCESAN->path());
$schema->in($payload);
$this->addToAssertionCount(1);
}
Fixture files
JSON fixtures for schema validation tests are stored in phpunit_tests/fixtures/payloads/. Follow this naming
convention:
valid_*.jsonfor payloads that should pass schema validationinvalid_*.jsonfor payloads that should fail schema validation
Load fixtures with a helper method:
private const FIXTURES_PATH = __DIR__ . '/../fixtures/payloads';
private static function loadFixture(string $filename): \stdClass
{
$path = self::FIXTURES_PATH . '/' . $filename;
$content = file_get_contents($path);
return json_decode($content);
}
Concurrent HTTP requests in integration tests
For tests that need to make many HTTP requests (e.g., testing all calendar endpoints), use Guzzle's EachPromise
for concurrent execution:
use GuzzleHttp\Promise\EachPromise;
use Psr\Http\Message\ResponseInterface;
$requests = [
['uri' => '/calendar/nation/US/2024'],
['uri' => '/calendar/nation/IT/2024'],
// ...
];
$each = new EachPromise(
(function () use ($requests) {
foreach ($requests as $idx => $request) {
yield self::$http->getAsync($request['uri'], ['http_errors' => false])
->then(function (ResponseInterface $response) use ($idx, &$responses) {
$responses[$idx] = $response;
});
}
})(),
['concurrency' => 6]
);
$each->promise()->wait();
Testing authenticated endpoints
For endpoints that require authorization (PUT/PATCH/DELETE on /data, /tests, /temporale), use the helpers
provided by ApiTestCase:
public function testProtectedEndpointRequiresAuth(): void
{
// Verify database is configured (needed for role-based authorization)
if (!self::isDatabaseConfigured()) {
$this->markTestSkipped('Database not configured');
}
$token = self::getJwtToken();
$this->assertNotNull($token, 'Failed to obtain JWT token');
$response = self::$http->put('/data', [
'headers' => self::authHeaders($token) + ['Content-Type' => 'application/json'],
'json' => $payload,
]);
$this->assertSame(200, $response->getStatusCode());
}
The getJwtToken() method first attempts Zitadel OIDC authentication (via service account key), then falls back
to legacy JWT authentication via /auth/login.
Environment variables
The bootstrap file (phpunit_tests/bootstrap.php) loads environment variables from .env, .env.local,
.env.development, .env.staging, and .env.production files using Dotenv.
Required for integration tests
| Variable | Description | Example |
|---|---|---|
API_PROTOCOL |
Protocol for API server | http |
API_HOST |
Hostname or IP | localhost |
API_PORT |
Port number | 8000 |
Optional
| Variable | Description | Default |
|---|---|---|
API_BASE_PATH |
API base path | / |
ADMIN_USERNAME |
Legacy JWT admin username | admin |
ADMIN_PASSWORD |
Legacy JWT admin password | password |
ZITADEL_ISSUER |
Zitadel OIDC issuer URL | — |
ZITADEL_PROJECT_ID |
Zitadel project ID | — |
ZITADEL_SERVICE_KEY_FILE |
Path to service account key JSON | — |
DB_HOST |
Database host (for protected routes) | — |
DB_PORT |
Database port | — |
DB_NAME |
Database name | — |
DB_USER |
Database user | — |
DB_PASSWORD |
Database password | — |
UNAUTHENTICATED_RATE_LIMIT |
Override rate limit (set high for CI) | — |
CI/CD integration
Tests run automatically via GitHub Actions (.github/workflows/phpunit.yml) on pushes and pull requests to the
development and stable branches. The CI environment:
- Uses PHP 8.4 with extensions: yaml, intl, zip, calendar, gettext, apcu, opcache, pdo_pgsql
- Starts a PostgreSQL 17 service container
- Initializes the database via
infrastructure/init-db.sql - Starts the API server with
composer start - Runs the full test suite with
composer test - Sets
UNAUTHENTICATED_RATE_LIMIT=999999to prevent rate limiting during tests
Writing a new test: step by step
-
Decide the test type: Is it a unit test (no API server needed) or an integration test (requires HTTP requests)?
-
Create the test file in the appropriate subdirectory under
phpunit_tests/, ending in*Test.php -
Extend the right base class:
PHPUnit\Framework\TestCasefor unit testsLiturgicalCalendar\Tests\ApiTestCasefor integration tests
-
Write test methods prefixed with
testand with avoidreturn type:public function testSomethingSpecific(): void { // Arrange // Act // Assert } -
Use standard PHPUnit assertions:
assertSame(),assertEquals(),assertTrue(),assertIsArray(),assertObjectHasProperty(),assertStringStartsWith(),expectException(), etc. -
Mark slow tests with
#[Group('slow')]if they take significant time -
Run the test to verify it passes:
vendor/bin/phpunit phpunit_tests/YourSubdir/YourNewTest.php -
Run the full suite to check for regressions:
composer test
Testing: [Liturgical event tests]] Next → ](/Liturgical-Calendar/LiturgicalCalendarAPI/wiki/[[Home)