Tests and tooling#
How this project verifies that something works β and what it cannot verify.
Tests#
tests/ β 1,092 PHP files (find tests -name "*.php" | wc -l):
| Suite | Files | What is in it |
|---|---|---|
Unit/ |
650 | unit tests β per module (Bazaar/, Eshop/, Invoicer/ β¦) and cross-cutting (Security/, Hydrators/, Filters/, Routers/, Cache/, Core/, System/); 4 JS tests in Unit/JavaScript/ (*.test.mjs) |
Integration/ |
33 | a pass across several layers: database tests (*Mysql*, Transactions/), the Api, Redis and Elasticsearch |
Manual/ |
354 | manual scenarios and CLI harnesses; 224 of them in the archive Manual/_archive/ |
Fixtures/, Support/ |
52 | data and helper classes |
E2E/ |
16 scenarios | Playwright scenarios in a browser (*.spec.mjs) |
One runner: tests/run.sh#
A single script runs every test level:
tests/run.sh # T1 (the default)
tests/run.sh T0 # static checks only
tests/run.sh T4 # everything
tests/run.sh T1 --coverage
The levels are cumulative: T2 also runs T0 and T1.
| Level | What it runs | Needs |
|---|---|---|
T0 static |
PHPStan, the PHPStan baseline ratchet, the layer boundary ratchet (layer_boundaries_check.php), the comment style (comment_guard.php --style-only) |
nothing |
T1 unit |
the PHPUnit Unit suite and the JS tests (npm run test:js) |
nothing |
T2 DB |
database tests (files with RUN_DB_TESTS) and Elasticsearch |
the development database; a snapshot of it is saved before the run (tests/db-snapshot.sh) |
T3 Api |
the PHPUnit Integration suite against a running development server |
a server on http://127.0.0.1:8000, Redis |
T4 crawl |
the front-end and administration crawl, the resave harnesses, the sign-in, payment and advert probes, sitemaps | a running development server |
Each step prints one β/β line, the full output of a failure is in tests/.run-logs/<step>.log,
and the run ends with TESTS-OK or TESTS-FAIL. Skipped tests are counted in the step's line
("N pΕeskoΔeno"), so a green step that measured nothing does not stay hidden.
A green run on a clean tree is the deployment gate
After a success with no uncommitted changes in app/, config/, tests/,
www/index.php and composer.lock, the script writes the line <commit> <level> to
tests/.last-run. The deployment script deploys only a commit that has at least T1
there, see Deploying code.
T2 writes to the database
Level T2 and above run against the development database and write to it. Never run
them against production data and never run two of them at once: a deadlock cancels the
test's transaction and its writes stay in the database.
composer test (the Unit suite only) and composer test-all (all of PHPUnit) remain for a
quick run without the other steps.
Elasticsearch#
The tests tests/Integration/Elasticsearch*Test.php run in T2 against the Elasticsearch
given in the ES_TEST_HOSTS variable (a comma-separated list, single-node without security):
They write only to their own indexes prefixed test_ and delete them afterwards; they use
neither the application's indexes (cms_*) nor its configuration. Without ES_TEST_HOSTS
they take the hosts from the application configuration. When Elasticsearch is unavailable the
tests are skipped with a reason; the step summary shows them as "N pΕeskoΔeno", not as a
failure.
E2E scenarios (Playwright)#
tests/E2E/scenarios/*.spec.mjs walk through the site in a browser: registration, signing in,
inserting and managing an advert, an e-shop order, search, switching the language, rendering
every template and more.
| What | Value |
|---|---|
| the site address | E2E_BASE_URL, default http://127.0.0.1:8000 |
| when a case fails | besides its own assertions also on a 5xx response, a Tracy bluescreen, an uncaught JS error and a JS dialog (the XSS probe) β the shared fixture tests/E2E/support/guard.mjs watches for them |
| summary | a scenario Γ case Γ result table in tests/.e2e-report/summary.md |
E2E is not part of tests/run.sh; the scenarios write to the database and may send emails,
so run them only against a development instance with outgoing mail blocked.
Code coverage#
tests/run.sh T1 --coverage runs the Unit suite with the pcov extension and saves an HTML
report to tests/.coverage/ (summary in tests/.coverage/summary.txt). Coverage per module is
computed from a clover XML by a script:
php -d pcov.enabled=1 -d pcov.directory=app vendor/bin/phpunit --testsuite Unit --coverage-clover /tmp/clover.xml
python3 tests/coverage_by_module.py /tmp/clover.xml
A module is the segment after app/UI/<layer>/, the rest counts as Core.
Archive of one-off harnesses#
tests/Manual/_archive/ holds scripts from closed stages (the subfolder structure matches the
original tests/Manual/<Module>/). They are not maintained: their paths no longer fit after the
move and you have to adjust them before running. The active set is tests/Manual/Verify (run by
T4), tests/Manual/Admin and the tools in System/, Security/ and Comments/.
Structural tests#
A category of its own: tests that read the sources and guard that nothing was forgotten. A forgotten guard in one new method breaks nothing β the hole simply stays there.
| Test | What it guards |
|---|---|
ApiPermissionCoverageTest |
that the permission guard is in every internalAction*() |
ApiOperationBoundaryTest |
that the body of every internalAction*() is wholly inside operation() and none calls another internalAction*() |
UnitOfWorkResetBoundaryTest |
that in app/ only UnitOfWorkReset (and the transaction infrastructure) calls clear(), detach() and reopens the EntityManager |
TranslationKeyLintTest |
without a database: the names and content of the translation files, the same keys in every language, and that every static key from Latte and PHP exists in the catalogue |
PermissionResolverTest |
that the resolver's SQL contains no privilege groups |
FieldAccessCoverageTest |
that a field with a suspicious name is annotated or has an exception with a reason; that Audience::Internal is only on the read axis |
OwnerWritableFieldsTest |
what an owner may write through an external save β for every entity with an owner grant against an allowlist, failing in both directions |
QueryPathPolicyTest |
that no filter, ordering or column of a non-privileged caller reaches a hidden field or personal data |
SafeUnserializeTest |
that app/ has no unserialize() without allowed_classes |
SqlMigrationPlannerTest |
the selection and order of migrations, the baseline, reporting changed and missing files; no rollback among migrations in sql/ |
ConditionallyUpdatableContractTest |
that the named check method exists |
ExternalReadonlyCoverageTest |
that server-computed fields cannot be written from outside |
A tautological guard proves nothing
A check comparing a value with itself always passes. Verify every guard with a counter-example β that is, by showing when it does not pass.
A seed in a fixture is usually a sign of missing code
If a test has to add data by hand to pass, it usually means the application does not add it. Before adding a seed, check whether it should not be created in the code.
What tests will not catch#
This is the most important section of the chapter.
| Bug | Why it passes | What catches it |
|---|---|---|
the form cannot be submitted (mixed in onSuccess) |
building the form and the Api endpoint beneath it both pass | a POST or a live click-through |
setValidationScope([]) empties the values |
tests go through the Api with a hand-written payload | a live click-through |
| a CLI harness without a token returns emptiness | it returns emptiness without an error | checking that data really arrived |
| a snippet with the error message is not redrawn | the failure is silent | clicking through a browser |
A live click-through reveals what a harness cannot
A CLI run bypasses the layers that apply in a browser β forms, snippets, signals, JavaScript. Before declaring something done, click through it.
A CLI harness needs an internal token
Without one it runs as guest, gets Permission denied inside the bridge and returns
emptiness with no error. A test over that passes green and tests nothing. The fix:
ApiBridge::setInternalAuthToken($jwtManager->generateToken(β¦)) β in detail
Calling the API.
Equal Elasticsearch scores are no proof of ordering
When documents share a score, the order is decided by the index's internal order β and that can change with no code change at all. A test over such a list only proves the search returned something.
PHPStan#
phpstan.neon, level 9. The baseline, stubs and the Doctrine loader live in the phpstan/
folder (paths in the baseline are relative to it, i.e. path: ../app/β¦); the baseline is a
single file with a few deliberately suppressed entries. tests/run.sh T0 runs it.
A dead path in a baseline prevents the run ENTIRELY
PHPStan exits with an error and the analysis never happens β and because the output does not look like a finding, it is easily mistaken for "it passed". After renaming or deleting a file, go through the baselines.
The baseline may only shrink
tests/Manual/System/phpstan_baseline_ratchet.php sums the suppressed errors in all
phpstan/*.neon and compares the sum with the lowest one reached, stored locally in
phpstan/var/baseline-ratchet.txt. When the sum grows, the commit stops: fix a new error
in the code, not in the baseline. After regenerating the baseline always run PHPStan
again and check for [OK].
The pre-commit hook#
.githooks/pre-commit (activate with git config core.hooksPath .githooks) blocks four
things:
- debug calls in PHP β
bdump(,dumpe(,dd(in added lines, :rootin component SCSS β tokens belong on the component's selector, not in the global scope (the allowlist exempts the token layers),- violations of the layer ratchet
Manager β Service β Mapper β Repository, - growth of the PHPStan baseline (the ratchet above; only when the
phpstan/folder exists).
--no-verify bypasses all four
It exists for exceptions, not for haste. Whatever slips through --no-verify comes up
at code review β or in production a month later.
Building assets#
npm run build:admin # the administration's JS and CSS
npm run build:admin:css
npm run build:admin:js
npm run watch:admin:css
The front-end theme is built from its own folder β see Front-end themes.
npx sass is a different compiler version than sass
It produces a different output. Use the commands from package.json.
OpenAPI specification#
The machine-readable Api description (www/api-docs.json, browser at /api-docs/) is generated
from the #[OA\β¦] attributes in the code by swagger-php:
The root of the specification (version, servers, security schemes, tags) is
app/UI/Api/OpenApiSpec.php; individual operations are annotated directly on presenter actions.
The class is abstract on purpose: Nette DI skips it when scanning app/UI (it is not a service)
and swagger-php still reads its attributes through reflection.
Procedure: annotating a new endpoint#
- Add
#[OA\β¦]attributes to the presenter action (example:app/UI/Api/System/Presenters/AuthPresenter.php). - Add the file path to the
api-docsscript incomposer.jsonβ the generator reads only the listed files, not the whole folder. - Run
composer api-docsand check the operation at/api-docs/.
A file missing from the script's list does not get into the specification
The generator reports no error; the operation is simply missing. The files of the schemas the
annotations refer to (Result, JwtPayload) must be in the list too.
Skills#
skills/ β 19 procedures. They are not recommendations; they are the binding
description of how a particular thing is done in this project.
| Group | For |
|---|---|
code-standard/ |
conventions per layer (PHP, model, presenter, component, forms, signals, Latte, Core) |
cs-admin-* |
the administration β grid, form, picker, form audit |
cs-template-* |
templates and themes |
cs-translations |
translations |
cs-scss-structure |
SCSS |
cs-module-refactoring, cs-payment-gateway, cs-ui-audit |
end-to-end procedures |
Open the relevant skill before a task β not all of them
Every cs-conv-* asks for its own dependencies. The overview is in
Code conventions.
Diagnostic logging#
An application Debugger::log($message, 'channel') does not reach log/ automatically.
Channels are filtered by App\Core\Logging\Logger, a decorator around the Tracy logger. It
is wired only by config/Shared/logging.neon, which overrides the DI service tracy.logger,
so it applies on the website, in cron and in every CLI script that builds the container from
the same configuration.
| Channel | Decided by |
|---|---|
Tracy levels (debug, info, warning, error, exception, critical) and auth |
always pass, the filter cannot switch them off |
a dotted path (save.error, eshop.import) |
the logging.channels tree; the first true/false on the path also applies to unlisted descendants (save: true enables save.anything too) |
| a channel not in the tree | silent (fail-closed) |
logging.all: true |
enables everything at once |
The channel is also the file name (Tracy lowercases it):
Debugger::log($e, 'eshop.import') writes to log/eshop.import.log.
Procedure: adding a new channel#
- Pick a subchannel under an existing branch (
delete.advert,invoicer.payment), or a new bare name. - Write it into
config/Shared/logging.neonastrue, or knowingly asfalse. - Run
tests/Unit/Core/LoggingChannelRegistrationTest.php. It checks that every channel used inapp/is in the file and that a bare channel does not collide with a branch. - Delete
temp/cache. The tree is a DI container parameter; without deleting it the old one applies.
Procedure: switching diagnostics on while hunting a bug#
- Put the key
logging: all: trueunderparameters:in the server'sconfig/Shared/local.neon, or just one branch (logging: channels: modal: trace: true). - Delete
temp/cache. No deployment is needed. - Once the bug is found, switch it back and delete the cache again;
all: truein production also writes the noise the channels were switched off for.
A bare channel that is a branch in the tree never switches on
Debugger::log($x, 'delete') is silent, because delete is an array in the
configuration (delete.rollback, delete.advertβ¦) and there is no true on the path.
Rewriting the branch to delete: true would in turn drop the subchannel settings. Use
a subchannel.
The test does not see a channel from a constant
The registration test reads only a string literal in the second argument (and skips
Legacy/ folders). A channel passed as a constant (self::LOG_CHANNEL) or a variable
has to be written into logging.neon by you, otherwise its log is silently dropped.
Log cleanup touches only the root of log/
The cron log/purge-old (App\Core\Logging\LogRetention) deletes Tracy dumps and
rotates large *.log files only directly in log/. Move a dump you want to keep into a
subdirectory (e.g. log/saved/). Rotating to .log.1 while running loses nothing: Tracy
opens the file on every write and the next line creates a new one.
π΄ Develop on your own copy of the data#
Do not experiment on production data
Develop and test against your own copy of the database, not against the data of a running site. Even there, run experiments in a transaction with a rollback, and before rewriting anything in bulk, count how many rows it affects.
The database, the files and the cache are three different places
Generated files (PDFs, images, feeds) sit on the disk of the machine that created them, and the cache in the Redis that machine is connected to. When two machines point at the same database, a missing PDF need not mean a generator bug, and clearing the cache on one does not clear it on the other. Verify where it is supposed to work.
Where to look#
| I want | Where |
|---|---|
| every test level | tests/run.sh [T0βT4] [--coverage], see One runner |
| the unit tests | tests/Unit/ |
| browser scenarios | tests/E2E/scenarios/, npm run test:e2e |
| the manual harnesses | tests/Manual/ |
| the static analysis configuration | phpstan.neon + phpstan/ |
| the pre-commit checks | .githooks/pre-commit |
| the build commands | package.json, composer.json |
| the binding procedures | skills/ |
| switch on or register a log channel | config/Shared/logging.neon, see Diagnostic logging |
| to run the SQL migrations, a preview without writing | php bin/migrate.php [--dry-run], see SQL migrations |
| diverged copies of frontend scripts between templates | php bin/theme-js-sync.php [--canonical=β¦] [--apply], see Syncing scripts |
| which settings keys are read | php bin/settings-usage.php, see The settings read probe |
Follow-up chapters: Code conventions Β· Deployment checklist Β· Calling the API Β· RBAC in the API