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
CakePHPDevelopmentPHP

Fast, reliable CakePHP 5 tests: fixtures, strategies and mocks

A practical CakePHP 5 testing setup: schema from migrations, strict fixtures, TransactionStrategy, table and integration tests, and mocks with mockModel().

Cloudsoft Zone

Updated 6 min read

Engineer typing at a test console keyboard in a robotics test facility with monitors and cables
Photograph: National Institute of Standards and Technology / Public domain, via Wikimedia Commons
In this article

A fast, trustworthy CakePHP 5 test suite rests on four decisions: build the test schema from your migrations, keep fixtures small and strict, reset state with transactions instead of truncation, and test controllers through IntegrationTestTrait rather than by instantiating them. CakePHP 5.4 adds mockModel() for Mockery users; everything else here works on any 5.x release.

This guide is the setup we recommend for application teams in October 2026, based on the CakePHP 5.x testing chapter and the current cakephp/app skeleton.

The baseline the skeleton gives you

A new app from composer create-project cakephp/app already includes most of the wiring. Check that an older app has the same pieces:

PieceWherePurpose
PHPUnit ^11.5.3 || ^12.1.3 || ^13.0composer.json (require-dev)Test runner
PHPUnitExtensionphpunit.xml.distLoads and resets fixtures around each test
test datasourceconfig/app_local.phpA separate database used only by tests
(new Migrator())->run()tests/bootstrap.phpBuilds the test schema from your migrations

The test connection must point at a different database from development. CakePHP aliases connections in tests (default becomes test, replica becomes test_replica), and fixture connections must start with test so a misconfiguration cannot truncate real data.

PHP
// config/app_local.php
'Datasources' => [
    'test' => [
        // className and driver are inherited from config/app.php
        'host' => '127.0.0.1',
        'username' => 'app_test',
        'password' => env('TEST_DB_PASSWORD'),
        'database' => 'app_test',
    ],
],

Build the schema from migrations

Hand-maintained fixture schemas drift from production. Reusing migrations avoids that: the Migrator runs only unapplied migrations and rebuilds the test database when the applied history differs from your migration files.

PHP
// tests/bootstrap.php
use Cake\TestSuite\ConnectionHelper;
use Migrations\TestSuite\Migrator;

ConnectionHelper::addTestAliases();

(new Migrator())->runMany([
    ['connection' => 'test'],
    ['plugin' => 'Contacts'],
]);

Use runMany() when plugins share the database, so one set of migrations does not drop the tables of another. If you do not use the Migrations plugin, SchemaLoader::loadSqlFiles() can load a SQL dump instead.

Two cardboard boxes, one open with binary digits rising out of it and one closed with digits printed on its sides
Table tests look inside the box; integration tests treat the app as a closed box. Illustration: J-at-ywalters-dot-net, CC BY-SA 4.0.

Write small, strict fixtures

A fixture is a class in tests/Fixture that inserts known rows before each test. Keep only the rows many tests share; create anything else inside the test that needs it.

PHP
<?php
declare(strict_types=1);

namespace App\Test\Fixture;

use Cake\TestSuite\Fixture\TestFixture;

class ArticlesFixture extends TestFixture
{
    protected bool $strictFields = true;

    public function init(): void
    {
        $this->records = [
            ['user_id' => 1, 'title' => 'Published article', 'published' => true, 'created' => date('Y-m-d H:i:s')],
            ['user_id' => 2, 'title' => 'Draft article', 'published' => false, 'created' => date('Y-m-d H:i:s')],
        ];
        parent::init();
    }
}
  • $strictFields (added in 5.2) raises an error when a record contains a column that is not in the schema, which catches typos and dropped columns.
  • Every record must have the same keys, because rows are bulk inserted.
  • Do not set auto-increment IDs by hand; the book warns it breaks sequences on PostgreSQL and SQL Server.
  • Always call parent::init() when overriding init().

