Unit Tests

Table of contents

  1. Overview
  2. Creating Tests
  3. Running Tests
  4. Testing Configuration
  5. Simulating Controller Output
  6. ApplicationTestCase Assertions
  7. PHPUnit Assertions

1. Overview Table of Contents

The Unit Test system in the Chappy PHP framework enables developers to write automated tests for their applications using a clean, expressive API layered on top of PHPUnit. It includes a custom ApplicationTestCase base class that simulates HTTP requests (get, post, and put) and provides convenient methods for asserting database state, capturing controller output, and validating view data.

This setup is ideal for:

  • Testing controller logic in isolation
  • Verifying database insertions or updates
  • Ensuring CSRF protection works as expected
  • Simulating file uploads or form submissions
  • Confirming correct redirects and rendered content

The test layer integrates tightly with your MVC routing system, allowing you to call routes directly using URI patterns like /auth/register, just as a real user would in the browser.

All tests run in an isolated environment with support for:

  • In-memory SQLite or custom test databases
  • Auto-migration and seeding via your console commands
  • Custom response wrappers for fluent test assertions

🧪 Whether you’re testing form validation, user registration, or database interactions, the Chappy framework’s testing system provides the power and flexibility to help ensure your application is reliable and maintainable.


2. Creating Tests Table of Contents

You can make your own PHPUnit test class by running the following command:

php console make:test ${testName}

By default the make:test places the file under the unit test suite. To create a feature test run:

php console make:test ${testName} --feature

This version of the PHPUnit test class adds support for migrations and database seeding.

Naming Conventions

  • A PHPUnit enforced rule requires that each test case within your class begins with the word test.
  • In order to properly use filtering file this framework prevents you from creating files/classes with the same name across test suites with the make:test command. The filtering command also enforces this rule.
  • The name of your test case functions in all of your classes must be unique. Otherwise you will get confusing messages regarding assertions even when filtering.


3. Running Tests Table of Contents

Run all available tests.

php console test

Run Tests By File

Run all tests in a file that exists within the feature, unit, or both test suites.

php console test ${fileName}

Run A Particular Test

Run a specific test in a file.

php console test ${fileName}::${functionName}

Running Tests With PHPUnit

You can use vendor/bin/phpunit to bypass the console’s test command.

Supported PHPUnit flags

The following flags are supported without running PHPUnit directly using vendor/bin/phpunit. 🧹 Coverage and Logging

Flag Description
--coverage-text Output code coverage summary to console

✅ Output / Display Flags

Flag Description    
<!– --colors=always Always use ANSI colors in output (auto, never, always) –>
--debug Show debugging info for each test (e.g., method names being run)    
--display-deprecations Show deprecated method warnings    
--display-errors Show errors (on by default)    
--display-incomplete Show incomplete tests in summary    
--display-skipped Show skipped tests in summary    
--fail-on-incomplete Mark incomplete tests as failed    
--fail-on-risky Fail if risky tests are detected    
--testdox Print readable test names (e.g., “It returns true on success”)    

🔁 Execution / Behavior Flags

Flag Description
--random-order Randomize test order
--reverse-order Run tests in reverse order
--stop-on-error Stop on error
--stop-on-failure Stop as soon as a test fails
--stop-on-incomplete Stop on incomplete test
--stop-on-risky Stop on risky test
--stop-on-skipped Stop on skipped test
--stop-on-warning Stop on warning

If you have the same function in a class with the same name inside both test suites only the one found within the unit test suite will be executed.

Run A Test Suite

Run all test within a particular test suite by adding the --unit and/or --feature flags.

Run Specific Test File Within A Suite

You can run all test within a specific test file for an individual suite by specifying the file name and adding the --unit and/or --feature flags.


4. Testing Configuration Table of Contents

The Chappy.php framework allows you to run your unit and feature tests against SQLite (in-memory) or MySQL, depending on your project’s requirements.

This gives you flexibility for:

  • ✅ Fast, isolated testing with SQLite
  • ✅ Full-database compatibility testing with MySQL

🧪 SQLite (In-Memory) for Fast Testing

For quick and isolated test runs, SQLite is ideal. It requires no setup and runs entirely in memory.

Configure PHPUnit to use SQLite

