Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
CodeIgniterDevelopmentPHPTutorial

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.

Cloudsoft Zone

Updated 6 min read

Cluttered electronics workbench with an oscilloscope, two multimeters, a power supply, test leads and a computer monitor
Photograph: James Bastow / CC BY-SA 2.0, via Wikimedia Commons
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.xml in the project root defines a test suite for the tests/ folder. Copy it to phpunit.xml if you need local changes; your copy overrides the dist file.
  • A tests database group in app/Config/Database.php. When the environment is testing, the config switches the default group to tests, so tests cannot touch your development data by accident.
  • A Composer script: composer test runs phpunit.

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:

INI
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 = 3306

Use a database that holds nothing you care about. With the default settings, every test rolls back all migrations and builds the schema again.

Home programming desk with two monitors, a backlit keyboard, a mouse and a small printer
composer test runs the whole suite locally before you push.

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
<?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
<?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
<?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
<?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

PropertyDefaultMeaning
$migratetrueRun migrations before each test
$migrateOncefalseSet true to migrate only before the first test in the class
$refreshtrueRoll 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
$seedOncefalseSeed 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.

Screenshot of the DrRacket editor with a small program and a unit test module, and the results pane below
Whatever the language, a test pins one expected result to one input; the CodeIgniter traits do the same for HTTP requests.

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 the Content-Type header, 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 an Authorization token.
  • 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() or tearDown(), call parent::setUp() and parent::tearDown(), or migrations and seeds will silently not run.
  • Extend CIUnitTestCase, not PHPUnit's TestCase; 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 = true and $seedOnce = true in classes whose tests only read data.
  • Freeze time with Time::setTestNow() when a test depends on dates, and mock services with Services::injectMock() instead of calling real APIs.
  • CSRF on form posts: if the csrf filter applies to the route, a plain post() without a token may be rejected. Exclude JSON API paths from CSRF in app/Config/Filters.php or 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 tests group in app/Config/Database.php, which defaults to an in-memory SQLite3 database with a db_ prefix. When the environment is testing, the config switches the default group to tests automatically.

  • Why do my tests say the table does not exist?

    DatabaseTestTrait runs migrations from Tests\Support by default. Set protected $namespace = 'App'; to run your application migrations, or null to 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 $seed to the fully qualified class name, such as App\Database\Seeds\BookSeeder::class. A short name without a namespace is looked up in tests/_support/Database/Seeds.

  • How can I make a slow test suite faster?

    Set $migrateOnce and $seedOnce to true in 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.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Testing overview — CodeIgniter Foundation
  2. Testing Your Database — CodeIgniter Foundation
  3. HTTP Feature Testing — CodeIgniter Foundation
  4. Testing Responses — CodeIgniter Foundation
  5. Database Seeding — CodeIgniter Foundation
  6. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.

  • Plush elePHPant toy from Heroku, a version of the PHP community mascot, photographed close up

    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