← Back to Development Workflow
7.3 Testing Infrastructure
Last Updated: November 9, 2025
Purpose: Comprehensive guide to KMP’s testing framework, test data approach, and best practices
Overview
KMP uses PHPUnit for testing with CakePHP’s testing framework. Unlike traditional CakePHP applications that use fixtures, KMP loads test data from dev_seed_clean.sql and uses database transactions for test isolation. This provides rich, realistic test data while maintaining fast test execution.
Test Data Strategy
SQL Dump Approach
KMP uses a complete SQL database dump for test data instead of individual CakePHP fixtures.
How It Works:
- Bootstrap Load:
tests/bootstrap.phploadsdev_seed_clean.sqlinto the test database - Rich Data: 2,883 members, 54 branches, 1,516 permissions, 76 roles, and complete relationship data
- Transaction Isolation: Each test runs in a transaction that rolls back automatically
- Fast Tests: No database reload between tests - transactions are fast
From tests/bootstrap.php:
use App\Test\TestCase\Support\SeedManager;
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/config/bootstrap.php';
// Configure test database connections
ConnectionManager::setConfig('test_debug_kit', [...]);
ConnectionManager::alias('test_debug_kit', 'debug_kit');
ConnectionManager::alias('test', 'default');
// Seed the test database
SeedManager::bootstrap('test');
Test Data Reference
Stable IDs in dev_seed_clean.sql are documented in tests/TestDataReference.md and available as constants in BaseTestCase.
Key Test Data:
| Entity | ID | Identifier | Notes |
|---|---|---|---|
| Member | 1 | admin@amp.ansteorra.org | Super user with full permissions |
| Branch | 1 | Kingdom of Ansteorra | Root of branch tree |
| Role | 1 | Admin | System administrator role |
| Permission | 1 | Is Super User | Grants full access |
Usage:
use App\Test\TestCase\BaseTestCase;
class MyTest extends BaseTestCase
{
public function testSomething(): void
{
$admin = $this->getTableLocator()
->get('Members')
->get(self::ADMIN_MEMBER_ID);
}
}
BaseTestCase
All tests should extend App\Test\TestCase\BaseTestCase for automatic transaction management and access to test data constants.
Features
Automatic Transaction Wrapping:
- Begins transaction in
setUp() - Rolls back transaction in
tearDown() - Tests start with clean state from
dev_seed_clean.sql - Tests don’t affect each other
Test Data Constants:
| Constant | Value | Description |
|---|---|---|
ADMIN_MEMBER_ID |
1 | Super user member (admin@amp.ansteorra.org) |
KINGDOM_BRANCH_ID |
2 | Root branch (Kingdom of Ansteorra) |
ADMIN_ROLE_ID |
1 | Admin role with super user permission |
SUPER_USER_PERMISSION_ID |
1 | Super user permission (grants all access) |
TEST_MEMBER_AGATHA_ID |
2871 | Local MoAS role test member |
TEST_MEMBER_BRYCE_ID |
2872 | Local Seneschal role test member |
TEST_MEMBER_DEVON_ID |
2874 | Regional Armored Marshal test member |
TEST_MEMBER_EIRIK_ID |
2875 | Kingdom Seneschal test member |
TEST_BRANCH_LOCAL_ID |
14 | Shire of Adlersruhe |
TEST_BRANCH_STARGATE_ID |
39 | Barony of Stargate |
TEST_BRANCH_CENTRAL_REGION_ID |
12 | Central Region |
TEST_BRANCH_SOUTHERN_REGION_ID |
13 | Southern Region |
Helper Methods:
assertRecordExists()- Assert database record existsassertRecordNotExists()- Assert database record doesn’t existassertRecordCount()- Assert table has specific count
Usage Example
<?php
declare(strict_types=1);
namespace App\Test\TestCase\Model\Table;
use App\Test\TestCase\BaseTestCase;
class MembersTableTest extends BaseTestCase
{
protected $Members;
protected function setUp(): void
{
parent::setUp(); // Starts transaction
$this->Members = $this->getTableLocator()->get('Members');
}
public function testFindActive(): void
{
$active = $this->Members->find('active')->toArray();
$this->assertNotEmpty($active);
// Transaction automatically rolls back in tearDown()
}
}
Disabling Transactions
If you need to test transaction behavior itself, disable automatic wrapping:
protected function setUp(): void
{
parent::setUp();
$this->disableTransactions();
// Now you can test transaction behavior
}
Authentication in Tests
HttpIntegrationTestCase (Recommended)
For controller tests, extend HttpIntegrationTestCase which includes TestAuthenticationHelper automatically.
Location: tests/TestCase/Support/HttpIntegrationTestCase.php
Usage:
<?php
declare(strict_types=1);
namespace App\Test\TestCase\Controller;
use App\Test\TestCase\Support\HttpIntegrationTestCase;
class MembersControllerTest extends HttpIntegrationTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->enableCsrfToken();
$this->enableSecurityToken();
$this->authenticateAsSuperUser();
}
public function testIndex(): void
{
// Already authenticated with full permissions
$this->get('/members');
$this->assertResponseOk();
}
}
What It Does:
- Extends
BaseTestCasefor automatic transaction wrapping - Includes
IntegrationTestTraitfor HTTP testing - Includes
TestAuthenticationHelperfor authentication methods
Note: The older
SuperUserAuthenticatedTraitstill exists intests/TestCase/Controller/but new tests should useHttpIntegrationTestCaseinstead, which provides the same authentication viaTestAuthenticationHelper.
AuthenticatedTrait
For tests requiring basic authentication without super user permissions:
use App\Test\TestCase\Controller\AuthenticatedTrait;
class MyControllerTest extends BaseTestCase
{
use IntegrationTestTrait;
use AuthenticatedTrait;
// Tests run as authenticated admin user
}
TestAuthenticationHelper
For manual control over authentication in tests:
Location: tests/TestCase/TestAuthenticationHelper.php
Available Methods:
// Authenticate as different users
$this->authenticateAsSuperUser();
$this->authenticateAsAdmin();
$this->authenticateAsMember($memberId);
$this->logout();
// Assertions
$this->assertAuthenticated();
$this->assertNotAuthenticated();
$this->assertAuthenticatedAs($memberId);
Usage:
use App\Test\TestCase\BaseTestCase;
use App\Test\TestCase\TestAuthenticationHelper;
use Cake\TestSuite\IntegrationTestTrait;
class MyControllerTest extends BaseTestCase
{
use IntegrationTestTrait;
use TestAuthenticationHelper;
public function testAsAdmin(): void
{
$this->authenticateAsAdmin();
$this->get('/admin/route');
$this->assertResponseOk();
}
public function testAsSpecificMember(): void
{
$this->authenticateAsMember(123);
$this->get('/member/profile');
$this->assertResponseOk();
}
}
Test Types and Patterns
Controller Tests
Test HTTP request/response cycles using IntegrationTestTrait.
<?php
declare(strict_types=1);
namespace App\Test\TestCase\Controller;
use App\Test\TestCase\BaseTestCase;
use App\Test\TestCase\Controller\SuperUserAuthenticatedTrait;
use Cake\TestSuite\IntegrationTestTrait;
class MembersControllerTest extends BaseTestCase
{
use IntegrationTestTrait;
use SuperUserAuthenticatedTrait;
public function testIndex(): void
{
$this->get('/members');
$this->assertResponseOk();
$this->assertResponseContains('Members');
}
public function testAddWithValidData(): void
{
$data = [
'email_address' => 'newmember@example.com',
'sca_name' => 'New Member',
];
$this->post('/members/add', $data);
$this->assertResponseSuccess();
$this->assertRedirect(['action' => 'index']);
// Verify record created
$this->assertRecordExists('Members', [
'email_address' => 'newmember@example.com'
]);
}
public function testEditWithInvalidData(): void
{
$data = ['email_address' => 'invalid-email'];
$this->post('/members/edit/1', $data);
$this->assertResponseOk();
$this->assertResponseContains('error');
}
public function testDeleteRemovesRecord(): void
{
$this->delete('/members/delete/999');
$this->assertResponseSuccess();
$this->assertRecordNotExists('Members', ['id' => 999]);
}
}
Model/Table Tests
Test database operations, finders, validations, and associations.
<?php
declare(strict_types=1);
namespace App\Test\TestCase\Model\Table;
use App\Test\TestCase\BaseTestCase;
class MembersTableTest extends BaseTestCase
{
protected $Members;
protected function setUp(): void
{
parent::setUp();
$this->Members = $this->getTableLocator()->get('Members');
}
public function testFindActive(): void
{
$active = $this->Members->find('active')->toArray();
$this->assertNotEmpty($active);
foreach ($active as $member) {
$this->assertEquals('verified', $member->status);
}
}
public function testValidationRequiresEmail(): void
{
$member = $this->Members->newEntity([
'sca_name' => 'Test User',
// missing email_address
]);
$this->assertFalse($this->Members->save($member));
$this->assertNotEmpty($member->getErrors());
$this->assertArrayHasKey('email_address', $member->getErrors());
}
public function testAssociationsLoaded(): void
{
$member = $this->Members->get(self::ADMIN_MEMBER_ID, [
'contain' => ['MemberRoles', 'Branches']
]);
$this->assertNotEmpty($member->member_roles);
$this->assertNotNull($member->branch);
}
}
Entity Tests
Test entity methods, virtual fields, and business logic.
<?php
declare(strict_types=1);
namespace App\Test\TestCase\Model\Entity;
use App\Test\TestCase\BaseTestCase;
class MemberTest extends BaseTestCase
{
public function testAgeCalculation(): void
{
$membersTable = $this->getTableLocator()->get('Members');
$member = $membersTable->newEntity([
'birth_year' => 1990,
'birth_month' => 6,
]);
$age = $member->calculateAge();
$expectedAge = date('Y') - 1990;
$this->assertEquals($expectedAge, $age);
}
public function testIsAdultWithAdult(): void
{
$membersTable = $this->getTableLocator()->get('Members');
$member = $membersTable->newEntity([
'birth_year' => 1990,
'birth_month' => 1,
]);
$this->assertTrue($member->isAdult());
}
public function testPermissionLoading(): void
{
$membersTable = $this->getTableLocator()->get('Members');
$member = $membersTable->get(self::ADMIN_MEMBER_ID);
$member->warrantableReview();
$this->assertNotEmpty($member->permissions);
}
}
Service Tests
Test service layer business logic.
<?php
declare(strict_types=1);
namespace App\Test\TestCase\Services;
use App\Services\AuthorizationService;
use App\Test\TestCase\BaseTestCase;
class AuthorizationServiceTest extends BaseTestCase
{
protected $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new AuthorizationService();
}
public function testCanAccessWithSuperUser(): void
{
$membersTable = $this->getTableLocator()->get('Members');
$member = $membersTable->get(self::ADMIN_MEMBER_ID);
$result = $this->service->canAccess($member, 'Members', 'delete');
$this->assertTrue($result);
}
public function testCanAccessWithoutPermission(): void
{
$membersTable = $this->getTableLocator()->get('Members');
$member = $membersTable->newEntity([
'email_address' => 'test@example.com',
'permissions' => [], // No permissions
]);
$result = $this->service->canAccess($member, 'Members', 'delete');
$this->assertFalse($result);
}
}
Testing Best Practices
1. Test One Thing at a Time
Good:
public function testAddCreatesNewRecord(): void
{
$data = ['name' => 'Test'];
$this->post('/resource/add', $data);
$this->assertResponseSuccess();
$this->assertRecordExists('Resources', ['name' => 'Test']);
}
Bad:
public function testEverything(): void
{
// Testing add, edit, delete, view all in one test
}
2. Use Descriptive Test Names
Good:
public function testAddWithValidDataCreatesRecord(): void
public function testEditWithInvalidPermissionsReturnsForbidden(): void
public function testDeleteRemovesRecordFromDatabase(): void
Bad:
public function testAdd(): void
public function test1(): void
public function testStuff(): void
3. Clean Up After Tests
Transactions handle database cleanup automatically, but clean up other resources:
public function tearDown(): void
{
// Clean up any temp files, connections, etc.
unset($this->service);
parent::tearDown(); // Rolls back transaction
}
4. Use Data Providers for Similar Tests
/**
* @dataProvider invalidDataProvider
*/
public function testValidationWithInvalidData($data, $expectedError): void
{
$result = $this->service->validate($data);
$this->assertFalse($result->success);
$this->assertStringContainsString($expectedError, $result->getError());
}
public static function invalidDataProvider(): array
{
return [
'empty name' => [['name' => ''], 'Name cannot be empty'],
'invalid email' => [['email' => 'notanemail'], 'Invalid email format'],
'negative number' => [['count' => -1], 'Count must be positive'],
];
}
5. Use Assertions Effectively
// Test response status
$this->assertResponseOk();
$this->assertResponseSuccess();
$this->assertResponseCode(404);
// Test response content
$this->assertResponseContains('Expected text');
$this->assertResponseNotContains('Unexpected text');
// Test redirects
$this->assertRedirect(['controller' => 'Members', 'action' => 'index']);
// Test database records (from BaseTestCase)
$this->assertRecordExists('Members', ['id' => 1]);
$this->assertRecordNotExists('Members', ['id' => 999]);
$this->assertRecordCount('Members', 5, ['status' => 'verified']);
6. Test Authorization
Always test that unauthorized users cannot access protected actions:
public function testDeleteWithoutPermission(): void
{
// Authenticate as user without delete permission
$this->authenticateAsMember(123);
$this->delete('/members/delete/1');
$this->assertResponseCode(403); // Forbidden
}
Running Tests
Run All Tests
cd app
vendor/bin/phpunit
Run Specific Test Suite
vendor/bin/phpunit --testsuite core-unit
vendor/bin/phpunit --testsuite core-feature
vendor/bin/phpunit --testsuite plugins
Run Specific Test File
vendor/bin/phpunit tests/TestCase/Controller/MembersControllerTest.php
Run Specific Test Method
vendor/bin/phpunit --filter testIndex tests/TestCase/Controller/MembersControllerTest.php
Run Tests with Coverage
vendor/bin/phpunit --coverage-html coverage/
Run Tests with Specific Filter
vendor/bin/phpunit --filter Controller
vendor/bin/phpunit --filter Table
Test Organization
app/tests/
├── bootstrap.php # Test bootstrap (loads seed SQL via SeedManager)
├── TestCase/ # Test cases
│ ├── BaseTestCase.php # Base class with transaction isolation + data constants
│ ├── TestAuthenticationHelper.php # Auth helper trait
│ ├── ApplicationTest.php # Application tests
│ ├── Support/ # Test infrastructure
│ │ ├── HttpIntegrationTestCase.php # Base for HTTP tests
│ │ ├── PluginIntegrationTestCase.php # Base for plugin HTTP tests
│ │ └── SeedManager.php # Loads dev_seed_clean.sql
│ ├── Controller/ # Controller tests
│ │ ├── AuthenticatedTrait.php
│ │ ├── SuperUserAuthenticatedTrait.php (legacy)
│ │ └── *ControllerTest.php
│ ├── Model/ # Model tests
│ │ ├── Table/ # Table tests
│ │ └── Entity/ # Entity tests
│ ├── Services/ # Service tests
│ ├── Command/ # CLI command tests
│ ├── Middleware/ # Middleware tests
│ └── View/ # View tests
│ ├── Helper/ # Helper tests
│ └── Cell/ # Cell tests
├── js/ # JavaScript tests (Jest)
└── ui/ # UI/E2E tests (Playwright)
Troubleshooting
Tests Affecting Each Other
Symptom: Tests pass individually but fail when run together
Solution: Check that you’re extending BaseTestCase and calling parent::setUp(). Transactions should isolate tests automatically.
Getting 302 Redirects
Symptom: Tests get redirected instead of accessing protected routes
Solution: Ensure you’re using SuperUserAuthenticatedTrait or manually authenticating before accessing protected routes.
Permission Denied Errors
Symptom: User authenticated but still getting permission denied
Solution:
- Use
SuperUserAuthenticatedTraitfor tests requiring full access - Verify the member has required permissions loaded
- Check your policy classes handle the action correctly
Database Connection Errors
Symptom: Cannot connect to test database
Solution: Verify config/.env has correct test database credentials and the test database exists.
Additional Resources
- Test Data Reference:
tests/TestDataReference.md- Stable IDs and test data - BaseTestCase:
tests/TestCase/BaseTestCase.php- Base test class implementation - CakePHP Testing Documentation: https://book.cakephp.org/5/en/development/testing.html
- PHPUnit Documentation: https://docs.phpunit.de/