Test CodeIgniter 4 apps with PHPUnit feature and database tests
Write CodeIgniter 4 feature tests that hit real routes and a migrated, seeded test database, with working PHPUnit examples, assertions and common gotchas.
Updated 6 min read

In this article
The quickest way to get reliable tests in a CodeIgniter 4 project is to combine two traits that ship with the framework: FeatureTestTrait, which sends a fake HTTP request through your real routes, filters and controllers, and DatabaseTestTrait, which migrates and seeds a separate test database before each test. With both in place, one test can call POST /books, check the JSON response and assert that the row exists in the database.
This tutorial builds that setup on CodeIgniter 4.7 with PHPUnit, using a small books endpoint as the example. It covers the test database, migrations and seeds in tests, feature tests for success and failure, and the gotchas that usually waste the first afternoon.
What the app starter already gives you
A project created with composer create-project codeigniter4/appstarter is ready for testing out of the box:
- PHPUnit is in
require-dev(phpunit/phpunit^10.5.16), along with Faker and vfsStream. phpunit.dist.xmlin the project root defines a test suite for thetests/folder. Copy it tophpunit.xmlif you need local changes; your copy overrides the dist file.- A
testsdatabase group inapp/Config/Database.php. When the environment istesting, the config switches the default group totests, so tests cannot touch your development data by accident. - A Composer script:
composer testrunsphpunit.
Out of the box, that tests group uses SQLite3 with an in-memory database (:memory:) and a db_ table prefix. That is fast and needs no server, but it requires the sqlite3 PHP extension, and SQLite does not behave exactly like MySQL. For an application that runs on MySQL, point the group at a real, disposable database in .env:
database.tests.hostname = 127.0.0.1
database.tests.database = books_test
database.tests.username = books_test
database.tests.password = change-me
database.tests.DBDriver = MySQLi
database.tests.DBPrefix =
database.tests.port = 3306Use a database that holds nothing you care about. With the default settings, every test rolls back all migrations and builds the schema again.

The code under test
The example uses a books table created by a normal migration in app/Database/Migrations, a model with validation rules and a controller that returns JSON. The model is where the rules live:
<?php
namespace App\Models;
use CodeIgniter\Model;
class BookModel extends Model
{
protected $table = 'books';
protected $returnType = 'array';
protected $allowedFields = ['title', 'author'];
protected $useTimestamps = true;
protected $validationRules = [
'title' => 'required|max_length[200]',
'author' => 'required|max_length[120]',
];
}The controller uses ResponseTrait for consistent status codes:
<?php
namespace App\Controllers;
use App\Models\BookModel;
use CodeIgniter\API\ResponseTrait;
class Books extends BaseController
{
use ResponseTrait;
public function index()
{
return $this->respond(['data' => model(BookModel::class)->findAll()]);
}
public function create()
{
$model = model(BookModel::class);
$data = $this->request->getJSON(true) ?? [];
if ($model->insert($data) === false) {
return $this->failValidationErrors($model->errors());
}
return $this->respondCreated(['data' => $model->find($model->getInsertID())]);
}
}The routes are $routes->get('books', 'Books::index'); and $routes->post('books', 'Books::create');. Finally, a seeder in app/Database/Seeds/BookSeeder.php inserts one known row:
<?php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
class BookSeeder extends Seeder
{
public function run()
{
$this->db->table('books')->insert([
'title' => 'Clean Code',
'author' => 'Robert C. Martin',
]);
}
}Write the feature test
Create tests/Feature/BooksApiTest.php. You can generate a skeleton with php spark make:test, but the full class is short enough to write by hand:
<?php
namespace Tests\Feature;
use App\Database\Seeds\BookSeeder;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
final class BooksApiTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use FeatureTestTrait;
protected $migrate = true;
protected $refresh = true;
protected $namespace = 'App';
protected $seed = BookSeeder::class;
public function testIndexListsSeededBooks(): void
{
$result = $this->get('books');
$result->assertOK();
$result->assertJSONFragment(['data' => [['title' => 'Clean Code']]]);
}
public function testCreateStoresAValidBook(): void
{
$result = $this->withBodyFormat('json')
->post('books', ['title' => 'Refactoring', 'author' => 'Martin Fowler']);
$result->assertStatus(201);
$this->seeInDatabase('books', ['title' => 'Refactoring']);
$this->seeNumRecords(2, 'books', []);
}
public function testCreateRejectsAMissingTitle(): void
{
$result = $this->withBodyFormat('json')
->post('books', ['author' => 'Anonymous']);
$result->assertStatus(400);
$this->dontSeeInDatabase('books', ['author' => 'Anonymous']);
}
}Run it with composer test, or only this class with vendor/bin/phpunit --filter BooksApiTest.
What each setting does
| Property | Default | Meaning |
|---|---|---|
$migrate | true | Run migrations before each test |
$migrateOnce | false | Set true to migrate only before the first test in the class |
$refresh | true | Roll every migration back to version 0 first, so each test starts clean |
$namespace | 'Tests\Support' | Which namespace's migrations to run; null runs all namespaces |
$seed | '' | Seeder to run; a fully qualified class name works from any namespace |
$seedOnce | false | Seed only once per class |
The default $namespace is the most common surprise: it points at tests/_support/Database/Migrations, not your application. Without $namespace = 'App', the test database has no books table and every test fails with a missing-table error.

