v1.0
Docs / Unit Testing

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

  1. Overview
  2. Test Directory Structure
  3. Running Tests
  4. Test Suites
  5. Writing Tests
  6. Mocking & Stubs
  7. Feature Tests
  8. API Testing
  9. Database Testing
  10. Code Coverage
  11. CI/CD Integration
  12. Best Practices

1. Overview

The project uses PHPUnit with CodeIgniter 4's testing framework for comprehensive testing. The testing architecture supports:

Test Stats

MetricValue
Total Test Files8+
Total Test Methods80+
Test CategoriesUnit, 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 fixtures

3. 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 testJwtTokenGeneration

Running 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/database

Module-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 SecurityTest

4. Test Suites

4.1 SecurityTest.php

Tests for core security features:

Test MethodCoverage
testJwtTokenGenerationJWT token creation
testJwtTokenValidationToken decode and verify
testJwtTokenExpiryExpired token handling
testCsrfTokenPresenceCSRF token in forms
testSecurityHeadersPresentX-Frame-Options, etc.
testRateLimitEnforcedThrottling behavior

4.2 JwtServiceTest.php

Deep testing of the JWT infrastructure:

Test MethodCoverage
testEncodeCreatesValidTokenToken structure validation
testDecodeReturnsPayloadPayload extraction
testTamperedTokenThrowsTamper detection
testExpiredTokenThrowsExpiry enforcement
testInvalidSignatureRejectedSignature validation

4.3 AdminModuleTest.php

Admin panel functionality:

4.4 ApiModuleTest.php

REST API endpoint testing:

4.5 WebModuleTest.php

Public website testing:

4.6 RbacTest.php

Role-based access control:

4.7 LoginFlowTest.php

Feature tests for complete authentication flows:


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-Options

9. 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-text

Viewing 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@v2

12. Best Practices

Test Naming

Test Organization

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

ESC

Start typing to search the documentation