| name | elgg-test-writer |
| description | Use when writing PHPUnit tests for Elgg plugins, generating test suites, or adapting tests between Elgg versions. Triggers on "test elgg plugin", "write elgg tests", "elgg integration test".
|
elgg-test-writer
Generate PHPUnit test suites for Elgg plugins, adapted to the target version's testing API.
Iron Laws
- SCAN BEFORE WRITING โ Read every PHP file in the plugin first. Never write tests for functionality you haven't read.
- TEST BEHAVIOR, NOT IMPLEMENTATION โ Test what the plugin does, not how it does it.
- MATCH THE ELGG VERSION โ Use the correct base classes and session API for the target version.
- RUN IN DOCKER โ ALL operations (PHPUnit, Playwright, npm) run inside containers. Nothing executes on the host.
- UI TESTS ARE MANDATORY โ Every plugin with user-facing features MUST have Playwright tests that assert both UI state and database state.
- TESTS FIRST FOR MIGRATIONS โ When a plugin is about to be migrated to a new major, generate and run the
BaselineTest + MigrationRegressionTest before editing a single line of plugin code. MigrationRegressionTest MUST be RED first (it proves the failure classes are present); a migration that never showed RED never proved it fixed anything.
Skill layout (templates, not live infra)
This skill ships templates that get copied into each plugin
repository. It does not run any shared Docker stack of its own, and it
does not depend on the elgg-migrate skill for infrastructure. After
npx skills add:
<skill-dir>/
SKILL.md # this file
bin/migrate.php # AST migration engine CLI
bin/scaffold-docker.sh # copy docker/ into a plugin
bin/scaffold-ci.sh # copy .github/workflows/ into a plugin
bin/scaffold-phpcs.sh # backfill phpcs into existing docker stack
bin/scaffold-smoke-tests.sh # emit baseline SmokeTest + RegressionTest
src/ # ElggMigrate\ PHP namespace
rules/{2..6}x-to-{3..7}x/ # per-version rule manifests
composer.json # nikic/php-parser dep + PSR-4 autoload
phpunit.xml # test runner config
tests/ # PHPUnit tests for src/
formulas/ # plugin-test-scaffold beads formula
templates/elgg{N}/ # per-target Elgg test stack (N = 2..7)
templates/SmokeTest.php.template # post-migration integration smoke test
templates/BaselineTest.php.template # tests-first: GREEN before + after (behavior net)
templates/RegressionTest.php.template # static guard for recurring 7.x fatals
templates/MigrationRegressionTest.php.template # tests-first: RED before, GREEN after (per-target failure classes)
templates/PerformanceRegressionTest.php.template # query-cost gate: Handler_read_next per entity shape vs .perf-baseline.json
templates/DEVELOPMENT.md # plugin-level testing docs template
references/ci/ # GitHub Actions workflow templates
references/regression-classes.md # bug-class โ assertion map
references/*.json # mirrored from elgg-migrate; read by the bundled engine
bin/elgg-migrate-run # per-plugin orchestrator (mirrored)
infra/elgg{N}/ # whole-site docker stacks, consumed by elgg-migrate-run
selftest/run.sh # tests for THIS skill's scaffolds + extractor
tests/ vs selftest/ โ tests/ is the mirrored elgg-migrate engine suite
and is wiped and regenerated by bin/gen-elgg-infra.sh, so nothing skill-specific
may live there. The scaffolders and bin/lib/extract-plugin-config.php are covered
by selftest/run.sh, which scaffolds throwaway fixture plugins and asserts on the
result. It needs no docker, network or DB, runs in seconds, and is executed by the
repo's bin/validate-elgg-infra.sh.
templates/elgg{N}/ vs infra/elgg{N}/ โ they are different stacks and both
are needed. templates/ holds the per-plugin stack that scaffold-docker.sh
copies into <plugin>/docker/; it is scoped to ${PLUGIN_ID} and is the one you
edit when changing how a plugin's own tests run. infra/ holds the whole-site
stack that the bundled bin/elgg-migrate-run boots (it reads
$SKILL_ROOT/infra/<version>/docker-compose.yml). Both are generated from
skills/elgg-migrate/ by bin/gen-elgg-infra.sh; edit the canonical copies there
and re-run the generator โ bin/validate-elgg-infra.sh fails if they drift.
Each templates/elgg{N}/ directory holds a self-contained docker stack
โ Dockerfile, docker-compose.yml, elgg-install.sh,
elgg-composer.json, index.php, .env.example โ that the skill
copies into the plugin under test at <plugin>/docker/. Every plugin
ends up with its own isolated stack (own containers, volumes, network,
and ports scoped to ${PLUGIN_ID}-elgg{N}); nothing is shared between
plugins.
Resolve $SKILL once at session start as the absolute path of the
directory containing this SKILL.md, and $SKILL_TEMPLATES as
$SKILL/templates. Every docker command in this skill is run from
the plugin root and references docker/docker-compose.yml relative
to that root โ never a path inside the skill directory.
Phase 0: Scaffold the plugin's docker stack
Before any test work, run the bootstrap script to copy the per-plugin
docker stack into the plugin repository. The script is deterministic โ
no prompts, no LLM inference. It resolves PLUGIN_ID from
composer.json, manifest.xml, or the directory name, infers the Elgg
major version from the elgg/elgg composer constraint, and writes
every file from templates/<elggN>/ into <plugin>/docker/.
$SKILL/bin/scaffold-docker.sh
$SKILL/bin/scaffold-docker.sh --plugin-dir=/abs/path/to/plugin
$SKILL/bin/scaffold-docker.sh --elgg-version=elgg4
The script writes:
<plugin>/docker/Dockerfile
<plugin>/docker/docker-compose.yml
<plugin>/docker/elgg-install.sh (chmod +x)
<plugin>/docker/elgg-composer.json
<plugin>/docker/index.php
<plugin>/docker/.env.example
<plugin>/docker/.env (PLUGIN_ID filled in)
<plugin>/DEVELOPMENT.md (if missing)
<plugin>/.gitignore (ensures docker/.env + test-runner artifacts)
Existing files are left alone unless --force is passed. After the
scaffold runs, every subsequent command โ docker compose -f docker/docker-compose.yml ... โ touches only files inside the plugin
repo and never reaches into the skill directory.
Container Infrastructure
All test operations run inside Docker containers.
| Service | Purpose | Compose file |
|---|
elgg | PHPUnit integration tests, Elgg bootstrap | docker/docker-compose.yml |
node | Playwright browser tests, npm operations | docker/docker-compose.yml (profile: test) |
db | MySQL database (shared by elgg + node) | docker/docker-compose.yml |
Debugging inside containers
docker compose -f docker/docker-compose.yml exec elgg tail -f /var/log/apache2/error.log
docker compose -f docker/docker-compose.yml logs elgg
docker compose -f docker/docker-compose.yml exec elgg bash
docker compose -f docker/docker-compose.yml exec elgg php -r "
require 'vendor/autoload.php';
\$app = \Elgg\Application::getInstance(); \$app->bootCore();
foreach (elgg_get_plugins('active') as \$p) echo \$p->getID() . PHP_EOL;
"
docker compose -f docker/docker-compose.yml exec db mysql -uelgg -pelgg elgg
docker compose -f docker/docker-compose.yml exec db mysql -uelgg -pelgg elgg \
-e "SHOW TABLES LIKE 'c_i_elgg_%'"
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin/tests/playwright && npm ci && npx playwright test --debug"
docker compose -f docker/docker-compose.yml build --no-cache
Phase 0.5: Scaffold the tests-first suite (BEFORE any plugin code change)
After the docker stack is in place but before writing any custom tests โ
and, for a migration, before touching a single line of plugin code โ
generate the deterministic suite:
$SKILL/bin/scaffold-smoke-tests.sh
$SKILL/bin/scaffold-smoke-tests.sh --plugin-dir=/abs/path/to/plugin
$SKILL/bin/scaffold-smoke-tests.sh --target-version=elgg7
The script statically parses elgg-plugin.php (no Elgg bootstrap needed),
infers the current major from the elgg/elgg composer constraint and the
target major (current + 1, or --target-version), and writes up to five files:
| File | Boot? | Role in the REDโGREEN cycle |
|---|
tests/phpunit/unit/MigrationRegressionTest.php | no (static scan) | RED before migration, GREEN after. Asserts every statically-detectable failure class for the target major is absent. |
tests/phpunit/integration/BaselineTest.php | yes (current stack) | GREEN before AND after. Captures the observable behavior the migration must preserve. |
tests/phpunit/unit/RegressionTest.php | no (static scan) | Standing 7.x fatal guard (signature-incompat, null-title, add_translation, removed instance method, orphaned css). |
tests/phpunit/integration/SmokeTest.php | yes (target stack) | Post-migration proof: registered, activates, actions registered, entity classes bind. |
tests/phpunit/integration/PerformanceRegressionTest.php | yes (target stack) | Query-cost gate (only when the plugin owns entities). Measures Handler_read_next for each of the plugin's entity shapes (delta method โ no elevated DB privilege) and fails if it drifts >25% over a committed tests/.perf-baseline.json. First run records the baseline (skips + writes .observed); commit it to arm the gate. The per-plugin companion to the elgg-benchmark skill and migrate.php --benchmark. |
The tests-first cycle (mandatory for migrations)
- Scaffold the suite on the un-migrated plugin (above).
- Prove RED. Run
MigrationRegressionTest on the un-migrated source โ it
MUST fail (the target major's removed symbols, forbidden start.php,
camelCase plugin-id callsites, wrong Seed/Batch shape, orphaned css, etc.
are all still present). It runs without the docker stack:
vendor/bin/phpunit tests/phpunit/unit/MigrationRegressionTest.php
If it is already GREEN, either the plugin is already migrated or the target
was mis-detected โ do not proceed until you have seen it RED.
- Capture the baseline. Boot the current-version docker stack and run
BaselineTest โ it MUST be GREEN. This is the behavior net.
docker compose -f docker/docker-compose.yml run --rm elgg \
vendor/bin/phpunit tests/phpunit/integration/BaselineTest.php
- Migrate the plugin (via the
elgg-migrate skill โ one major at a time).
- Prove GREEN. Re-run
MigrationRegressionTest (now GREEN โ every failure
class fixed) and re-run BaselineTest on the target-version stack (still
GREEN โ nothing that worked broke). Then run SmokeTest on the target stack.
MigrationRegressionTest is a static source scan on purpose: most catalog
classes fatal at class-load or page-render on the target version, so a booted
test crashes before it can assert โ the signature has to be caught in the
source. It is parameterized by the target major (const TARGET_MAJOR) and
version-gates each check, so the same template guards a 3.xโ4.x or a 6.xโ7.x
step. Its embedded maps mirror the engine-side detectors โ keep them in
lock-step:
These ship inside this skill (mirrored from elgg-migrate, the canonical source,
by bin/gen-elgg-infra.sh), so paths are skill-local and a standalone vendoring
of elgg-test-writer resolves them:
references/removed-functions.json (removed symbols)
references/changed-class-contracts.json (interfaceโclass)
references/migration-failure-catalog.md (the full class list)
bin/scan-frontend-residue.sh (CORE_SIG)
Failure classes it asserts absent (target-gated): removed core functions +
constants + ElggFile::detectMimeType; changed class contracts (Hook,
Batch, ServiceFacade, NotificationEvent); forbidden start.php /
activate.php / deactivate.php (and start.php required at 3.x);
camelCase plugin-id callsites; hook/event confusion in elgg-plugin.php; Seed
subclass missing getType()/getCountOptions(); implements Batch;
add_translation(); unsafe unserialize(); route:rewrite registered at
init; ::class/::CONST in the entities block; incompatible core-method
overrides; menu ->add() on 7.x; orphaned css/elements/* overrides.
See references/regression-classes.md for the standing 7.x guard's
bug-classโassertion map and how to extend CORE_SIG when a new major retypes a
core method.
The whole suite is deterministic โ no LLM judgment, no plugin code
execution in the static scans. The LLM-driven phases below add richer
per-feature coverage (action 200/403 paths, route reachability, view rendering,
UI flows) on top of these files.
The post-migration verifier in src/PostMigrationVerifier.php (bundled with this
skill) emits a warning for plugins missing this scaffold.
Quick Reference
| Elgg | Unit Base | Integration Base | Session API |
|---|
| 2.x | PHPUnit\Framework\TestCase | Custom bootstrap | N/A |
| 3.x | \Elgg\UnitTestCase | \Elgg\IntegrationTestCase | elgg_get_session()->setLoggedInUser() |
| 4.x | \Elgg\UnitTestCase | \Elgg\IntegrationTestCase | elgg_get_session()->setLoggedInUser() |
| 5.x+ | \Elgg\UnitTestCase | \Elgg\IntegrationTestCase | _elgg_services()->session_manager->setLoggedInUser() |
What to test
| Category | Source | PHPUnit | Playwright |
|---|
| Entity types | elgg-plugin.php, activate.php | CRUD lifecycle, class mapping | โ |
| Actions | actions/ directory | Input validation, side effects, permissions | Form submit โ assert DB state |
| Routes | route registrations | URL resolution, response codes | Navigate โ assert page renders |
| Hooks/Events | handler registrations | Handler execution, return values | โ |
| Views | views/ directory | Render without errors | Assert UI elements visible |
| Permissions | permission hooks | Owner can edit, non-owner cannot | Login as different users, assert access |
| Forms | form views + actions | โ | Fill form, submit, assert UI + DB |
| Listings | list views | โ | Navigate, assert items, pagination |
| Modals/Widgets | JS-driven UI | โ | Trigger, assert appear/function |
| Admin pages | views/default/admin/ | โ | Navigate, assert renders |
| AJAX | async actions | โ | Trigger action, assert UI update + DB |
Workflow
Phase 1: SCAN โ catalog all testable features from plugin source
Phase 2: SET UP test infrastructure
<plugin>/tests/
bootstrap.php
phpunit.xml
phpunit/
unit/<Namespace>/
integration/<Namespace>/
Phase 3: WRITE TESTS
Use \Elgg\IntegrationTestCase for most tests. Key helpers:
$user = $this->createUser();
$group = $this->createGroup();
$object = $this->createObject(['subtype' => 'blog']);
Plugin Seeder is the canonical fixture source
If the plugin owns entity types/subtypes, it MUST ship a
Seeder class extending \Elgg\Database\Seeds\Seed registered on
seeds, database (see the elgg-migrate skill's "Introduce a Seeder
subclass" step). Tests reuse it as the single source of truth for what
a valid fixture looks like.
When writing tests:
- Check first: does
<Vendor>\<Plugin>\Seeder (or
<Vendor>\<Plugin>\Seeds\*) already exist? If yes, instantiate and
call its seed() / specific helpers from up() instead of
hand-rolling entity creation in every test method.
- If absent: stop and add the Seeder before continuing the test
suite. A test fixture that diverges from how the plugin actually
constructs entities masks real bugs (missing required metadata,
wrong owner/container shape, missed access defaults).
- Fixture parity: any field the Seeder sets is a field the plugin
expects to exist. Tests that rely on a subset of those fields are
still valid, but tests that bypass the Seeder must justify why in a
comment.
public function up() {
$this->seeder = new \<Vendor>\<Plugin>\Seeder();
$this->seeder->setLimit(3);
$this->seeder->seed();
}
public function down() {
$this->seeder->unseed();
}
For ad-hoc fixtures within a single test, the inherited
$this->createObject([...]) helper from IntegrationTestCase is fine
โ those entities are auto-tagged with __faker and cleaned up. But
fixtures that mirror the plugin's actual entity shape must come from
the Seeder.
IMPORTANT: $this->executeAction() does NOT exist in IntegrationTestCase โ it's only in ActionResponseTestCase. For integration tests, test entity behavior directly instead of through actions.
Entity CRUD (4.x):
public function testEntityClassMapping(): void {
$entity = $this->createObject(['subtype' => 'blog']);
$loaded = get_entity($entity->guid);
$this->assertInstanceOf(\ElggObject::class, $loaded);
$this->assertEquals('blog', $loaded->getSubtype());
}
Entity creation with metadata (4.x):
public function testEntityMetadataPersists(): void {
$user = $this->createUser();
$entity = new \ElggObject();
$entity->setSubtype('mytype');
$entity->owner_guid = $user->guid;
$entity->container_guid = elgg_get_site_entity()->guid;
$entity->access_id = ACCESS_PUBLIC;
$entity->title = 'Test Entity';
$entity->custom_field = 'custom_value';
$this->assertTrue($entity->save() !== false);
_elgg_services()->entityCache->delete($entity->guid);
$loaded = get_entity($entity->guid);
$this->assertEquals('custom_value', $loaded->custom_field);
$entity->delete();
}
Permissions (4.x):
public function testNonOwnerCannotEdit(): void {
$owner = $this->createUser();
$other = $this->createUser();
$post = $this->createObject(['subtype' => 'blog', 'owner_guid' => $owner->guid]);
$this->assertTrue($post->canEdit($owner->guid));
$this->assertFalse($post->canEdit($other->guid));
}
Relationships (4.x):
public function testRelationshipCreated(): void {
$user = $this->createUser();
$entity = $this->createObject(['subtype' => 'blog']);
$user->addRelationship($entity->guid, 'viewed');
$this->assertTrue($user->hasRelationship($entity->guid, 'viewed'));
}
Hook handler testing (4.x):
public function testHookModifiesValue(): void {
$hook_called = false;
$handler = function (\Elgg\Hook $hook) use (&$hook_called) {
$hook_called = true;
return $hook->getValue();
};
elgg_register_plugin_hook_handler('register', 'menu:test', $handler);
elgg_trigger_plugin_hook('register', 'menu:test', [], []);
$this->assertTrue($hook_called);
elgg_unregister_plugin_hook_handler('register', 'menu:test', $handler);
}
View rendering (4.x โ integration tests only):
public function testViewRenders(): void {
$output = elgg_view('my_plugin/my_view', ['key' => 'value']);
$this->assertIsString($output);
$this->assertNotEmpty($output);
}
Plugin active skip workaround:
IntegrationTestCase auto-skips tests if the plugin isn't active in the test DB. This frequently happens because the test DB (c_i_elgg_ prefix) has separate plugin state. Two fixes:
public function getPluginID(): string {
return '';
}
public function up() {
$libFile = dirname(__DIR__, 5) . '/lib/functions.php';
if (!function_exists('my_plugin_function')) {
require_once $libFile;
}
}
public function down() {}
Phase 3.5: WRITE PLAYWRIGHT TESTS
Playwright tests verify UI features end-to-end against a running Elgg instance in Docker. They assert both UI state (elements visible, text content, navigation) and database state (entities created, metadata set, relationships formed).
Test structure
<plugin>/tests/
playwright/
playwright.config.ts
package.json
tests/
<feature>.spec.ts
helpers/
elgg.ts # Elgg-specific helpers (login, DB queries, etc.)
Playwright config
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30000,
use: {
baseURL: process.env.ELGG_BASE_URL || 'http://elgg',
ignoreHTTPSErrors: true,
},
workers: 1,
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
});
CRITICAL Playwright notes:
- Tests run inside the
node Docker service, NOT on the host machine
- Default base URL is
http://elgg (Docker container networking)
- DB host is
db on port 3306 (internal Docker networking, not host-mapped ports)
- Use
workers: 1 โ parallel workers cause DB race conditions with shared Elgg state
- Environment variables (
ELGG_BASE_URL, ELGG_DB_HOST, etc.) are set in docker-compose.yml
Elgg helpers
import { Page, expect } from '@playwright/test';
import mysql from 'mysql2/promise';
const DB_CONFIG = {
host: process.env.ELGG_DB_HOST || 'db',
port: Number(process.env.ELGG_DB_PORT || 3306),
user: process.env.ELGG_DB_USER || 'elgg',
password: process.env.ELGG_DB_PASS || 'elgg',
database: process.env.ELGG_DB_NAME || 'elgg',
};
export async function loginAs(page: Page, username: string, password: string = process.env.ELGG_ADMIN_PASSWORD || 'admin12345') {
await page.goto('/login');
const form = page.locator('.elgg-module-aside, form.elgg-form-login').();
form.().(username);
form.().(password);
form.().();
page.( !url.().());
}
() {
conn = mysql.();
[rows] = conn.(sql, params);
conn.();
rows;
}
() {
(
, [guid]
);
}
() {
sql = ;
: [] = [subtype];
(ownerGuid) {
sql += ;
params.(ownerGuid);
}
(sql, params);
}
() {
(
,
[entityGuid, name]
);
}
() {
(
,
[guid_one, relationship, guid_two]
);
}
(): <> {
conn = mysql.();
{
[result]: = conn.(
,
[subtype, ownerGuid, ownerGuid]
);
guid = result.;
conn.(
,
[guid, title, guid, description]
);
guid ;
} {
conn.();
}
}
() {
conn = mysql.();
{
conn.(, [guid]);
conn.(, [guid]);
} {
conn.();
}
}
Test patterns
Form submission โ assert UI + database:
import { test, expect } from '@playwright/test';
import { loginAs, getEntitiesBySubtype, getMetadata } from '../helpers/elgg';
test.describe('Blog plugin', () => {
test('create blog post via form', async ({ page }) => {
await loginAs(page, 'testuser');
await page.goto('/blog/add');
await page.fill('input[name="title"]', 'Test Blog Post');
await page.fill('textarea[name="description"]', 'This is test content');
await page.selectOption('select[name="status"]', 'published');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/blog\/view\//);
await expect(page.locator('h1, .elgg-heading-main')).toContainText('Test Blog Post');
const entities = ();
blog = entities[entities. - ];
(blog).();
(blog.).();
status = (blog., );
(status[]?.).();
});
});
Listing page โ assert items render:
test('blog listing shows posts', async ({ page }) => {
await loginAs(page, 'testuser');
await page.goto('/blog/all');
await expect(page.locator('.elgg-list')).toBeVisible();
const items = page.locator('.elgg-list > .elgg-item');
await expect(items).toHaveCount.greaterThan(0);
const pagination = page.locator('.elgg-pagination');
});
Permissions โ test as different users:
test('non-owner cannot edit post', async ({ page }) => {
await loginAs(page, 'owner_user');
await page.goto('/blog/add');
await page.fill('input[name="title"]', 'Owner Only Post');
await page.fill('textarea[name="description"]', 'Content');
await page.click('button[type="submit"]');
const postUrl = page.url();
const editUrl = postUrl.replace('/view/', '/edit/');
await loginAs(page, 'other_user');
const response = await page.goto(editUrl);
expect([403, 302]).toContain(response?.status() ?? 0);
});
AJAX interactions โ assert UI update + DB:
test('like button updates UI and database', async ({ page }) => {
await loginAs(page, 'testuser');
await page.goto('/blog/all');
const likeButton = page.locator('.elgg-item').first().locator('.elgg-button-like');
await likeButton.click();
await expect(likeButton).toHaveClass(/elgg-state-active/);
});
Admin pages โ assert render:
test('admin settings page renders', async ({ page }) => {
await loginAs(page, 'admin');
await page.goto('/admin/plugin_settings/<plugin-id>');
await expect(page.locator('.elgg-form-settings')).toBeVisible();
await expect(page.locator('.elgg-system-messages .elgg-message-error')).toHaveCount(0);
});
Phase 4: RUN AND VERIFY
PHPUnit setup checklist (one-time per Docker env)
Before running PHPUnit for the first time in a Docker environment:
docker compose -f docker/docker-compose.yml exec elgg \
composer require --dev phpunit/phpunit:^9.6 --no-interaction
docker compose -f docker/docker-compose.yml exec elgg php -r "
\$pdo = new PDO('mysql:host=db;dbname=elgg', 'elgg', 'elgg');
\$tables = \$pdo->query(\"SHOW TABLES LIKE 'elgg_%'\")->fetchAll(PDO::FETCH_COLUMN);
foreach (\$tables as \$t) {
\$new = str_replace('elgg_', 'c_i_elgg_', \$t);
\$pdo->exec(\"DROP TABLE IF EXISTS \$new\");
\$r = \$pdo->query(\"SHOW CREATE TABLE \$t\")->fetch(PDO::FETCH_ASSOC);
\$pdo->exec(str_replace(\$t, \$new, \$r['Create Table']));
}
foreach (['config','entities','metadata','private_settings','entity_relationships'] as \$t) {
\$pdo->exec(\"INSERT INTO c_i_elgg_\$t SELECT * FROM elgg_\$t\");
}
echo 'Done.' . PHP_EOL;
"
Running tests
docker compose -f docker/docker-compose.yml exec elgg \
vendor/bin/phpunit --configuration mod/<plugin>/tests/phpunit.xml --no-coverage
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin/tests/playwright && npm ci && npx playwright test"
After activating/deactivating plugins, refresh test data:
docker compose -f docker/docker-compose.yml exec elgg php -r "
\$pdo = new PDO('mysql:host=db;dbname=elgg', 'elgg', 'elgg');
foreach (['entities','metadata','private_settings','entity_relationships','config'] as \$t) {
\$pdo->exec(\"TRUNCATE TABLE c_i_elgg_\$t\");
\$pdo->exec(\"INSERT INTO c_i_elgg_\$t SELECT * FROM elgg_\$t\");
}
echo 'Refreshed.' . PHP_EOL;
"
Behavior coverage rubric (read before the checklist)
The coverage checklist below lists what to test. This rubric is about
whether your tests would actually catch a regression โ which is the point
of pre-migration tests and the part that's easy to skip.
A test suite can have high line coverage, pass the checklist, and still
miss the migration breaking the plugin. That happens when tests exercise
the code without actually asserting on the user-visible behavior that
would regress. The rubric below is a set of questions to ask about every
test before calling coverage "done."
For every user-visible feature, ask:
-
If a migration silently removed this feature, would any test fail?
If the answer is "maybe" or "the function would still run," the test
doesn't cover the behavior โ it covers the code path. Assert on the
observable outcome (DOM change, DB row, HTTP status, response body),
not on whether a function was called.
-
If a migration changed the route for this feature, would any test
fail? If the test hardcodes a route it's likely to survive a
breaking change that moved the route. Good Playwright tests click
links rather than typing URLs directly; when URLs are necessary, use
elgg_generate_url() so the test follows the plugin's real route
definition.
-
If a migration changed the permission model, would any test fail?
A permission test that only runs as the owner tests the happy path,
not the permission. Include a negative case (non-owner attempt, 403
expected).
-
If a migration introduced a subtle data-type change (int โ string,
null โ empty string), would any test fail? Brittle strict-equality
assertions sometimes catch these; loose assertions ("truthy") miss
them. Prefer typed assertions on the fields the migration might
touch.
-
If the background queue / cron / async work stopped running after
migration, would any test fail? Features that depend on async
processing (notifications, search indexing, file processing) need
tests that actually run the queue, not just enqueue the job.
For every hook/event registration, ask:
- Does the test trigger the event that the handler listens on, or
does it just call the handler directly? A test that calls the handler
directly doesn't catch "migration forgot to register the handler."
- Does the test assert on the effect of the handler running, or just
that the handler returned without error?
For every action, ask:
- Does the test POST through the real action dispatcher (which runs
CSRF, input validation, and the full action lifecycle), or does it
invoke the action file directly? The latter misses CSRF, routing,
and middleware regressions.
When you can honestly answer "yes, a regression would fail a test"
for every feature, the coverage is real. If you can't, the gap is the
test to write next.
Coverage checklist
PHPUnit (backend):
Playwright (UI + database):
Version-Specific Notes
Elgg 3.x
- Plugin boots via
start.php โ tests may need manual boot (see template below)
elgg_get_session()->setLoggedInUser($user) for session
_elgg_services()->hooks for hook service
Elgg 4.x
- Plugin boots via
elgg-plugin.php โ test framework handles activation
- Session API:
elgg_get_session()->setLoggedInUser($user) โ same as 3.x
_elgg_services()->session_manager does NOT exist in 4.x โ that's 5.x+
_elgg_services()->hooks for hook service
- No closures in elgg-plugin.php (use class callbacks)
canWriteToContainer() requires ($uid, $type, $subtype)
- IntegrationTestCase uses DB prefix
c_i_elgg_ โ must create test tables first
- Namespaced constants in test bootstrap: if a plugin lib file (
lib/functions.php) uses a namespaced constant (e.g. Acme\Geo\PLUGIN_ID), define it in the test bootstrap BEFORE calling \Elgg\Application::loadCore(). Use the lowercase plugin ID value โ 4.x normalizes all plugin IDs to lowercase. If the Bootstrap defines the constant in load() or init(), the test bootstrap must replicate it.
- Undefined
$type/$return/$params in migrated hook handlers: the signature rewrite rule converts ($hook, $type, $return, $params) โ (\Elgg\Hook $hook) but does NOT fix the handler body. Tests that invoke these handlers will fail with "Undefined variable" PHP notices/fatals. Grep the plugin's classes for bare $type, $return, $params after migration and replace with $hook->getType(), $hook->getValue(), $hook->getParam('key').
register/menu:* hook return value is a MenuItems collection: $hook->getValue() returns an Elgg\Collections\Collection, not a plain array. array_merge($return, $items) will throw. Use $return->merge($items) instead.
Elgg 5.x+
_elgg_services()->session_manager->setLoggedInUser($user) for session
_elgg_services()->events โ hooks and events unified into events
\Elgg\Event replaces \Elgg\Hook
File Templates
bootstrap.php (3.x and 4.x โ SAME bootstrap works for both)
CRITICAL: The path from tests/ to the Elgg root is always 3 levels up: tests/ โ mod/plugin/ โ mod/ โ elgg_root/. Use dirname(__DIR__, 3) or dirname(dirname(dirname(__DIR__))).
<?php
$elggRoot = dirname(dirname(dirname(__DIR__)));
require_once $elggRoot . '/vendor/autoload.php';
$testClassesDir = $elggRoot . '/vendor/elgg/elgg/engine/tests/classes';
spl_autoload_register(function ($class) use ($testClassesDir) {
$file = $testClassesDir . '/' . str_replace('\\', '/', $class) . '.php';
if (file_exists($file)) require_once $file;
});
$pluginRoot = dirname(__DIR__);
if (file_exists($pluginRoot . '/vendor/autoload.php')) {
require_once $pluginRoot . '/vendor/autoload.php';
} elseif (file_exists($pluginRoot . '/autoloader.php')) {
require_once $pluginRoot . ;
}
::();
DO NOT use dirname(__DIR__, 4) โ that goes one level too high.
DO NOT try to locate engine/tests/phpunit/bootstrap.php โ load autoloader + test classes + loadCore() directly.
phpunit.xml (3.x/4.x)
CRITICAL: Only include <directory> entries for test suite directories that EXIST. PHPUnit errors if a directory is missing. If the plugin only has integration tests, omit the unit suite.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="bootstrap.php" colors="true">
<php>
<env name="ELGG_DB_PREFIX" value="elgg_"/>
<env name="ELGG_DB_HOST" value="db"/>
<env name="ELGG_DB_NAME" value="elgg"/>
<env name="ELGG_DB_USER" value="elgg"/>
<env name="ELGG_DB_PASS" value="elgg"/>
</php>
<testsuites>
<testsuite name="integration"><directory>phpunit/integration</directory></testsuite>
</testsuites>
PHPUnit must be installed in Elgg's vendor
The Elgg Docker images do NOT include PHPUnit by default. Before running tests:
docker compose -f docker/docker-compose.yml exec elgg \
composer require --dev phpunit/phpunit:^9.6 --no-interaction
Pin PHPUnit to the container's PHP, not to the Elgg version:
| Stack | PHP | PHPUnit |
|---|
Elgg 2.x (php:7.2-apache) | 7.2 | 8.x โ PHPUnit 9 requires PHP โฅ 7.3 |
Elgg 3.x / 4.x (php:7.4-apache) | 7.4 | 9.x |
| Elgg 5.x+ | 8.1+ | 10.x |
Test database setup (REQUIRED for IntegrationTestCase)
Elgg's IntegrationTestCase uses a separate DB prefix (c_i_elgg_) for test isolation. These tables must exist before integration tests can run. Create them by cloning the production schema:
docker compose -f docker/docker-compose.yml exec elgg php -r "
\$pdo = new PDO('mysql:host=db;dbname=elgg', 'elgg', 'elgg');
\$stmt = \$pdo->query(\"SHOW TABLES LIKE 'elgg_%'\");
\$tables = \$stmt->fetchAll(PDO::FETCH_COLUMN);
foreach (\$tables as \$table) {
\$newTable = str_replace('elgg_', 'c_i_elgg_', \$table);
\$pdo->exec(\"DROP TABLE IF EXISTS \$newTable\");
\$row = \$pdo->query(\"SHOW CREATE TABLE \$table\")->fetch(PDO::FETCH_ASSOC);
\$pdo->exec(str_replace(\$table, \$newTable, \$row['Create Table']));
}
\$pdo->exec('INSERT INTO c_i_elgg_config SELECT * FROM elgg_config');
echo 'Test tables created.' . PHP_EOL;
"
CRITICAL: You must also copy entity/metadata/relationship data so plugins are recognized in the test environment:
docker compose -f docker/docker-compose.yml exec elgg php -r "
\$pdo = new PDO('mysql:host=db;dbname=elgg', 'elgg', 'elgg');
foreach (['entities','metadata','private_settings','entity_relationships','config'] as \$t) {
\$pdo->exec(\"TRUNCATE TABLE c_i_elgg_\$t\");
\$pdo->exec(\"INSERT INTO c_i_elgg_\$t SELECT * FROM elgg_\$t\");
}
echo 'Test data refreshed.' . PHP_EOL;
"
Re-run this refresh after activating/deactivating plugins or changing plugin settings. The test DB is a snapshot โ it doesn't auto-sync with the production prefix.
Unit tests vs Integration tests
Unit tests (\Elgg\UnitTestCase):
- Do NOT boot the full Elgg app โ no database, no plugins loaded
elgg_view_exists() returns false for plugin views (view system not initialized)
- Use for testing pure PHP logic (string manipulation, data transforms, etc.)
- Do NOT test view existence, hook registration, or entity operations in unit tests
Integration tests (\Elgg\IntegrationTestCase):
- Boot the full Elgg app with database
- Plugins are loaded and activated
elgg_view_exists(), elgg_trigger_plugin_hook(), entity CRUD all work
- Require database connection (Docker)
- Use
$this->createUser(), $this->createObject() โ auto-cleaned after test
Rule of thumb: If your test needs Elgg functions, it's an integration test. Most plugin tests are integration tests.
Test class with plugin boot (3.x only)
<?php
namespace MyPlugin;
use Elgg\IntegrationTestCase;
class PluginTest extends IntegrationTestCase {
private static bool $pluginBooted = false;
public function up() {
if (!self::$pluginBooted) {
require_once dirname(__DIR__, 5) . '/start.php';
elgg_trigger_event('init', 'system');
self::$pluginBooted = true;
}
}
public function down() {}
}
In 4.x+, no manual boot needed โ elgg-plugin.php is loaded by the test framework.
package.json (Playwright)
{
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug"
},
"devDependencies": {
"@playwright/test": "1.49.0",
"mysql2": "^3.6.0"
}
}
Test directory structure (complete)
<plugin>/tests/
bootstrap.php # PHPUnit bootstrap
phpunit.xml # PHPUnit config
phpunit/
unit/<Namespace>/ # Unit tests (no DB)
integration/<Namespace>/ # Integration tests (needs DB)
playwright/
package.json # Playwright deps
playwright.config.ts # Playwright config
helpers/
elgg.ts # loginAs(), queryDb(), getEntity(), etc.
tests/
<feature>.spec.ts # One file per feature area
Common Mistakes
| Mistake | Fix |
|---|
Using _elgg_services()->session_manager in 4.x tests | session_manager is 5.x+ only โ use elgg_get_session()->setLoggedInUser() in 3.x/4.x |
| Running integration tests without Docker | Integration tests need database โ use Docker |
| Not cleaning up entities | Use $this->createObject() โ auto-cleaned by Seeding trait |
| Testing implementation details | Test behavior: "entity saved" not "SQL query ran" |
Missing canWriteToContainer args in 4.x | Always pass ($uid, $type, $subtype) |
| Playwright tests only assert UI | MUST also query database to verify side effects โ UI can lie |
| Hardcoded ports in Playwright tests | Use ELGG_BASE_URL env var โ in Docker, base URL is http://elgg |
| Playwright tests not cleaning up test data | Create unique test data per run, or use DB transactions/cleanup |
| Playwright tests skip login | Most Elgg pages require auth โ always loginAs() first |
| No DB assertion after form submit | Form could "succeed" (200) without actually saving โ always verify DB |
Wrong bootstrap path: dirname(__DIR__, 4) | Use dirname(__DIR__, 3) โ tests/ โ plugin/ โ mod/ โ elgg_root/ (3 levels) |
Using elgg_view_exists() in UnitTestCase | View system not initialized in unit tests โ move to IntegrationTestCase |
| phpunit.xml references missing directory | Only include <directory> for suites that exist โ PHPUnit errors on missing dirs |
| PHPUnit not installed in Docker | Run composer require --dev phpunit/phpunit:^9.6 in container first |
| PHPUnit version mismatch | PHP 7.4 = PHPUnit 9.x, PHP 8.1+ = PHPUnit 10.x |
Bootstrap loads engine/tests/phpunit/bootstrap.php | Don't search for Elgg's bootstrap โ load autoloader + test classes + loadCore() directly |
| Elgg 4 rejects plugin with |
Phase 5: CI Setup
Once a plugin has the docker stack and a test suite, scaffold GitHub
Actions workflows so every push and PR runs the same checks the local
docker stack runs. Reference workflows live under
references/ci/ and are copied verbatim โ they resolve PLUGIN_ID
at runtime from composer.json, so no per-plugin substitution is
needed at scaffold time.
CI is forward-looking, not a migration gate. The workflows trigger
on push to main / master and on pull_request:. Migration work
happens on branches like migrate/elgg-5.x and is gated by the local
docker stack (bin/verify-plugin.sh), not by GitHub Actions. The
"completed/failure" runs you may see immediately after scaffolding
are scheduling skips (the branch filter rejects the push), not real
test failures โ they have 0 jobs and 0s duration. Treat scaffolded
workflows as done when committed and pushed; their first real
execution happens when a PR lands or the migration branch is merged
to the default branch.
Scaffold
$SKILL/bin/scaffold-ci.sh
$SKILL/bin/scaffold-ci.sh --plugin-dir=/abs/path/to/plugin
$SKILL/bin/scaffold-ci.sh --force
The script writes:
<plugin>/.github/workflows/tests.yml
<plugin>/.github/workflows/lint.yml
What the workflows do
| File | Jobs | Skips when |
|---|
tests.yml | phpunit, playwright | The plugin lacks docker/docker-compose.yml, tests/phpunit.xml, or tests/playwright/package.json (each job key is a hashFiles() guard). |
lint.yml | php-syntax (matrix 7.4 / 8.1 / 8.3), composer-validate, json, workflow-yaml | Per-job hashFiles() guards โ composer-validate skips when no composer.json, workflow-yaml skips when no .github/workflows/*.yml. |
The test workflow re-uses the per-plugin docker stack
(docker compose up) โ the runner does not install PHP, MySQL, or
Elgg natively. Green CI = green local. There is no separate CI
install path to maintain.
Customizing the workflows after scaffold
The reference templates pin choices that suit the typical Elgg 3.x/4.x
plugin. Change them in the plugin's copy when:
- The plugin targets Elgg 5.x+ (PHP 8.1+) โ bump
phpunit/phpunit:^9.6 to ^10.5 in the Install PHPUnit step.
- The plugin's
composer.json require.php excludes one of the lint
matrix versions โ drop the row from lint.yml's php-version
matrix.
- The plugin uses release branches like
4.x / 5.x โ add them to
the branches: list under on.push.
Debugging a CI failure
Failures upload diagnostics as artifacts:
phpunit-diagnostics/compose.log โ output of docker compose logs.
phpunit-diagnostics/apache-error.log โ Apache/PHP error log from
the elgg container (where fatal errors land).
playwright-report/ โ Playwright HTML report (always uploaded).
playwright-diagnostics/compose.log โ on Playwright failure.
Reproduce locally with the exact same commands the workflow uses:
PLUGIN_ID=<id> docker compose -f docker/docker-compose.yml up -d
docker compose -f docker/docker-compose.yml exec elgg \
vendor/bin/phpunit --configuration mod/$PLUGIN_ID/tests/phpunit.xml --no-coverage
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin/tests/playwright && npm ci && npx playwright test"
See references/ci/README.md for the full design rationale.
Backfilling phpcs into existing docker stacks
Plugins whose docker stacks predate the phpcs gate (added in
elgg-migrate@d09e475) are missing squizlabs/php_codesniffer +
elgg/sniffs from docker/elgg-composer.json and the
phpcs --config-set installed_paths โฆ step from docker/Dockerfile.
Use bin/scaffold-phpcs.sh to backfill them idempotently:
$SKILL/bin/scaffold-phpcs.sh
$SKILL/bin/scaffold-phpcs.sh --plugin-dir=/abs/path/to/plugin
The script:
- Adds
squizlabs/php_codesniffer ^3.9 and elgg/sniffs dev-master
under require-dev in docker/elgg-composer.json (creates the
block if absent; preserves 4-space indent and existing entries).
- Inserts a
RUN vendor/bin/phpcs --config-set installed_paths โฆ
line into docker/Dockerfile after the composer install step.
Both steps are skipped when the content is already present, so
re-running is a no-op. After scaffolding, rebuild the docker image,
run phpcbf to auto-fix violations, then commit:
docker compose -f docker/docker-compose.yml build --no-cache elgg
docker compose -f docker/docker-compose.yml up -d
docker compose -f docker/docker-compose.yml exec elgg \
vendor/bin/phpcbf --standard=Elgg mod/<plugin-id>/ \
--ignore='*/vendor/*,*/tests/*,*/node_modules/*'
git add docker/ && git commit -m "style: add phpcs to docker stack and fix Elgg coding standard violations"
See the git history of an already-migrated plugin for a worked example of this pattern.