Testing Guide
Audience: plugin contributors.
The pre-push hook calls make test-docker. A push without a green gate is rejected.
Test layers
| Layer | Location | What it covers | Runtime |
|---|---|---|---|
| PHP lint | make lint | Syntax errors only. Catches typos before any other tool runs. | sub-second |
| PHPCS (WPCS) | make phpcs | WordPress Coding Standards. Zero errors and zero warnings required. | seconds |
| PHPStan | make phpstan | Level 9 static analysis. WP stubs via szepeviktor/phpstan-wordpress. | tens of seconds |
| PHPUnit | make phpunit (tests/Unit/) | Pure-PHP logic in isolation. WordPress functions stubbed via 10up/wp_mock. | seconds |
| Semgrep | make semgrep | SAST rules from --config=auto and the bundled MCP server. | tens of seconds |
| Coverage | make coverage → coverage.xml | PHPUnit Clover coverage. Requires pcov or xdebug. | tens of seconds |
| Playwright E2E | make e2e / make e2e-docker | 64 sections in testing/scripts/test_helpdesk_pw.py against a real WP stack. | minutes |
make test chains lint → phpcs → phpstan → phpunit → semgrep on the host. make test-docker runs the same chain inside the phptest container — preferred because no host PHP / Semgrep install is needed.
When to add what
| Change | Required test |
|---|---|
| New user-facing feature | New numbered Playwright section. |
| Bug fix (regression) | New or extended section that covers the bug scenario. |
| Admin-only UI change | New or extended admin section. |
| Pure logic helper (no WP API surface) | PHPUnit case under tests/Unit/. |
| Internal refactor with no UX change | None required — but existing sections must still pass. |
| Security fix | Extend or add a security-marked Playwright section. |
If a change touches both logic and UX, both layers get tests.
New Playwright sections continue from the next available number (currently 65). Add the section to the taxonomy table below.
How to run
Local gate
make test-docker # Required before push. Full PHP gate in Docker.
make e2e-docker # Self-contained E2E: spin up + setup + test + teardown.
make test-all # test + e2e (host PHP path; requires WP_MODE=docker or SSH env).
Individual tools
make lint # PHP syntax check on all plugin files
make phpcs # WordPress Coding Standards
vendor/bin/phpcbf && make phpcs # auto-fix then re-check
make phpstan # Level 9
make phpunit # Unit tests
make semgrep # SAST
make coverage # → coverage.xml
make bench # Performance baseline against Docker stack (COUNT=N to override)
Self-contained Docker E2E
make e2e-docker
Or manually:
docker compose -f docker-compose.test.yml up -d db wordpress wpcli mailhog
bash docker/setup-test-wp.sh
WP_MODE=docker MAILHOG_URL=http://localhost:8025 make e2e
docker compose -f docker-compose.test.yml down -v
MailHog: when MAILHOG_URL is set and WP_MODE=docker, expect_email() asserts delivery via the MailHog API (http://localhost:8025). In SSH mode it falls back to the EMAIL_CHECKS summary printed at the end of the run for manual verification.
CodeRabbit
After opening a PR, run /review. Address every actionable finding before merge.
Playwright test taxonomy
64 sections in testing/scripts/test_helpdesk_pw.py. Marks: smoke, security, slow.
| # | Name | Marks |
|---|---|---|
| 01 | admin_auth | smoke |
| 02 | plugin_verification | smoke |
| 03 | ticket_submission | smoke |
| 04 | admin_locate_ticket | smoke |
| 05 | portal_url | |
| 06 | admin_update_ticket | |
| 07 | technician_workflow | |
| 08 | client_portal | |
| 09 | admin_verify_reply | |
| 10 | portal_close_reopen | |
| 11 | access_control | |
| 12 | ticket_list_filters | |
| 13 | ticket_lookup | |
| 14 | accessibility | |
| 15 | plugin_icons | slow |
| 16 | honeypot_spam | security |
| 17 | form_validation | security |
| 18 | settings_persistence | |
| 19 | canned_responses | |
| 20 | bulk_status_change | |
| 21 | tech2_workflow | |
| 22 | admin_search_and_filters | |
| 23 | file_attachments | slow |
| 24 | portal_token_security | security |
| 25 | xss_escaping | security |
| 26 | subscriber_access_control | security |
| 27 | rate_limiting | security |
| 28 | cleanup | |
| 29 | humanized_timestamps | |
| 30 | resolved_cta_layout | |
| 33 | csat_prompt | |
| 34 | my_tickets_dashboard | |
| 35 | portal_guest_lookup | |
| 36 | shortcode_attrs | |
| 37 | admin_list_filtering | |
| 38 | admin_list_sorting | |
| 39 | ticket_templates | |
| 40 | first_response_time | |
| 41 | cc_watchers | |
| 42 | categories_taxonomy | |
| 43 | ticket_merge | |
| 44 | sla_breach_detection | |
| 45 | assignment_rules | |
| 46 | reporting_dashboard | |
| 47 | inbound_email_webhook | |
| 48 | timestamp_locale | |
| 49 | dedicated_reply_buttons | |
| 50 | unread_badge | |
| 51 | unread_row_highlight | |
| 52 | email_test_button | |
| 53 | ux_a11y | |
| 54 | responsive | |
| 55 | email_branding | |
| 56 | dark_mode | |
| 57 | toast_notifications | |
| 58 | reports_loading_states | |
| 59 | admin_dark_mode | |
| 60 | email_color_scheme | |
| 61 | skip_to_content | |
| 62 | focus_visible_rings | |
| 63 | csat_focus_management | |
| 64 | aria_live_announcements |
(Section numbers are not consecutive; some are reserved for now-merged work.)
Test architecture
- Session-scoped browser. A single Chromium instance is shared across all tests so login cookies persist. Defined in
testing/scripts/conftest.py. check(). Soft-fail helper. Failures accumulate and surface after each test via an autouse fixture inconftest.py. Prefercheck()overassertfor non-blocking expectations.skip(). Records a skip without aborting the section.as_user(page, user, pass). Context manager — logout, then login, then yield, then logout again.wpcli(cmd). Runs WP-CLI via SSH+docker exec (default), ordocker compose execwhenWP_MODE=docker. Returns the stdout string. Exits non-zero for absent data —wp option get,wp post meta get,wp comment meta getall exit 1 when the key is absent. This is a normal result;wpcli()returns an empty string and callers handle it._clear_rate_limits(). Deletesswh_rl_*rows fromwp_optionsand flushes the object cache. The rate limiter usesget_option()which reads cache first, so both must be cleared._navigate_settings(page). Navigates to settings and removes.wp-pointerelements. Security Ninja and other admin pointers intercept Playwright clicks otherwise.statedict. Carriesticket_id,ticket2_id,portal_url, etc. across sections. Never hardcode an ID.EMAIL_CHECKSlist. Printed at end of run for manual verification in SSH mode. In Docker mode MailHog asserts automatically.
Key gotchas
- Meta key is
_ticket_status(underscore prefix).wp post meta getreturns empty unless you include the underscore. Usewp eval 'echo get_post_meta(ID, "_ticket_status", true);'if the CLI behaves oddly. requiredattributes must be stripped before JS form submit in validation tests, or HTML5 browser validation intercepts the submit and the POST never reaches the server.- WordPress word-level search.
LIKE '%Ticket%'matches “Ticket” and “Ticket2” both. Avoid negative assertions based on title substrings. - WP admin pointers. Security Ninja and similar plugins inject
.wp-pointeroverlays that intercept Playwright clicks. Always call_navigate_settings()on settings page visits. - Bulk action key format.
sanitize_title('In Progress')→in-progress→ action valueswh_status_in-progress. expect_navigation(). Wrap JS-triggered form submits inwith page.expect_navigation():to avoid a race betweenevaluateandpage.content().- Canned response persistence check. Read
el.valueviapage.evaluate(). Input values are not ininner_text(). - File upload form POST + caching plugins. A page-caching plugin can fire an immediate GET after the file POST, overwriting the success HTML in the DOM. Use
expect_navigation()+wait_for_load_state("load")to let it settle, then verify success via WP-CLI meta rather thanpage.content(). - Authorization header stripped in Docker. Apache strips the
Authorizationheader before PHP receives it, regardless of.htaccessrules. For section 47 (inbound webhook), bypass HTTP entirely: construct aWP_REST_Requestand callswh_handle_inbound_email()directly viawp eval.
Environment variables
testing/.env is gitignored. Copy testing/.env.example and fill values. Required keys:
| Variable | Purpose |
|---|---|
WP_URL, WP_LOGIN_URL, WP_ADMIN_URL, WP_SUBMIT_PAGE, WP_PORTAL_PAGE | Test target URLs. |
WP_ADMIN_USER, WP_ADMIN_PASS | Admin login. |
WP_TECH1_EMAIL, WP_TECH1_USER, WP_TECH1_PASS | Technician 1. |
WP_TECH2_USER, WP_TECH2_PASS | Technician 2. |
CLIENT1_NAME, CLIENT1_EMAIL | Client 1 fixtures. |
CLIENT2_NAME, CLIENT2_EMAIL | Client 2 fixtures. |
WP_MODE | ssh (default) or docker. |
SSH_HOST, WP_CONTAINER, WP_PATH | Required when WP_MODE=ssh. |
MAILHOG_URL | MailHog API base, e.g. http://localhost:8025. Enables automated email assertions when WP_MODE=docker. |
Anti-patterns
- Skipping the pre-push gate with
--no-verify. - Asserting
inner_text()for form input values (useel.valueviaevaluate()). - Adding network-dependent tests without a fallback path.
- Hardcoding ticket IDs across sections — use the shared
statedict. - Raising on non-zero
wpcli()return — it is a normal result for absent meta.
CI configuration
.github/workflows/:
| File | Purpose |
|---|---|
php-tests.yml | Lint, PHPCS, PHPStan, PHPUnit, Semgrep gate on push and PR. |
e2e.yml | Playwright E2E against the Docker stack on PR. |
semgrep.yml | Standalone Semgrep scan. |
coverage.yml | PHPUnit coverage report. |
release.yml | Builds simple-wp-helpdesk.zip and creates a GitHub Release on v*.*.* tag push. |
claude.yml | Claude Code automation hooks. |
Update protocol
Update this doc when:
- A new test layer is added (e.g. visual regression, performance gate).
- A new Playwright section lands — bump the taxonomy table.
- A new helper appears in
conftest.py. - A new gotcha bites someone — add it to “Key gotchas” with reproduction.
- An environment variable is added or renamed.
- The CI workflow set changes.