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().
Updated 6 min read

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:
| Piece | Where | Purpose |
|---|---|---|
PHPUnit ^11.5.3 || ^12.1.3 || ^13.0 | composer.json (require-dev) | Test runner |
PHPUnitExtension | phpunit.xml.dist | Loads and resets fixtures around each test |
test datasource | config/app_local.php | A separate database used only by tests |
(new Migrator())->run() | tests/bootstrap.php | Builds 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.
// 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.
// 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.

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
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 overridinginit().
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:
// 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
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.
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.

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:
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. Addmockery/mockeryto your own require-dev before using it.$this->mockService(Class::class, fn () => ...)replaces a service in the dependency injection container for integration tests.
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
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()intests/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
- Switch
fixtureStrategytoTransactionStrategyand time the suite before and after. - Turn on
$strictFieldsin your largest fixtures. - 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/appskeleton and CakePHP 5.4 accept PHPUnit^11.5.3,^12.1.3or^13.0.How do I create the test database schema in CakePHP 5?
Call
(new Migrator())->run()intests/bootstrap.phpto reuse your migrations, or load a SQL dump withSchemaLoader::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.fixtureStrategyconfiguration 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
TestCasemethod that returns a partial Mockery mock of a table class, as an alternative to the PHPUnit-basedgetMockForModel(). Your app needsmockery/mockeryinstalled.How do I test POST actions protected by CSRF?
Call
$this->enableCsrfToken(), and$this->enableSecurityToken()if you use FormProtection, before sending the request.
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 — CakePHP Book (5.x)
- 5.4 Migration Guide — CakePHP Book (5.x)
- cakephp/app tests/bootstrap.php — cakephp/app on GitHub
- cakephp/app composer.json — cakephp/app on GitHub
- 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.
More from Cloudsoft Zone

Industry updateCakePHP
What's new in CakePHP 5.4: ORM changes, DTO mapping and a built-in container
CakePHP 5.4 changes default eager loading, enforces entity-table ownership and adds request-to-DTO mapping, an optional built-in DI container, typed unhydrated queries and JSON streaming. Here is what to check before upgrading.
By 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

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
