Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2746307
Adds a PHPUnit suite for code that needs no database
albertlast Jul 29, 2026
120f6a9
Merge branch 'fix/action-trait-subclass-instances' into tests/phpunit
albertlast Jul 29, 2026
10dfe94
Merge branch 'fix/createpost-notify-time-offset' into tests/phpunit
albertlast Jul 29, 2026
a142e5c
Runs the unit tests in CI and documents them
albertlast Jul 29, 2026
99ee104
Broadens the unit tests to the rest of the stateless surface
albertlast Jul 29, 2026
560dfb2
Compares URL schemes case insensitively
albertlast Jul 29, 2026
4caec1d
Merge branch 'fix/sapi-memory-return-bytes' into tests/phpunit
albertlast Jul 29, 2026
f9d38cd
Merge branch 'fix/url-is-scheme-case' into tests/phpunit
albertlast Jul 29, 2026
7d656ce
Turns the two noted defects into regression tests
albertlast Jul 29, 2026
3e45ec4
Marks the ActionTrait test as covering a trait
albertlast Jul 29, 2026
fa76555
Merge branch 'docs/agent-instructions' into tests/phpunit
albertlast Jul 30, 2026
b5f342f
Documents when the unit test suite can cover a change
albertlast Jul 31, 2026
8c939ce
Merge remote-tracking branch 'origin/release-3.0' into tests/phpunit
albertlast Aug 5, 2026
8c110ef
Merges release-3.0 into the test branch
albertlast Aug 13, 2026
5db44eb
Covers last week's fixes that the unit suite can reach
albertlast Aug 13, 2026
fa113d5
Merges the section banner spacing fix into the test branch
albertlast Aug 13, 2026
bcfcccb
Merges release-3.0 into the unit test branch
albertlast Aug 16, 2026
6e8bf15
Follows TimeInterval back to DateInterval's own constructor
albertlast Aug 16, 2026
2d609b3
Merges the updated unit test branch, and release-3.0 with it
albertlast Aug 16, 2026
610be14
Merge branch 'pr-9511' into tests/sni_fetch_safe
albertlast Aug 20, 2026
e98cbe4
Covers the SNI regression in the unit suite
albertlast Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: PHPUnit

on:
push:
branches:
- release-3.0
pull_request:

jobs:
phpunit:
name: Unit tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: [ 8.4, 8.5 ]

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2

- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 #2.32.0
with:
php-version: ${{ matrix.php }}
coverage: none

