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:
<?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:
<?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', /* ... */]);
Related¶
- Getting started for the controller these tests cover
- JavaScript and APIs for testing JSON endpoints