Testing

Testing

Niang\Core\Testing\TestCase simulates requests by calling the Router directly, with no real HTTP server — checked against packages/foundation/src/Testing/*.php and a real framework test (PasswordResetTest).

TestCase: simulating a request

php

namespace Tests\Feature;

use Niang\Core\Testing\TestCase;

class HomeTest extends TestCase
{
    public function test_home_page_loads(): void
    {
        $this->get('/')->assertOk()->assertSee('NiangPro');
    }
}

get(), post($uri, $data), put(), patch(), delete() — and call($method, $uri, $data, $headers) underneath them all. Each builds a Request and passes it through Application::handle(), exactly the path a real HTTP request would take (middleware included).

setUp() rebuilds a fresh Application for every test (routes reloaded, Service Providers re-run), resets Event and Queue (otherwise listeners would pile up from one test to the next), and runs pending migrations. APP_ENV=testing (set by phpunit.xml) loads .env.testing instead of .env:

.env.testing
APP_ENV=testing
DB_CONNECTION=sqlite
DB_DATABASE=:memory:

An in-memory SQLite database, never storage/database.sqlite: the test suite never touches your local development data.

Response assertions

get()/post()/... return a chainable TestResponse:

MethodChecks
assertStatus($code)Exact HTTP code (prints the response body if the assertion fails).
assertOk()Shortcut for assertStatus(200).
assertRedirect($to = null)301/302, and optionally the value of the Location header.
assertSee($text) / assertDontSee($text)Presence/absence of a substring in the body.
assertJson($subset)The decoded JSON contains at least these keys/values; nested objects are compared the same way (['data' => ['email' => ...]] ignores the other fields of data), a list exactly.
json() / content() / header($key) / status()Raw access, for direct PHPUnit assertions.

RefreshDatabase

The :memory: database lives for the whole PHPUnit run (unlike a real web process): a test that writes to the database must undo its own writes so as not to pollute the following ones.

php
use Niang\Core\Testing\RefreshDatabase;
use Niang\Core\Testing\TestCase;

class PasswordResetTest extends TestCase
{
    use RefreshDatabase;

    public function test_a_user_can_reset_their_password_end_to_end(): void
    {
        User::create(['name' => 'Awa', 'email' => 'awa@example.test', 'password' => Hash::make('old-password-1234')]);
        // ... write to the database freely: everything is rolled back after this test.
    }
}

The trait wraps each test in a transaction (DB::beginTransaction() in setUp(), DB::rollBack() in tearDown()) — add it (use RefreshDatabase;) to any test that writes to the database; it is never enabled by default.

Mail::fake()

Switches the mail driver to 'array' for the duration of the test, whatever MAIL_MAILER is in .env.testing — see Mail:

php
protected function tearDown(): void
{
    Mail::reset(); // otherwise the 'array' driver would stay active for the next test
    parent::tearDown();
}

public function test_a_reset_link_is_emailed(): void
{
    Mail::fake();

    $this->post('/forgot-password', ['_token' => Csrf::token(), 'email' => 'awa@example.test'])
        ->assertRedirect('/forgot-password');

    $sent = Mail::sent();
    $this->assertCount(1, $sent);
    $this->assertSame('awa@example.test', $sent[0]['to']);
}

Mail::reset() in tearDown() is the test's responsibility (whereas Event::reset()/Queue::reset() are automatic in TestCase::setUp()) — without it, Mail::fake() would stay active for the next test in the same process.

Unit / Feature / Database structure

phpunit.xml declares three suites, each on its own folder:

SuiteTypical content
tests/Unit/Isolated classes (Container, Validator, Router...) — often without extending Niang\Core\Testing\TestCase, just PHPUnit\Framework\TestCase.
tests/Feature/Full requests through Testing\TestCase — routes, middleware, controllers, responses.
tests/Database/Migrations, Query Builder, eager loading run against a real database (:memory:).
bash
./bin/niang make:test PostsPage   # generates tests/Unit/PostsPageTest.php
composer test                     # vendor/bin/phpunit

make:test always generates into tests/Unit/, with a stub that extends PHPUnit\Framework\TestCase directly (not Niang\Core\Testing\TestCase): change the use and the file location by hand for a Feature or Database test that needs the full application.

Security test suite

bash
vendor/bin/phpunit --testsuite=Security

tests/Security/ holds one attack per test: SQL injection, XSS, CSRF, open redirect, Host header, session fixation, cookies, path traversal, disguised files, mass assignment, IDOR, rate limiting, security headers, debug in production. Every fixed flaw adds its test there.

Code coverage

For the framework itself, CI measures coverage (pcov extension; locally, pcov or xdebug) and enforces a global threshold (78%) plus a threshold per critical component: Router, Container, Database, Auth (including two-factor and OAuth), Validation, HTTP, security. The thresholds live in tools/coverage-check.php.

bash
vendor/bin/phpunit --coverage-clover build/clover.xml
php tools/coverage-check.php build/clover.xml

API stability and deprecations

docs/API_STABILITY.md classifies each public class: stable (no breaking change before the next major version), experimental (@experimental: OpenAPI, OAuth, permissions, SSE, S3 disk...) or internal (@internal). A removed API is first deprecated for at least one version: @deprecated and trigger_deprecation(), logged in production. The core passes PHPStan level 7 and declares strict_types everywhere.

Contributing to the framework

The CONTRIBUTING.md guide covers setup, the checks to run (composer test, lint, analyse) and what a pull request should contain. Report a security flaw privately, never in an issue: see SECURITY.md.

⏱ 7.87 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200