Load fixtures per test case with the $fixtures property (['app.Articles', 'app.Users']) or a getFixtures() method returning fixture class names. Load one for every table a test touches.

Use TransactionStrategy for speed and isolation

By default CakePHP resets state after each test by truncating fixture tables. That gets slow as the schema grows. TransactionStrategy wraps each test in a transaction and rolls it back. The book calls it the recommended strategy for medium and large applications. Enable it globally:

PHP
// config/app.php (or a test-only config)
'TestSuite' => [
    'fixtureStrategy' => \Cake\TestSuite\Fixture\TransactionStrategy::class,
],

Two caveats: auto-increment values are not reset between tests, so never assert on hard-coded new IDs; and code that commits its own transaction in a separate connection is invisible to the rollback. Override getFixtureStrategy() on a single test case if it needs truncation.

Test tables directly

Validation rules, finders and application rules are cheap to test without HTTP:

PHP
<?php
declare(strict_types=1);

namespace App\Test\TestCase\Model\Table;

use App\Model\Table\ArticlesTable;
use Cake\TestSuite\TestCase;

class ArticlesTableTest extends TestCase
{
    protected array $fixtures = ['app.Users', 'app.Articles'];
    protected ArticlesTable $Articles;

    protected function setUp(): void
    {
        parent::setUp();
        $this->Articles = $this->fetchTable('Articles');
    }

    public function testPublishedFinderHidesDrafts(): void
    {
        $titles = $this->Articles->find('published')->all()->extract('title')->toList();
        $this->assertSame(['Published article'], $titles);
    }

    public function testTitleIsRequired(): void
    {
        $article = $this->Articles->newEntity(['user_id' => 1, 'title' => '']);
        $this->assertArrayHasKey('title', $article->getErrors());
    }
}

Test controllers through the full stack

IntegrationTestTrait sends a request through middleware, routing, controller and view, then lets you assert on the response. It is the best place to cover CSRF, authentication and authorization together.

PHP
use Cake\TestSuite\IntegrationTestTrait;

class ArticlesControllerTest extends TestCase
{
    use IntegrationTestTrait;

    protected array $fixtures = ['app.Users', 'app.Articles'];

    public function testOwnerCanCreateArticle(): void
    {
        $this->session(['Auth' => $this->fetchTable('Users')->get(1)]);
        $this->enableCsrfToken();
        $this->enableSecurityToken();

        $this->post('/articles/add', ['title' => 'From a test', 'body' => 'Text']);

        $this->assertRedirect(['controller' => 'Articles', 'action' => 'index']);
        $this->assertSame(1, $this->fetchTable('Articles')->find()->where(['title' => 'From a test'])->count());
    }

    public function testJsonIndex(): void
    {
        $this->configRequest(['headers' => ['Accept' => 'application/json']]);
        $this->get('/articles.json');
        $this->assertResponseOk();
        $this->assertContentType('application/json');
    }
}

enableCsrfToken() and enableSecurityToken() generate valid tokens so POSTs are not rejected. When a test fails with an unhelpful error page, call $this->disableErrorHandlerMiddleware() temporarily to see the real exception and stack trace.

Code editor with a small test file above and a test runner panel below reporting a failed equality check with actual and expected values
A good failure message shows actual and expected values side by side. Screenshot: Qeef, CC0.

Test console commands the same way

Scheduled jobs and maintenance commands often run unattended, which makes them the code most likely to fail silently. ConsoleIntegrationTestTrait runs a command exactly as bin/cake would and captures its output and exit code, so you can cover them with the same fixtures as your controllers:

PHP
use Cake\Console\TestSuite\ConsoleIntegrationTestTrait;
use Cake\TestSuite\TestCase;

class PruneDraftsCommandTest extends TestCase
{
    use ConsoleIntegrationTestTrait;

    protected array $fixtures = ['app.Users', 'app.Articles'];

