Skip to the content.

← 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:

  1. Bootstrap Load: tests/bootstrap.php loads dev_seed_clean.sql into the test database
  2. Rich Data: 2,883 members, 54 branches, 1,516 permissions, 76 roles, and complete relationship data
  3. Transaction Isolation: Each test runs in a transaction that rolls back automatically
  4. 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:

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:

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

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:

  1. Extends BaseTestCase for automatic transaction wrapping
  2. Includes IntegrationTestTrait for HTTP testing
  3. Includes TestAuthenticationHelper for authentication methods

Note: The older SuperUserAuthenticatedTrait still exists in tests/TestCase/Controller/ but new tests should use HttpIntegrationTestCase instead, which provides the same authentication via TestAuthenticationHelper.

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:

  1. Use SuperUserAuthenticatedTrait for tests requiring full access
  2. Verify the member has required permissions loaded
  3. 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


← Back to Development Workflow