Useful request and response helpers
FeatureTestTrait has shorthand methods for every verb (get(), post(), put(), patch(), delete(), options()) plus modifiers you chain before them:
withBodyFormat('json')sends the parameters as a JSON body and sets theContent-Typeheader, so$this->request->getJSON()works in the controller.withSession(['user_id' => 1])sets session values for one request, which is handy for routes behind a login filter.withHeaders([...])adds headers such as anAuthorizationtoken.withRoutes([...])replaces the route table for a test of one controller in isolation.skipEvents()stops event listeners from running, so a sign-up test does not try to send email.
The call returns a TestResponse. Besides assertOK() and assertStatus(), it offers assertRedirectTo(), assertSessionHas(), assertHeader(), assertCookie(), assertSee() for HTML, assertJSONExact() and getJSON() when you want to decode and inspect the body yourself.
assertJSONFragment() merges your fragment into the decoded response and checks nothing changed, so the fragment must follow the response's structure. That is why the first test wraps the book in ['data' => [[...]]]: a flat ['title' => 'Clean Code'] would look for a top-level title key and fail.
Testing filters and authentication
Feature tests run the filters configured for the route, which is exactly what you want for checking access control. Add a route behind an auth filter and assert that an anonymous request is turned away, then repeat with withSession() or a token header and assert success. If you use CodeIgniter Shield, its documentation includes testing helpers; the principle is the same: one test proves the door is locked, one proves the key works.
Gotchas
- Always call the parent. If you override
setUp()ortearDown(), callparent::setUp()andparent::tearDown(), or migrations and seeds will silently not run. - Extend
CIUnitTestCase, not PHPUnit'sTestCase; the database and feature tooling depends on it. - SQLite is not MySQL. JSON columns, strict mode, collations and some date functions behave differently. Run at least your continuous integration job against the same engine as production.
- Speed up big suites with
$migrateOnce = trueand$seedOnce = truein classes whose tests only read data. - Freeze time with
Time::setTestNow()when a test depends on dates, and mock services withServices::injectMock()instead of calling real APIs. - CSRF on form posts: if the
csrffilter applies to the route, a plainpost()without a token may be rejected. Exclude JSON API paths from CSRF inapp/Config/Filters.phpor include the token.
What to do next
Start with one feature test per important endpoint: a happy path, a validation failure and an unauthorised request. Run them in GitHub Actions on every pull request against a MySQL service container that matches production. Once that safety net exists, upgrades such as CodeIgniter 4.7.5 become a routine merge instead of a manual click-through of the whole site.
Frequently asked questions
Which database do CodeIgniter 4 tests use by default?
The
testsgroup inapp/Config/Database.php, which defaults to an in-memory SQLite3 database with adb_prefix. When the environment istesting, the config switches the default group totestsautomatically.Why do my tests say the table does not exist?
DatabaseTestTraitruns migrations fromTests\Supportby default. Setprotected $namespace = 'App';to run your application migrations, ornullto run every namespace.How do I send JSON in a CodeIgniter feature test?
Chain
withBodyFormat('json')before the request, for example$this->withBodyFormat('json')->post('books', $data). The data becomes the JSON body and the Content-Type header is set for you.Can I seed with a seeder from app/Database/Seeds?
Yes. Set
$seedto the fully qualified class name, such asApp\Database\Seeds\BookSeeder::class. A short name without a namespace is looked up intests/_support/Database/Seeds.How can I make a slow test suite faster?
Set
$migrateOnceand$seedOncetotruein classes whose tests do not change shared data, keep feature tests focused, and use SQLite locally only if your SQL is portable.Do feature tests run my filters?
Yes. The request goes through routing and the filters configured for that route, so feature tests are a good way to prove that protected routes reject anonymous users.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Testing overview — CodeIgniter Foundation
- Testing Your Database — CodeIgniter Foundation
- HTTP Feature Testing — CodeIgniter Foundation
- Testing Responses — CodeIgniter Foundation
- Database Seeding — CodeIgniter Foundation
- codeigniter4/appstarter composer.json — GitHub
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Troubleshooting guideCodeIgniter
CodeIgniter 4 validation not working? 9 causes and fixes
Fix CodeIgniter 4 validation problems fast: wrong input source, lost errors after redirect, permit_empty, strict JSON rules, is_unique placeholders and more.
By Cloudsoft Zone

Industry updateCodeIgniter
CodeIgniter 4.7.5 security release: what to patch and how
CodeIgniter 4.7.5 fixes six advisories, including a critical upload bypass and Parser code execution. See who is exposed and how to upgrade safely.
By Cloudsoft Zone

Laravel
Upgrading from Laravel 12 to Laravel 13: a practical guide
Laravel 12 lost bug-fix support in August 2026. This guide covers the PHP 8.3 requirement, Composer changes, the CSRF middleware rename, cache and session hardening, low-impact changes that often bite, and a safe rollout plan.
By Cloudsoft Zone
