Unit Testing
Comprehensive guide to the project's testing infrastructure, test suites, and best practices for writing testable code in this architecture.
Table of Contents
- Overview
- Test Directory Structure
- Running Tests
- Test Suites
- Writing Tests
- Mocking & Stubs
- Feature Tests
- API Testing
- Database Testing
- Code Coverage
- CI/CD Integration
- Best Practices
1. Overview
The project uses PHPUnit with CodeIgniter 4's testing framework for comprehensive testing. The testing architecture supports:
- Unit Tests — Testing individual classes and methods in isolation
- Feature Tests — End-to-end tests for complete user flows
- Integration Tests — Testing component interactions
- Database Tests — Migration and seeding verification
Test Stats
| Metric | Value |
|---|---|
| Total Test Files | 8+ |
| Total Test Methods | 80+ |
| Test Categories | Unit, Feature, Database |
2. Test Directory Structure
tests/
├── unit/ # Unit tests for isolated components
│ ├── SecurityTest.php # Core security (JWT, CSRF, Headers)
│ ├── JwtServiceTest.php # JWT encode/decode/tampering
│ ├── AdminModuleTest.php # Dashboard, User, Auth controllers
│ ├── ApiModuleTest.php # REST API endpoints
│ ├── WebModuleTest.php # Public pages, Docs
│ ├── RbacTest.php # Roles, Permissions, Menus
│ └── HealthTest.php # Basic health checks
│
├── feature/ # End-to-end feature tests
│ └── LoginFlowTest.php # Full auth flows, Session vs Token
│
├── database/ # Database-specific tests
│ └── MigrationTest.php # Database migrations verification
│
├── session/ # Session-related tests
│ └── ExampleSessionTest.php # Session handling tests
│
└── _support/ # Test helpers and fixtures
├── Database/
│ ├── Migrations/ # Test migrations
│ └── Seeds/ # Test seeders
├── Libraries/
│ └── ConfigReader.php # Test utilities
└── Models/
└── ExampleModel.php # Test model fixtures3. Running Tests
Basic Commands
# Run all tests
docker-compose exec php php spark test
# Run with verbose output
docker-compose exec php php spark test --verbose
# Run specific test file
docker-compose exec php php spark test --filter SecurityTest
# Run specific test method
docker-compose exec php php spark test --filter testJwtTokenGenerationRunning Test Categories
# Unit tests only
docker-compose exec php php spark test tests/unit
# Feature tests only
docker-compose exec php php spark test tests/feature
# Database tests only
docker-compose exec php php spark test tests/databaseModule-Specific Tests
# Admin module tests
docker-compose exec php php spark test --filter AdminModuleTest
# API module tests
docker-compose exec php php spark test --filter ApiModuleTest
# Web module tests
docker-compose exec php php spark test --filter WebModuleTest
# RBAC tests
docker-compose exec php php spark test --filter RbacTest
# Security tests
docker-compose exec php php spark test --filter SecurityTest4. Test Suites
4.1 SecurityTest.php
Tests for core security features:
| Test Method | Coverage |
|---|---|
testJwtTokenGeneration | JWT token creation |
testJwtTokenValidation | Token decode and verify |
testJwtTokenExpiry | Expired token handling |
testCsrfTokenPresence | CSRF token in forms |
testSecurityHeadersPresent | X-Frame-Options, etc. |
testRateLimitEnforced | Throttling behavior |
4.2 JwtServiceTest.php
Deep testing of the JWT infrastructure:
| Test Method | Coverage |
|---|---|
testEncodeCreatesValidToken | Token structure validation |
testDecodeReturnsPayload | Payload extraction |
testTamperedTokenThrows | Tamper detection |
testExpiredTokenThrows | Expiry enforcement |
testInvalidSignatureRejected | Signature validation |
4.3 AdminModuleTest.php
Admin panel functionality:
- Dashboard access with/without auth
- User CRUD operations
- AJAX endpoint responses
- Permission enforcement
4.4 ApiModuleTest.php
REST API endpoint testing:
- Ping/Health endpoints
- JWT-protected endpoints
- Rate limiting behavior
- Error response formats
4.5 WebModuleTest.php
Public website testing:
- Home page rendering
- Documentation pages
- Search functionality
- 404 error handling
- Security headers
4.6 RbacTest.php
Role-based access control:
- Role assignment and checking
- Permission enforcement
- Menu visibility by role
- Filter blocking behavior
4.7 LoginFlowTest.php
Feature tests for complete authentication flows:
- Session-based login/logout
- JWT token authentication
- Remember me functionality
- Failed login handling
5. Writing Tests
Basic Test Structure
<?php
namespace Tests\Unit;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
class MyModuleTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected function setUp(): void
{
parent::setUp();
// Setup code here
}
public function testPageLoadsSuccessfully(): void
{
$result = $this->call('get', '/my-page');
$result->assertStatus(200);
$result->assertSee('Expected Content');
}
}Testing with Authentication
public function testPageRequiresAuth(): void
{
// Without auth - should redirect
$result = $this->call('get', '/admin/dashboard');
$result->assertRedirectTo('/login');
// With auth - should succeed
$result = $this->withSession([
'user_id' => 1,
'is_logged_in' => true
])->call('get', '/admin/dashboard');
$result->assertStatus(200);
}Testing JSON APIs
public function testApiReturnsJson(): void
{
$result = $this->call('get', '/api/users');
$result->assertStatus(200);
$result->assertHeader('Content-Type', 'application/json; charset=UTF-8');
$json = json_decode($result->response()->getBody(), true);
$this->assertArrayHasKey('data', $json);
}6. Mocking & Stubs
Mocking Services
public function testWithMockedService(): void
{
// Create mock
$mockRepo = $this->createMock(UserRepositoryInterface::class);
$mockRepo->method('findById')
->willReturn(new User(['id' => 1, 'name' => 'Test']));
// Inject mock
$service = new UserService($mockRepo);
// Test
$user = $service->getUser(1);
$this->assertEquals('Test', $user->name);
}Mocking HTTP Responses
public function testExternalApiCall(): void
{
$mock = $this->createMock(CURLRequest::class);
$mock->method('get')->willReturn(
new MockResponse(['data' => 'test'], 200)
);
// Continue testing...
}7. Feature Tests
Feature tests validate complete user flows:
<?php
namespace Tests\Feature;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\DatabaseTestTrait;
class LoginFlowTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
protected $migrate = true;
protected $seed = 'TestSeeder';
public function testCompleteLoginFlow(): void
{
// 1. Visit login page
$result = $this->call('get', '/login');
$result->assertStatus(200);
$result->assertSee('Login');
// 2. Submit credentials
$result = $this->call('post', '/login', [
'email' => 'test@example.com',
'password' => 'password'
]);
// 3. Verify redirect to dashboard
$result->assertRedirectTo('/admin/dashboard');
// 4. Access protected page
$result = $this->call('get', '/admin/dashboard');
$result->assertStatus(200);
}
}8. API Testing
Testing with JWT
public function testApiWithJwtToken(): void
{
// Generate token
$jwt = new \App\Infrastructure\Auth\Jwt\JwtService();
$token = $jwt->encode(['sub' => 1, 'email' => 'test@example.com']);
// Make authenticated request
$result = $this->withHeaders([
'Authorization' => 'Bearer ' . $token
])->call('get', '/api/users');
$result->assertStatus(200);
}Testing Rate Limits
public function testRateLimitExceeded(): void
{
// Exhaust rate limit
for ($i = 0; $i < 61; $i++) {
$this->call('get', '/api/ping');
}
// Next request should be blocked
$result = $this->call('get', '/api/ping');
$result->assertStatus(429);
}Manual API Testing with cURL
# Test without auth (should fail)
curl -X GET http://localhost:81/api/users
# Expected: 401 Unauthorized
# Get JWT token
curl -X POST http://localhost:81/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com", "password": "password"}'
# Test with token
curl -X GET http://localhost:81/api/users \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# Test security headers
curl -I http://localhost:81/
# Look for: X-Frame-Options, X-Content-Type-Options9. Database Testing
Using Test Database
class DatabaseTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $migrate = true; // Run migrations
protected $seed = 'TestSeeder'; // Run seeder
public function testUserCreation(): void
{
$this->hasInDatabase('users', [
'email' => 'new@example.com'
]);
}
}Testing Migrations
public function testMigrationCreatesTable(): void
{
$db = \Config\Database::connect();
$this->assertTrue($db->tableExists('users'));
$this->assertTrue($db->fieldExists('email', 'users'));
}10. Code Coverage
Generating Coverage Report
# HTML report
docker-compose exec php php spark test --coverage-html writable/coverage
# Clover XML (for CI tools)
docker-compose exec php php spark test --coverage-clover writable/coverage.xml
# Text summary
docker-compose exec php php spark test --coverage-textViewing Coverage
Open writable/coverage/index.html in a browser to see the detailed coverage report.
11. CI/CD Integration
GitHub Actions Example
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
coverage: xdebug
- name: Install dependencies
run: composer install
- name: Run tests
run: php spark test --coverage-clover coverage.xml
- name: Upload coverage
uses: codecov/codecov-action@v212. Best Practices
Test Naming
- Use descriptive method names:
testUserCanLoginWithValidCredentials - Follow pattern:
test{Action}{Condition}{Expected}
Test Organization
- One assertion per test when possible
- Arrange-Act-Assert pattern
- No dependencies between tests
Test Isolation
protected function setUp(): void
{
parent::setUp();
// Reset state before each test
$this->resetDatabase();
$this->clearCache();
}
protected function tearDown(): void
{
// Clean up after each test
parent::tearDown();
}Data Providers
/**
* @dataProvider validEmailProvider
*/
public function testEmailValidation(string $email, bool $expected): void
{
$result = $this->validator->isValidEmail($email);
$this->assertEquals($expected, $result);
}
public function validEmailProvider(): array
{
return [
['test@example.com', true],
['invalid-email', false],
['user@domain.co.uk', true],
];
}Recommended Workflow
Write tests first (TDD) → Write failing test → Implement feature → Refactor → Repeat