Skip to content

Testing and CI

Mikhail Deynekin edited this page Oct 11, 2026 · 3 revisions

Testing & CI

The suite is the contract: 386 tests / 1286 assertions run against every combination of PHP version and artifact, and several tests are the architecture documentation (no-eval/no-globals scan, engine independence, security containment).

Running tests

php8.3 tests/run.php                          # source tree (default)
php8.5 tests/run.php                          # source tree, newest PHP
php8.3 tests/run.php --lib=php83/html.php     # against the 8.3 bundle
php8.5 tests/run.php --lib=php85/html.php     # against the 8.5 bundle
php8.5 tests/run.php --filter=ssi --verbose   # filtered
php8.3 tests/run.php --list                   # inventory
bash tools/run-matrix.sh                      # whole matrix: 14 combos, both intl
                                              # modes; drift-checks bundles first,
                                              # never rewrites them (--build to opt in);
                                              # hard error when a PHP version is missing

Exit codes: 0 green Β· 1 failures Β· 2 harness error. Two tests skip in bundle mode (the per-file architecture scans) β€” everything else is artifact-independent.

The matrix

Every combination runs twice: with ext-intl and with it disabled (mbstring, :intl in CI, -d disable_functions=transliterator_transliterate locally) β€” 14 CI jobs. The suite is extension-independent by design (Str::slugify is deterministic); the two legs exist to keep it that way.

src php83 bundle php85 bundle
PHP 8.3 βœ… 386 βœ… 384 (+2 skip) β€” (parse error by design)
PHP 8.4 βœ… 386 βœ… 384 (+2 skip) β€”
PHP 8.5 βœ… 386 βœ… 384 (+2 skip) βœ… 384 (+2 skip)

Identical numbers with intl on and off.

What's covered (15 case files)

01 config/container/scope/request Β· 02 support/io/pathguard Β· 03 escaper/cleaner Β· 04 cache/log/events Β· 05 SSI core Β· 06 SSI conditionals/exec Β· 07 substitution/filters Β· 08 structure tags Β· 09 i18n (incl. binary MO built in-test) Β· 10 render API/cache/stats Β· 11 linter Β· 12 WordPress (bridge/adapters/toolkit/CLI/admin/plugin files against a 60-function WP stub set) Β· 13 security (containment, budgets, token-level no-eval/no-globals scan, strict-types scan) Β· 14 CLI Β· 15 v5 expressions & math (inline %{= …}, index/get() containment, INF/NAN guards, AST-memo bound, infinite-generator budgets, linter codes) (subprocess exit codes).

Determinism: UTC pinned, FrozenClock injection, fixture tree under tests/tmp/ (auto-cleaned), per-test $GLOBALS/superglobal snapshot-restore, no network.

Writing tests

<?php
// tests/cases/15_my_feature.test.php
declare(strict_types=1);

group('My feature');

test('does the thing', function (): void {
    $engine = test_engine(['syntax' => ['auto_escape' => false]]);  // bootstrap helper
    assertSame('x', $engine->renderString('%{v}', ['v' => 'x']));
});

Runner API: group() test() assertSame/True/False/Null assertContains/ NotContains assertMatches assertThrows assertInstanceOf skip/skipIf fail. Helpers from tests/bootstrap.php: test_engine(array $config, ?FrozenClock), test_request(), test_options(), fixture_write/path/dir, run_isolated_php(), ssi() (in 05).

Rules of the house:

  1. New behaviour β‡’ new assertions in the matching case file (or a new one).
  2. Security-relevant change β‡’ a 13_security case proving the guard.
  3. Bundle-visible change β‡’ run tools/build.php + both --lib legs.
  4. WordPress-touching change β‡’ must pass against tests/wp-stubs.php (extend the stubs rather than skipping).

Continuous integration

.github/workflows/ci.yml:

flowchart LR
    PR[push / PR] --> T["tests job Γ—9\nPHP 8.3Β·8.4Β·8.5 Γ— srcΒ·php83Β·php85\n(8.5 bundle excluded below 8.5)"]
    PR --> B[build bundles] --> C["build.php --check\n(drift gate)"] --> T
    PR --> S["PHPStan level 6 β€” mandatory\n(empty baseline, WP layer included)"]
    PR --> F["PHP-CS-Fixer β€” mandatory\n(pinned, dry run)"]
    PR --> W["wiki links\n(tools/check-wiki-links.php)"]
Loading
  • Drift gate: bundles are rebuilt in CI and compared byte-for-byte with the committed ones β€” a PR that edits src/ without rebuilding fails.
  • PHPStan: level 6 over the full src/ tree β€” the WordPress layer is analysed against the project's own WP stubs (bootstrapFiles), no exclusions. phpstan-baseline.neon is empty by policy (fix findings in code, never baseline them) and reportUnmatchedIgnoredErrors: true. The phar is version-pinned in CI (PHPSTAN_VERSION).
  • CS: PER-CS2.x + strict types + alpha imports via .php-cs-fixer.dist.php; the fixer version is pinned in CI (PHP_CS_FIXER_VERSION) and the dry-run is a hard gate β€” run php-cs-fixer fix locally before pushing.
  • Smoke: tests/smoke.php (end-to-end public-API tour) runs as its own job on PHP 8.3 and 8.5 (composer run smoke locally).
  • Wiki links: tools/check-wiki-links.php validates every internal link and heading anchor of the wiki repository (cloned at check time).

Static analysis & style locally

# PHPStan (phar, no install)
curl -sSLo phpstan.phar https://github.com/phpstan/phpstan/releases/latest/download/phpstan.phar
php8.3 phpstan.phar analyse -c phpstan.neon --memory-limit=1G

# PHP-CS-Fixer
PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --dry-run --diff

Benchmarks

php8.3 tools/bench.php 2000
# render (loops+ssi+i18n)   ~0.72 ms/op  Β· ~1.4k ops/s
# renderString (small)      ~0.014 ms/op Β· ~69k ops/s
# processSsi (5 directives) ~0.018 ms/op Β· ~54k ops/s
# t() with catalogue        ~0.002 ms/op Β· ~626k ops/s

Reference box: 2-vCPU container, PHP 8.3, opcache CLI off. A CI perf gate (Β±10 % regression fail) is on the ROADMAP.

Releasing

  1. php tools/build.php && php tools/build.php --check
  2. bash tools/run-matrix.sh (install 8.4 locally or let CI cover it)
  3. Bump src/Version.php (+ src-8.5/Version.php), CHANGELOG.md, README badges, plugin header/readme.txt.
  4. Tag v4.x.y; attach both bundles to the GitHub release (shared-hosting users download, not clone).
  5. Publish wiki sync: bash tools/publish-wiki.sh --push.

Clone this wiki locally