# Testing

**Last review: 2026-08-08.**

There is one test suite: **fast unit tests, no external dependency**. No database, no HTTP, no cache, no
session, no mail, no filesystem writes. The whole suite runs in well under a second, which is what makes it
usable before every push.

## Running

```shell
docker exec -ti raktar24-php-fpm ./vendor/bin/phpunit            # the suite
docker exec -ti raktar24-php-fpm ./vendor/bin/phpunit --testdox  # readable, one line per behaviour
docker exec -ti raktar24-php-fpm ./vendor/bin/phpunit --filter StringHelper
docker exec -ti raktar24-php-fpm composer test                   # same as the first
```

PHPUnit is a **dev dependency**. Production installs with `composer install --no-dev`, so it never reaches the
server — see [DEPLOYMENT.md](DEPLOYMENT.md).

## When it has to run

| When | Rule |
|------|------|
| Before a commit | **When the change touches the tested surface.** Editing a helper, a status vocabulary or the calculator means running it. A view or an SCSS change does not. |
| Before a push | **Always.** No exceptions. A red suite is not pushed. |
| Weekly | The review below. |

The full matrix — including when nothing runs at all — is
[Which gates run](WORKFLOW.md#which-gates-run). A gate that cannot see the change proves nothing and costs
minutes.

**The `pre-push` hook enforces the second row.** `bin/hooks/pre-push` runs `composer test` in the
`raktar24-php-fpm` container and refuses the push on a red suite; `git push --no-verify` bypasses it. It is a
symlink installed by `./bin/install-hooks`, which has to be run **once per clone** — and a missed run is
silent, nothing warns that the hook is not there. There is deliberately **no pre-commit hook**: the workflow
commits once per review round and amends small corrections, and a gate there would tax exactly those.

## How it is wired up

- [`phpunit.xml.dist`](../phpunit.xml.dist) — the suite and the declared scope.
- [`tests/bootstrap.php`](../tests/bootstrap.php) — loads `env.php`, `functions.php` and the autoloader, then
  starts a `yii\web\Application` carrying **only** `formatter`, `i18n` and `urlManager`.
- [`tests/TestCase.php`](../tests/TestCase.php) — the base class. Restores `$_COOKIE` and
  `Yii::$app->language` after every test, so test order never matters.
- Tests live under `tests/unit/`, mirroring the directory they test. The namespace is `app\tests\unit\…`, which
  the existing `app\` PSR-4 rule already resolves — no separate autoload entry.

**There is no `db` component on purpose.** `Yii::$app->db` throws, and
`BootstrapTest::testDatabaseIsUnreachable()` asserts that it does. A test that accidentally reaches for a table
fails loudly instead of quietly opening a connection and slowing the suite down.

A web application is used rather than a console one because the tested code is web code (`Url`, `JsonLdHelper`)
and because only `yii\web\Application` has a `homeUrl`. The bootstrap pins `$_SERVER['SCRIPT_FILENAME']` and
`$_SERVER['SCRIPT_NAME']` so the `@web` / `@webroot` aliases do not come from the PHPUnit binary.

## The tested surface

This is the list the weekly review works from. **Fully covered** means every public method has a test; those
files are also the `<source>` allowlist in `phpunit.xml.dist`.

| File | Coverage |
|------|----------|
| [`helpers/CookieHelper.php`](../helpers/CookieHelper.php) | full |
| [`helpers/Email.php`](../helpers/Email.php) | full |
| [`helpers/Geo.php`](../helpers/Geo.php) | full |
| [`helpers/Language.php`](../helpers/Language.php) | full |
| [`helpers/Property.php`](../helpers/Property.php) | full |
| [`helpers/StringHelper.php`](../helpers/StringHelper.php) | full |
| [`helpers/StyleHelper.php`](../helpers/StyleHelper.php) | full |
| [`helpers/Url.php`](../helpers/Url.php) | full |
| [`helpers/Validation.php`](../helpers/Validation.php) | full |
| [`models/PaymentMethod.php`](../models/PaymentMethod.php) | full |
| [`models/PaymentStatus.php`](../models/PaymentStatus.php) | full |
| [`models/RentStatus.php`](../models/RentStatus.php) | full |
| [`modules/admin/helpers/LabelingHelper.php`](../modules/admin/helpers/LabelingHelper.php) | full |
| [`modules/admin/models/ProposalRequestItemCalculator.php`](../modules/admin/models/ProposalRequestItemCalculator.php) | full |
| [`functions.php`](../functions.php) | partial — everything except `p()` and `i()`, which read the database |
| [`services/StorageSearchService.php`](../services/StorageSearchService.php) | partial — `columnFacet()` and `sizeFacet()` only; the rest queries |

**What stays out, and why.** Controllers, widgets, views, ActiveRecord models and most services are database-,
request- or session-bound. Testing them means a fixture database and a bootstrapped request, which is a
different suite with a different cost — and the rule here is that the suite stays fast enough to run before
every push. `AiTranslationService`, `TellGateService` and `BillingoHelper` additionally talk to third-party
APIs.

## Writing a test

- **Table-driven by default.** `#[DataProvider]` with named cases; the case name is the documentation.
- **Assert values, not shapes.** `assertSame` over `assertEquals`, exact strings over `assertStringContainsString`,
  unless the point of the assertion really is the shape.
- **Every boundary.** Off-by-one, empty input, null, the unknown enum value, the value exactly on a bucket edge.
- **Locale- and ICU-independent.** Pass an explicit date format instead of relying on the formatter's default,
  and build strings that contain a non-breaking space from `"\u{00A0}"` rather than pasting one in.
- **No database, ever.** A class that needs an ActiveRecord gets `createStub()` with its `__get()` stubbed —
  see the calculator test. If a stub is not enough, the code belongs outside the tested surface, or it needs a
  refactor first.
- A defect found while writing a test is **reported, not silently pinned.** A test that enshrines wrong
  behaviour is worse than no test.

## Weekly review

If more than a week has passed since the date at the top of this file, run a review before starting new work:

1. **Is there testable code that is not tested yet?** Walk `helpers/`, `models/`, `services/` and
   `modules/admin/` for anything pure that is missing from the table above. Write the tests, extend the table
   and the `<source>` allowlist.
2. **Do the existing tests still describe reality?** Read them against the code they cover. Update what drifted.
3. **Is anything now unnecessary?** A test for deleted code, a duplicate case, a case that asserts nothing
   the others do not — delete it. The suite is only useful while it stays fast and honest.
4. Update the review date at the top of this file.