Update your phpunit.xml and make sure .env.testing does not exist in your project root:

<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
...

To toggle on or off refresh, migrations, and seeding uncomment out the following:

```xml
...
<!-- Feature test configuration -->
<!-- <env name="DB_REFRESH" value="true"/> -->
<!-- <env name="DB_MIGRATE" value="true"/> -->
<!-- <env name="DB_SEED" value="true"/> -->

Benefits

  • No external service needed
  • Fastest test execution
  • Great for CI pipelines

🐬 MySQL for Real-World Compatibility To test real-world behavior like foreign key constraints or strict SQL modes (ONLY_FULL_GROUP_BY), use MySQL.

Configure PHPUnit to use MySQL by entering the required information in the MySQL/MariaDB section. Leave the SQLite information commented out.

<php>
    <env name="APP_ENV" value="testing"/>
    <!-- In memory SQLite config -->
    <!-- <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/> -->

    <!-- MySQL/MariaDB test DB config -->
    <env name="DB_CONNECTION" value="mysql_testing"/>
    <env name="DB_HOST" value="127.0.0.1"/>
    <env name="DB_PORT" value="3306"/>
    <env name="DB_DATABASE" value=""/>
    <env name="DB_USERNAME" value=""/>
    <env name="DB_PASSWORD" value=""/>

    <!-- Feature test configuration -->
    <!-- <env name="DB_REFRESH" value="true"/> -->
    <!-- <env name="DB_MIGRATE" value="true"/> -->
    <!-- <env name="DB_SEED" value="true"/> -->
</php>

You can keep everything commented out except for the APP_ENV line and make a copy of the .env.testing.sample file and rename it to .env.testing and configure that file.

DB_CONNECTION=mysql_testing
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
DB_REFRESH=true
DB_MIGRATE=true
DB_SEED=true

✅ Requirements

  • MySQL test database must exist (e.g., chappy_test)
  • Test user must have privileges to create/drop tables


5. Simulating Controller Output Table of Contents

The Chappy.php framework provides a convenient test helper method named controllerOutput() that allows you to simulate controller behavior inside unit or feature tests, similar to how routes behave when accessed via a browser.

This method is available within ApplicationTestCase and is ideal for verifying the output of controller actions.

🔧 Syntax

$this->controllerOutput(string $controller, string $action, array $params = []): string

Description of arguments:

  • controller — The lowercase controller slug (e.g. 'home', 'user')
  • action — The lowercase action name without the Action suffix (e.g. 'index', 'details')
  • params — Optional array of route parameters (like path segments)

🧪 Example Usage

✅ Simulate a basic route like /home/index

$html = $this->controllerOutput('home', 'index');
$this->assertStringContainsString('Welcome to Chappy.php', $html);

✅ Simulate a route like /admindashboard/details/1

public function test_feature_example_1() {
    $user = Users::findById(1);

    $username = $user->username;
    $output = $this->controllerOutput('admindashboard', 'details', ['1']);

    $this->assertStringContainsString('Details for '.$username, $output);
}

✅ Behavior Details

  • The controller is instantiated using the same logic as the router (App\Controllers\{Name}Controller)
  • The action is called with any extra parameters (mimicking a parsed URL like /user/edit/3)
  • Output from the controller is captured using ob_start() and returned as a string
  • This method is ideal for asserting against full HTML responses or checking content rendered by views

⚠️ Note on Test Data Since this method operates inside your test environment, ensure the required database records (e.g. users, posts) exist either by:

  • Calling a seeder (e.g. DatabaseSeeder)
  • Manually inserting records
  • Otherwise, calls like Users::findById($id) may return null, causing your view or controller to throw exceptions during the test.


6. ApplicationTestCase Assertions Table of Contents

This framework’s test infrastructure provides convenient assertion helpers to validate the state of the database during tests. These assertions are especially useful in integration and feature tests that interact with the database.

The following support assertions are available in the ApplicationTestCase base class:


A. 🔍 assertDatabaseHas()

$this->assertDatabaseHas(string $table, array $data, string $message = '');

Description: Asserts that a given row exists in the specified database table. This is useful for verifying that a model or query correctly created or updated a record.

Parameters:

Name Type Description
$table string The name of the database table to search.
$data array Key-value pairs representing column and expected value.
$message string (Optional) Custom error message if the assertion fails.

Example:

$this->assertDatabaseHas('users', [
    'email' => 'jane@example.com',
    'lname' => 'Doe',
]);

If the record is not found, the test will fail and display an informative error message.


B. 🚫 assertDatabaseMissing()

$this->assertDatabaseMissing(string $table, array $data, string $message = '');

Description: Asserts that no row exists in the given table with the provided data. Useful for checking deletions, failed inserts, or rollback behavior.

Parameters:

Name Type Description
$table string The name of the database table to search.
$data array Key-value pairs representing column and value to search for.
$message string (Optional) Custom error message if the assertion fails.

Example:

$this->assertDatabaseMissing('orders', [
    'user_id' => 1,
    'status' => 'canceled',
]);

This will fail if a record with the specified conditions exists in the table.


C. assertStatus(int $expected)

Overview

The assertStatus() method is used in feature and unit tests to verify that a controller or simulated HTTP request returned the expected status code. This method is part of the TestResponse class and ensures your controller logic responds with the correct HTTP semantics (e.g., 200 OK, 404 Not Found, 500 Internal Server Error).

Signature

public function assertStatus(int $expected): void

Parameters

Parameter Type Description
$expected int The HTTP status code you expect the response to return (e.g., 200, 404, 500)

Usage Use this method after performing a simulated request using get(), post(), or put() in your ApplicationTestCase. It will throw an assertion error if the actual status code does not match the expected one.

Example

public function test_homepage_returns_ok_status(): void
{
    $response = $this->get('/');

    $response->assertStatus(200);
}

If the response status is not 200, the test will fail with a message like:

Expected response status 200 but got 404.

When to Use

  • After simulating a request to validate that your route and controller handled it successfully.
  • To confirm that error routes return correct HTTP codes like 404, 403, or 500.


D. ✅ assertJson()

Overview The assertJson() method allows you to verify that the HTTP response body is valid JSON and contains specific key-value pairs. This is useful when testing API endpoints or any controller that returns JSON responses, such as AJAX routes or RESTful APIs.

It ensures:

  • The response content is valid JSON
  • All expected keys are present
  • All expected values match exactly (using assertSame)

Method Signature

public function assertJson(array $expected): void

Parameters

Parameter Type Description
$expected array An associative array of key-value pairs expected in the JSON response

How It Works

  • Parses JSON: It attempts to json_decode the response content.
  • Validates Format: It asserts the content is a valid JSON array.
  • Checks Keys: Asserts that each key in the $expected array exists.
  • Checks Values: Ensures the actual value matches the expected value exactly.

Example Usage ✅ Successful Test

$response = new TestResponse(json_encode([
    'status' => 'success',
    'message' => 'User created',
    'id' => 42
]));

$response->assertJson([
    'status' => 'success',
    'message' => 'User created',
    'id' => 42
]);

❌ Failing Test (Missing Key)

$response = new TestResponse(json_encode([
    'username' => 'testuser'
]));

$response->assertJson([
    'email' => 'testuser@example.com' // will fail: key not found
]);

❌ Failing Test (Mismatched Value)

$response = new TestResponse(json_encode([
    'status' => 'error'
]));

$response->assertJson([
    'status' => 'success' // will fail: mismatched value
]);

When to Use

  • API endpoint response validation
  • AJAX controller return assertions
  • JSON-based form submission confirmation

E. 🧪 Testing View Variables with controllerOutput() and assertViewContains()

This section describes how to test whether a controller assigns the expected properties to the View object using controllerOutput() and the assertViewContains() assertion method.

✅ Overview In this framework, controller actions assign data to views via dynamic properties:

$this->view->user = $user;
$this->view->render('admindashboard.details');

To test whether specific view variables are set correctly during a controller action, you can use:

  • controllerOutput() – simulates dispatching a controller and stores output
  • logViewForTesting() – captures the view during rendering
  • assertViewContains() – checks whether a specific view property exists (and optionally, its value)

🧩 Prerequisites Make sure the controller action includes:

$this->logViewForTesting($this->view);
$this->view->render('admindashboard.details');

📥 Example Controller Action

public function detailsAction($id): void {
    $user = Users::findById((int)$id);
    $profileImage = ProfileImages::findCurrentProfileImage($user->id);

    $this->view->profileImage = $profileImage;
    $this->view->user = $user;

    $this->logViewForTesting($this->view); // Required for testing
    $this->view->render('admindashboard.details');
}

🧪 Writing the Test

public function test_user_details_view_contains_user()
{
    static::controllerOutput('admindashboard', 'details', [1]);

    $this->assertViewContains('user');
    $this->assertViewContains('profileImage');
}

You can also verify the value if needed:

$this->assertViewContains('user', Users::findById(1));

🧠 How It Works

  • controllerOutput() simulates the route to AdmDashboardController@details
  • The controller calls logViewForTesting(), which stores $this->view in a static test property
  • assertViewContains() retrieves the view object and verifies its dynamic properties


F. 📥 Simulating GET Requests with get() and TestResponse

The ApplicationTestCase class provides a Laravel-style get() helper that lets you simulate HTTP GET requests in your feature tests. This function parses a URI string into a controller, action, and optional parameters, then returns a TestResponse object for assertion.

🔧 Syntax

$this->get(string $uri): TestResponse

✅ Example

public function test_homepage_loads_successfully(): void
{
    $response = $this->get('/');
    $response->assertStatus(200);
    $response->assertSee('Welcome');
}

This simulates a route to HomeController@indexAction() and captures its output and response code.

⚙️ Behavior

  • Automatically maps URIs like /products/show/3 to ProductsController::showAction(3)
  • Returns a TestResponse object with:
    • assertStatus(int $expected)
    • assertSee(string $text)
    • getContent(): string

📦 TestResponse Class

The TestResponse class is used to encapsulate the response content and status returned by get(). It provides useful assertion helpers for verifying behavior in your feature tests.

✅ Methods

Method Description
assertStatus(int) Asserts the response returned the expected status
assertSee(string) Asserts that the response content contains text
getContent(): string Returns the raw content captured from the output

📘 Example

$response = $this->get('/user/profile');
$response->assertStatus(200);
$response->assertSee('Profile');

🔍 Notes

  • get() uses controllerOutput() internally to resolve the controller and action.
  • Extra URI segments beyond /controller/action are passed as method parameters.
  • If the controller or action is not found, a 404 response is returned with the error message.


G. ✅ Simulating POST Requests in Feature Tests

In your framework’s test suite, the post() method allows you to simulate POST requests to any controller action as if it were triggered via a browser form submission. This is especially useful for testing routes like /auth/register or /products/create.

Example: Register User Test

public function test_register_action_creates_user(): void
{
    // 👤 Mock file upload (required even if no image uploaded)
    $this->mockFile('profileImage');

    // 🧪 Prepare valid form input with CSRF token
    $postData = [
        'fname' => 'Test',
        'lname' => 'User',
        'email' => 'testuser@example.com',
        'username' => 'testuser',
        'description' => 'Test description',
        'password' => 'Password@123',
        'confirm' => 'Password@123',
        'csrf_token' => FormHelper::generateToken(),
    ];

    // 🚀 Perform request to controller
    $response = $this->post('/auth/register', $postData);

    // ✅ Assert user was created
    $user = \Core\DB::getInstance()->query(
        "SELECT * FROM users WHERE username = ?",
        ['testuser']
    )->first();

    $this->assertNotNull($user, 'User should exist in the database');
    $this->assertEquals('testuser', $user->username);

    // 🔒 Also confirm with database helper
    $this->assertDatabaseHas('users', [
        'username' => 'testuser',
        'email' => 'testuser@example.com',
    ]);
}


H. ♻️ Simulating PUT Requests in Feature Tests

The put() method in ApplicationTestCase allows you to simulate HTTP PUT requests to test controller actions that update records. It mimics a real browser form submission using the PUT method and passes data to the targeted controller action.

This is especially useful for testing resourceful routes like /users/update/1, where form data is submitted via PUT.

🧪 Example: Update User Test

public function test_put_updates_user(): void
{
    // ✅ Seed user
    DB::getInstance()->insert('users', [
        'fname' => 'Original',
        'lname' => 'User',
        'email' => 'original@example.com',
        'username' => 'originaluser',
        'description' => 'Seeded user',
        'password' => password_hash('Password@123', PASSWORD_DEFAULT),
        'created_at' => date('Y-m-d H:i:s'),
        'updated_at' => date('Y-m-d H:i:s')
    ]);

    $userId = DB::getInstance()->lastID();

    // ✅ Prepare updated values
    $data = [
        'username' => 'updateduser',
        'email' => 'updated@example.com',
        'csrf_token' => FormHelper::generateToken()
    ];

    // ✅ Simulate PUT request to update controller
    $response = $this->put("/admindashboard/update/{$userId}", $data);

    // ✅ Assert changes in DB
    $user = DB::getInstance()->query("SELECT * FROM users WHERE id = ?", [$userId])->first();

    $this->assertNotNull($user);
    $this->assertEquals('updateduser', $user->username);
    $this->assertEquals('updated@example.com', $user->email);
}

🔐 CSRF Support Ensure you include a valid CSRF token using your form helper before submitting the PUT request:

'csrf_token' => FormHelper::generateToken()

✅ When to Use Use put() in feature tests to:

  • Verify that a controller correctly updates a database record
  • Simulate a PUT or PATCH form submission from the browser
  • Test CSRF validation and data sanitization
  • Assert redirect behavior after update


I. 📂 Mocking File Uploads in Tests

When your controller expects file uploads (like $_FILES[‘profileImage’]), you must mock this data in your test to avoid runtime errors.

Usage in a test

$this->mockFile('profileImage');

This simulates an empty file upload, which is sufficient for passing validation or skipping optional image logic in Uploads::handleUpload().


7. PHPUnit Assertions Table of Contents

PHPUnit provides a rich set of built-in assertions you can use in your tests. These are all supported out of the box in your test classes (like ApplicationTestCase) because they extend PHPUnit\Framework\TestCase.

Here’s a categorized list of commonly used PHPUnit assertions (as of PHPUnit 11.x):

✅ Equality & Identity

Assertion Description
assertEquals($expected, $actual) Checks if two values are equal (==)
assertSame($expected, $actual) Checks if two values are identical (===)
assertNotEquals($expected, $actual) Asserts that two values are not equal
assertNotSame($expected, $actual) Asserts that two values are not identical


🚫 Null / Empty / Boolean

Assertion Description
assertNull($actual) Checks if a value is null
assertNotNull($actual) Checks if a value is not null
assertTrue($condition) Checks if condition is true
assertFalse($condition) Checks if condition is false
assertEmpty($actual) Checks if a variable is empty (e.g., [], "", null)
assertNotEmpty($actual) Checks if a variable is not empty


🧵 Type Assertions

Assertion Description
assertInstanceOf($expectedClass, $object) Asserts object is an instance of a class
assertIsArray($actual) Asserts variable is an array
assertIsString($actual) Asserts variable is a string
assertIsInt($actual) Asserts variable is an integer
assertIsBool($actual) Asserts variable is a boolean
assertIsFloat($actual) Asserts variable is a float
assertIsCallable($actual) Asserts variable is callable
assertIsObject($actual) Asserts variable is an object
assertIsScalar($actual) Asserts variable is a scalar (int, float, string, or bool)


🧮 Array / Count / Contains

Assertion Description
assertCount($expectedCount, $array) Asserts array has expected number of elements
assertContains($needle, $haystack) Asserts that a value exists in array or string
assertArrayHasKey($key, $array) Asserts key exists in an array
assertArrayNotHasKey($key, $array) Asserts key does not exist in array
assertContainsOnly($type, $array) Asserts array contains only values of a certain type


⚠️ Exception / Error / Output

Assertion Description
expectException(Exception::class) Expects an exception to be thrown
expectExceptionMessage('message') Expects exception message to match
expectExceptionCode(123) Expects exception code to match
expectOutputString('expected output') Asserts output matches string
assertStringContainsString($needle, $haystack) Asserts that a string contains another string


⏱️ Performance / Custom

Assertion Description
assertLessThan($expected, $actual) Asserts that actual is less than expected
assertGreaterThan($expected, $actual) Asserts that actual is greater than expected
assertMatchesRegularExpression($pattern, $string) Asserts that a string matches regex
assertThat($value, $constraint) Use custom constraints (advanced)