Skip to content

Testing your forms

Forms are ordinary classes, so you test them with CodeIgniter's testing tools. Test the form class for its rules, and your controllers for the whole request.

Testing a form class

validate() accepts an array, so no request is needed:

tests/AppointmentFormTest.php
<?php

declare(strict_types=1);

use App\Forms\AppointmentForm;
use CodeIgniter\Test\CIUnitTestCase;

/**
 * @internal
 */
final class AppointmentFormTest extends CIUnitTestCase
{
    /**
     * @return array<string, string>
     */
    private function valid(array $overrides = []): array
    {
        return array_merge([
            'full_name'        => 'Jane Namuli',
            'phone'            => '0772123456',
            'service'          => 'fitting',
            'appointment_date' => '2030-01-15',
            'appointment_time' => '10:30',
        ], $overrides);
    }

    public function testValidDataPasses(): void
    {
        $form = new AppointmentForm();

        $this->assertTrue($form->validate($this->valid()));
    }

    public function testRequiredFields(): void
    {
        $form = new AppointmentForm();

        $this->assertFalse($form->validate([]));
        $this->assertTrue($form->hasError('full_name'));
        $this->assertTrue($form->hasError('phone'));
        $this->assertFalse($form->hasError('email'), 'email is optional');
    }

    public function testUnknownServiceIsRejected(): void
    {
        $form = new AppointmentForm();

        $this->assertFalse($form->validate($this->valid(['service' => 'free_suit'])));
        $this->assertSame('Please choose a valid option for Service.', $form->getError('service'));
    }

    public function testValidatedDataIsClean(): void
    {
        $form = new AppointmentForm();
        $form->validate($this->valid(['full_name' => '  Jane Namuli ', 'is_admin' => '1']));

        $data = $form->validated();

        $this->assertSame('Jane Namuli', $data['full_name']);
        $this->assertNull($data['email']);
        $this->assertArrayNotHasKey('is_admin', $data);
    }
}

Useful assertions:

Method Checks
validate($data) Returns whether the data is valid
getErrors(), getError('field'), hasError('field') Which fields failed, and their messages
validated() The clean data a controller would save
render(), field('name') The HTML, for checking attributes or options

Testing a controller

Feature tests send real requests through your routes and controller. With $migrate = true, your migrations run on a fresh test database before each test:

tests/AppointmentsTest.php
<?php

declare(strict_types=1);

use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;

/**
 * @internal
 */
final class AppointmentsTest extends CIUnitTestCase
{
    use DatabaseTestTrait;
    use FeatureTestTrait;

    // Run the app's migrations before each test, on a fresh database.
    protected $migrate = true;
    protected $refresh = true;
    protected $namespace;

    protected function setUp(): void
    {
        parent::setUp();

        $this->withRoutes([
            ['get', 'appointments/new', '\App\Controllers\Appointments::new'],
            ['post', 'appointments', '\App\Controllers\Appointments::create'],
            ['patch', 'appointments/(:num)', '\App\Controllers\Appointments::update/$1'],
        ]);
    }

    public function testBookingIsSaved(): void
    {
        $result = $this->post('appointments', [
            'full_name'        => 'Jane Namuli',
            'phone'            => '0772123456',
            'service'          => 'fitting',
            'appointment_date' => date('Y-m-d', strtotime('+7 days')),
            'appointment_time' => '10:30',
        ]);

        $result->assertRedirectTo('/appointments');
        $this->seeInDatabase('appointments', ['full_name' => 'Jane Namuli', 'service' => 'fitting']);
    }

    public function testInvalidBookingIsShownAgain(): void
    {
        $result = $this->post('appointments', ['full_name' => 'Jane Namuli']);

        $result->assertOK();
        $result->assertSee('value="Jane Namuli"', null);
        $result->assertSee('The Phone number field is required.', null);
        $this->dontSeeInDatabase('appointments', ['full_name' => 'Jane Namuli']);
    }

    public function testChangingABooking(): void
    {
        $id = $this->db->table('appointments')->insert([
            'full_name'        => 'Jane Namuli',
            'phone'            => '0772123456',
            'service'          => 'fitting',
            'appointment_date' => date('Y-m-d', strtotime('+7 days')),
            'appointment_time' => '10:30',
        ]) ? $this->db->insertID() : 0;

        // Browsers send POST with _method=PATCH; in feature tests, send PATCH
        // with a URL-encoded body. Clearing the POST data mimics a fresh request.
        service('superglobals')->setPostArray([]);

        $result = $this->withBody(http_build_query([
            'full_name'        => 'Jane Namuli',
            'phone'            => '0772123456',
            'service'          => 'alterations',
            'appointment_date' => date('Y-m-d', strtotime('+8 days')),
            'appointment_time' => '14:00',
        ]))->patch('appointments/' . $id);

        $result->assertRedirectTo('/appointments');
        $result->assertStatus(303);
        $this->seeInDatabase('appointments', ['id' => $id, 'service' => 'alterations']);
    }
}

Common mistakes

Posting a PATCH, PUT or DELETE form in a feature test. Browsers send these forms as POST with a hidden _method field, but the test framework sets the HTTP method before CodeIgniter reads _method, so a POST never reaches a PATCH route. Send the real method with a URL-encoded body, as testChangingABooking() does, and clear the previous request's POST data first with service('superglobals')->setPostArray([]).

Expecting an upload to be valid in a test. PHP's is_uploaded_file() only accepts files from a real upload. Define replacements in the CodeIgniter\HTTP\Files namespace in your test bootstrap, as CodeIgniter's own test suite does, then set the files with service('superglobals')->setFilesArray([...]) before the request:

namespace CodeIgniter\HTTP\Files;

function is_uploaded_file(string $filename): bool
{
    return is_file($filename);
}

function move_uploaded_file(string $from, string $to): bool
{
    return copy($from, $to);
}

Posting without a CSRF token. If your app runs CodeIgniter's CSRF filter on every request, it checks tokens in feature tests too, and a post without one fails with "The action you requested is not allowed." Add the token to the posted data:

$this->post('appointments', [csrf_token() => csrf_hash(), 'full_name' => 'Jane Namuli', /* ... */]);