Skip to content
V
For developers
Architecture, conventions, the core and security
For developers / Tests and tooling

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):

ES_TEST_HOSTS=http://127.0.0.1:9200 tests/run.sh T2

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.

npm run test:e2e
E2E_BASE_URL=http://127.0.0.1:8000 npm run test:e2e
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:

  1. debug calls in PHP β€” bdump(, dumpe(, dd( in added lines,
  2. :root in component SCSS β€” tokens belong on the component's selector, not in the global scope (the allowlist exempts the token layers),
  3. violations of the layer ratchet Manager β†’ Service β†’ Mapper β†’ Repository,
  4. 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:

composer api-docs

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#

  1. Add #[OA\…] attributes to the presenter action (example: app/UI/Api/System/Presenters/AuthPresenter.php).
  2. Add the file path to the api-docs script in composer.json β€” the generator reads only the listed files, not the whole folder.
  3. Run composer api-docs and 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#

  1. Pick a subchannel under an existing branch (delete.advert, invoicer.payment), or a new bare name.
  2. Write it into config/Shared/logging.neon as true, or knowingly as false.
  3. Run tests/Unit/Core/LoggingChannelRegistrationTest.php. It checks that every channel used in app/ is in the file and that a bare channel does not collide with a branch.
  4. 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#

  1. Put the key logging: all: true under parameters: in the server's config/Shared/local.neon, or just one branch (logging: channels: modal: trace: true).
  2. Delete temp/cache. No deployment is needed.
  3. Once the bug is found, switch it back and delete the cache again; all: true in 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

Cookie settings