    public function testPrunesOldDrafts(): void
    {
        $this->exec('prune_drafts --days 30');

        $this->assertExitSuccess();
        $this->assertOutputContains('Removed');
    }

    public function testRejectsInvalidDays(): void
    {
        $this->exec('prune_drafts --days -1');
        $this->assertExitCode(1);
    }
}

The input argument of exec() feeds answers to interactive prompts, and assertErrorContains() checks what the command wrote to stderr. Because commands can receive constructor-injected services from the dependency injection container, you can replace their collaborators with mockService() exactly as in controller tests.

Mock only what crosses a boundary

Mock mailers, payment gateways and HTTP clients; do not mock the ORM you are trying to test. CakePHP offers three tools:

  • getMockForModel('Alias', ['method']) creates a PHPUnit mock of a table that keeps its fixtures and associations.
  • mockModel('Alias'), new in 5.4, returns a partial Mockery mock instead. Add mockery/mockery to your own require-dev before using it.
  • $this->mockService(Class::class, fn () => ...) replaces a service in the dependency injection container for integration tests.
PHP
public function testVerificationEmailIsSent(): void
{
    $users = $this->mockModel('Users');
    $users->shouldReceive('sendVerification')->once()->andReturn(true);

    $this->assertTrue($users->sendVerification('dev@example.com'));
}

protected function tearDown(): void
{
    $this->getTableLocator()->clear();
    parent::tearDown();
}

Clear the table locator in tearDown() so a mocked table does not leak into the next test. Note that the book’s own mockModel() example assigns $model but calls $mock; use one variable name.

Run fast, run often

Shell
vendor/bin/phpunit
vendor/bin/phpunit --filter testPublishedFinder tests/TestCase/Model/Table/ArticlesTableTest.php

--filter takes a case-sensitive regular expression. In CI, run the full suite against the same database engine and version as production; SQLite is convenient locally but hides MySQL-specific behaviour such as strict mode and collations.

Gotchas

  • Tests pass alone but fail together. Something is leaking: a mocked table not cleared, a static cache, or reliance on auto-increment IDs under TransactionStrategy.
  • “Table not found” for a plugin. Add the plugin to runMany() in tests/bootstrap.php.
  • Fixtures growing out of control. Consider the community fixture factories plugin the book links to, which builds data per test instead of in shared fixture files.

What to do next

  1. Switch fixtureStrategy to TransactionStrategy and time the suite before and after.
  2. Turn on $strictFields in your largest fixtures.
  3. Add one integration test per controller that covers a guest, an owner and another user.

Frequently asked questions

  • Which PHPUnit versions does CakePHP 5 support?

    The cakephp/app skeleton and CakePHP 5.4 accept PHPUnit ^11.5.3, ^12.1.3 or ^13.0.

  • How do I create the test database schema in CakePHP 5?

    Call (new Migrator())->run() in tests/bootstrap.php to reuse your migrations, or load a SQL dump with SchemaLoader::loadSqlFiles().

  • What is TransactionStrategy?

    A fixture strategy that runs each test in a transaction and rolls it back, instead of truncating tables. Enable it with the TestSuite.fixtureStrategy configuration key.

  • What does strictFields do on a fixture?

    Added in 5.2, it makes the fixture raise an error when a record contains a field that is not in the table schema.

  • What is mockModel() in CakePHP 5.4?

    A TestCase method that returns a partial Mockery mock of a table class, as an alternative to the PHPUnit-based getMockForModel(). Your app needs mockery/mockery installed.

  • How do I test POST actions protected by CSRF?

    Call $this->enableCsrfToken(), and $this->enableSecurityToken() if you use FormProtection, before sending the request.

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 — CakePHP Book (5.x)
  2. 5.4 Migration Guide — CakePHP Book (5.x)
  3. cakephp/app tests/bootstrap.php — cakephp/app on GitHub
  4. cakephp/app composer.json — cakephp/app on GitHub
  5. TestCase::mockModel() source (5.4.3) — cakephp/cakephp on 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