Docs

Testing & QA

MonkeysLegion ships with no test framework baked in, so you’re free to use PHPUnit, Pest, or any runner you like—but the packages are designed to be easy to bootstrap, mock, and assert against.

0 · TL;DR

composer require --dev phpunit/phpunit vimeo/psalm squizlabs/php_codesniffer
vendor/bin/phpunit             # run unit + integration
vendor/bin/psalm --shepherd    # static analysis
vendor/bin/phpcs --standard=PSR12 app/  # code style

Add a GitHub Action to run those three commands and you already catch >90 % of issues before merge.

1 · Directory layout

tests/
├─ Unit/
│  └─ Services/...
├─ Feature/
│  ├─ Http/
│  └─ Console/
└─ Playwright/                # optional UI tests
phpunit.xml.dist

Set bootstrap="tests/bootstrap.php" in phpunit.xml and build the container there:

// tests/bootstrap.php
$container = (new MonkeysLegion\DI\ContainerBuilder())
    ->addDefinitions(require __DIR__ . '/../config/services.php')
    ->build();

$GLOBALS['ml_container'] = $container; // handy for helpers

2 · Unit Tests

namespace Tests\Unit\Services;

use PHPUnit\Framework\TestCase;
use App\Service\Slugger;

class SluggerTest extends TestCase
{
    public function test_it_slugs_utf8(): void
    {
        $slug = (new Slugger())->slugify('Héllö World!');
        $this->assertSame('hello-world', $slug);
    }
}

Keep unit tests pure PHP—no DB, no filesystem. Mock collaborators with Prophecy or Mockery.

3 · Feature / HTTP tests

namespace Tests\Feature\Http;

use PHPUnit\Framework\TestCase;
use Laminas\Diactoros\ServerRequestFactory;

class PostApiTest extends TestCase
{
    private $app;

    protected function setUp(): void
    {
        // bootstrap HTTP kernel once
        $this->app = $GLOBALS['ml_container']
            ->get(MonkeysLegion\Http\MiddlewareDispatcher::class);
    }

    public function test_list_posts_returns_empty_array(): void
    {
        $req  = ServerRequestFactory::fromGlobals()->withMethod('GET')
                    ->withUri(new \Laminas\Diactoros\Uri('/posts'));

        $res  = $this->app->handle($req);

        $this->assertSame(200, $res->getStatusCode());
        $this->assertJsonStringEqualsJsonString('[]', (string) $res->getBody());
    }
}

Spin up no real server—just dispatch the PSR-7 request against the in-memory middleware pipeline.

4 · Database tests

  1. Point .env.test (or config/env/test.mlc) at SQLite :memory:

  2. In tests/bootstrap.php run CLI migrations:

(new MonkeysLegion\Migration\MigrateCommand(
    $GLOBALS['ml_container']->get(MonkeysLegion\Database\Connection::class)
))->handle();
  1. Wrap each test in a transaction:

protected function setUp(): void
{
    $this->db = $GLOBALS['ml_container']->get(Connection::class);
    $this->db->pdo()->beginTransaction();
}
protected function tearDown(): void
{
    $this->db->pdo()->rollBack();
}

Fast, isolated, no residual state.

5 · Validation & Auth scenarios

public function test_registration_fails_with_short_password(): void
{
    $payload = ['email'=>'a@b.c', 'password'=>'123'];
    $req = (new ServerRequestFactory)->createServerRequest('POST','/auth/register')
           ->withParsedBody($payload);

    $res = $this->app->handle($req);

    $this->assertSame(422, $res->getStatusCode());
    $body = json_decode((string) $res->getBody(), true);
    $this->assertSame('Length', $body['errors'][0]['rule']);
}

ValidationMiddleware automatically returns 422 JSON on rule violations—assert against that.

6 · Front-end / Visual tests

  1. npm i -D @playwright/test

  2. Create tests/Playwright/visual.spec.ts:

test('home page loads', async ({ page }) => {
  await page.goto(process.env.APP_URL ?? 'http://localhost:8000');
  await expect(page).toHaveTitle(/MonkeysLegion/);
  expect(await page.screenshot()).toMatchSnapshot('home.png');
});

Run Playwright in CI after PHP tests; fail on diff.

7 · Static analysis & style

  • Psalm or PHPStan at level 6+

  • PHP_CodeSniffer with PSR-12 or your own ruleset

  • Deptrac (optional) to keep domain layers independent

Add a Composer script:

"scripts": {
  "analyse": "psalm --shepherd && phpcs --standard=PSR12 app/",
  "lint": "php-cs-fixer fix --dry-run"
}

8 · Quality gates in CI (GitHub Actions)

name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: myapp_test
        ports: [3306:3306]
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: 8.4, extensions: pdo_mysql }
      - run: composer install --no-progress --no-interaction
      - run: vendor/bin/phpunit --coverage-text
      - run: composer analyse

Fail the build if tests, static analysis, or style report errors.

9 · Test helpers & traits (optional)

  • DatabaseTransactions trait — starts & rolls back a DB transaction per test.

  • MakesRequests — tiny helper to build PSR-7 requests with JSON / form bodies.

  • WithFaker — seeds Faker v2 for factories.

Add them in tests/Support/ and reuse across the suite.

10 · Recommended coverage

Layer

Coverage target

Domain services

90 % lines / 70 % branches

Repositories

“happy path” + constraint errors

Controllers

1 path per route

Views

visual diff or Playwright snapshots

CLI commands

1 success + 1 failure case

Recap

  • PHPUnit/Pest for code, Playwright for pixels, Psalm for types, phpcs for style.

  • Use DI container + in-memory middleware to test HTTP without spinning up a server.

  • Transaction-wrap DB tests for speed & isolation.

  • Enforce everything in CI so master/main never breaks.

Happy shipping — with confidence! 🐒✅