- name: Cache Composer packages
id: composer-cache
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf #4.2.2
with:
path: vendor
key: ${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-php${{ matrix.php }}-

- name: Install dependencies
if: steps.composer-cache.outputs.cache-hit != 'true'
run: composer install --prefer-dist --no-progress --ansi

- name: Run the unit tests
run: vendor/bin/phpunit --no-coverage --colors=always
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,7 @@ vendor/
.phplint-cache
.phplint.cache
composer.phar

# PHPUnit
.phpunit.cache/
.phpunit.result.cache
85 changes: 76 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,83 @@ param, throws, return.

## Verifying a change

**There is no test suite.** No PHPUnit, no `tests/` directory, nothing in the history.
CI only proves that the code parses (`phplint` on 8.4 and 8.5) and is formatted
correctly. It never executes SMF. Do not assume green checks mean a change works.
### Tests

So verify by running the forum. The repository ships a Docker environment, documented
in full in `.docker/README.md`:
There is a unit test suite. It is small and deliberately narrow, but where it reaches,
it is the only automated proof that a change does what it claims:

```bash
composer test # or: vendor/bin/phpunit
```

CI runs it on every pull request, and on pushes to `release-3.0`, via
`.github/workflows/phpunit.yml`. Feature branches are only checked once they are in a PR,
so run it locally.

**The expectation: if the code you touched is reachable from this suite, your change
adds or updates a test in the same commit.** A bug fix lands as a regression test that
fails before the fix and passes after it, with a comment saying what went wrong — see
`SapiTest::testAPlainByteCountKeepsItsLastDigit()` for the shape. When the code is not
reachable, say so explicitly in the PR description rather than leaving it unsaid; do not
contort production code, add mocks or fake a database to force something under test.

#### When a test is possible

`tests/bootstrap.php` defines the constants `index.php` would define, points the
autoloader at `Sources/` and sets `Config::$boarddir`, `$sourcedir`, `$packagesdir`,
`$languagesdir`, `$cachedir` and `$language`. That is all. No `Settings.php`, no
database, no request. Within those limits the following are all testable, and each has a
worked example in `tests/Unit/`:

- **Pure and static helpers**: `Utils::buildRegex()`, `Sapi::memoryReturnBytes()`,
`Security::hashPassword()`. Cheap to cover with a `#[DataProvider]`.
- **Value objects that parse or normalise a string**: `IP`, `Url`, `Uuid`,
`TimeInterval`, `Punycode`. Construct one and assert on the result.
- **Class-level behaviour that needs no state**: late static binding, shared statics,
what `Foo::load()` returns. `ActionTraitTest` is entirely this.
- **Protected and private helpers**, through `ReflectionMethod`, when the public entry
point around them needs a database but the helper itself does not
(`CreatePostNotifyTest::getTimeOffset()`).
- **Code that reads a few `Config::$modSettings` keys.** Set them in `setUp()` and
`unset()` them in `tearDown()`. PHPUnit does not reset SMF's statics between tests, so
a key left behind leaks into every test that follows.
- **Anything that only needs the language or Unicode data files**, since the bootstrap
sets the paths they look in.

#### When it is not

- Anything calling `Db::$db` — there is no connection, and faking one is not worth it.
- Anything reading `User::$me`, the session, `$_GET`/`$_POST`/`$_SERVER`, or expecting a
loaded theme or `Utils::$context`.
- Anything that emits output or sends headers. `beStrictAboutOutputDuringTests` is on, so
a stray `echo` fails the test rather than being swallowed.

`failOnRisky` and `failOnWarning` are on as well: a test that asserts nothing is a
failure, not a pass.

#### Writing one

`tests/Unit/<Class>Test.php`, namespace `SMF\Tests\Unit`, `declare(strict_types=1)`,
extending `PHPUnit\Framework\TestCase`, with `#[CoversClass]` (or `#[CoversTrait]` for a
trait) on the class. Name the test after the behaviour, not the method —
`testItNormalisesIPv6ToItsShortestForm()`, not `testConstruct()`. New directories need
the usual `index.php` stub.

The code style rules apply to tests too, so run `composer lint-fix` on them. Two
consequences of the fixer worth knowing before you fight it:

- Data providers are `public static`, so `ordered_class_elements` moves them *below* the
public test methods, into their own `Public static methods` banner.
- The `SMF/section_comments` fixer inserts a banner between an attribute and the method
it belongs to. Do not let a method carrying `#[DataProvider]` be the first one in its
group; `CreatePostNotifyTest` carries a note about this.

### Running the forum

The rest of CI only proves the code parses (`phplint` on 8.4 and 8.5) and is formatted.
So a fully green PR still tells you very little about whether a change works. Verify by
running the forum. The repository ships a Docker environment, documented in full in
`.docker/README.md`:

```bash
docker compose up -d --build
Expand Down Expand Up @@ -146,10 +217,6 @@ docker compose exec postgres psql -U smf -d smf -c 'SELECT * FROM smf_log_errors
`smf_log_errors` is the first place to look. Many failures are recorded there rather
than shown, especially anything in a background task.

Some code is reachable with only the autoloader plus the constants that `index.php`
defines, which is enough to exercise pure helpers without a database. Anything that
touches `User::$me` or `Db::$db` needs a real request or fixtures.

## Things that bite in this codebase

- **Typed properties with no default throw when read before assignment.** Several are
Expand Down
3 changes: 3 additions & 0 deletions Sources/Url.php
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,9 @@ public function isWebsite(): bool
/**
* Check if this URL uses one of the specified schemes.
*
* Scheme names are case insensitive, per RFC 3986, section 3.1, and this
* class does not normalize them, so both sides are folded before comparing.
*
* @param string|string[] $scheme Schemes to check.
* @return bool Whether the URL matches a scheme.
*/
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
"prefer-stable": true,
"require-dev": {
"simplemachines/build-tools": "dev-release-3.0",
"friendsofphp/php-cs-fixer": "^3.95"
"friendsofphp/php-cs-fixer": "^3.95",
"phpunit/phpunit": "^13.1"
},
"scripts": {
"test": "phpunit --no-coverage",
"lint": "php-cs-fixer --quiet check --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes || php-cs-fixer check --diff --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes",
"lint-fix": "php-cs-fixer fix -v --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes",
"post-install-cmd": "php ./vendor/simplemachines/build-tools/secure-vendor-dir.php",
Expand Down
Loading
Loading