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() and authHeaders()
  • 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 tests
    • Routes/ReadWrite/ for endpoints that also support PUT/PATCH/DELETE
    • Routes/Auth/ for authentication-related tests
    • Schemas/ for JSON schema validation tests
    • Services/ for service-layer unit tests
    • Enum/, Http/, Methods/ for other categories
  • Use final class for concrete test classes
  • Use declare(strict_types=1) at the top of every test file

Namespace conventions

  • Unit tests (extending TestCase): use the namespace LiturgicalCalendar\Api\Tests\{Subdirectory}
  • Integration tests (extending ApiTestCase): use the namespace LiturgicalCalendar\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_*.json for payloads that should pass schema validation
  • invalid_*.json for 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=999999 to prevent rate limiting during tests

Writing a new test: step by step

  1. Decide the test type: Is it a unit test (no API server needed) or an integration test (requires HTTP requests)?

  2. Create the test file in the appropriate subdirectory under phpunit_tests/, ending in *Test.php

  3. Extend the right base class:

    • PHPUnit\Framework\TestCase for unit tests
    • LiturgicalCalendar\Tests\ApiTestCase for integration tests
  4. Write test methods prefixed with test and with a void return type:

    public function testSomethingSpecific(): void
    {
        // Arrange
        // Act
        // Assert
    }
    
  5. Use standard PHPUnit assertions: assertSame(), assertEquals(), assertTrue(), assertIsArray(), assertObjectHasProperty(), assertStringStartsWith(), expectException(), etc.

  6. Mark slow tests with #[Group('slow')] if they take significant time

  7. Run the test to verify it passes:

    vendor/bin/phpunit phpunit_tests/YourSubdir/YourNewTest.php
    
  8. Run the full suite to check for regressions:

    composer test
    

Testing: [Liturgical event tests]] Next → ](/Liturgical-Calendar/LiturgicalCalendarAPI/wiki/[[Home)