Files
xamxam/apps/public/TESTING_BEST_PRACTICES.md
Théophile Gervreau-Mercier 467aced734 Restructure repository and implement secure search feature
Phase 1: Consolidate shared infrastructure
- Create shared/ directory for common code
- Consolidate Database.php from front-backend and formulaire into unified shared/Database.php
  - Smart path detection for test.db vs posterg.db
  - Secure search with wildcard escaping and input validation
  - Support both singleton and direct instantiation patterns
  - Full CRUD methods for admin functionality
- Move RateLimit.php to shared/ (30 requests/min)
- Update all require paths across apps to use shared/

Phase 2: Reorganize directory structure
- Rename front-backend/ → apps/public/
- Rename formulaire/ → apps/admin/
- Rename db/ → database/
- Update all file paths for new structure
- Create root .gitignore excluding databases, cache, logs

Implement secure search feature
- Add apps/public/search.php with full-text search across theses
- Search filters: query, year, orientation, AP program, keywords
- Security features:
  - SQL injection prevention (prepared statements)
  - Wildcard injection prevention (escape % and _)
  - Input validation (max 200 chars, year range 1900-2100)
  - Rate limiting (30 req/min per IP)
  - Pagination limited to 100 results/page
  - XSS protection (htmlspecialchars on output)

Add comprehensive test suite
- Create apps/public/tests/ with proper structure
  - tests/Integration/SearchTest.php - 12 search scenarios
  - tests/Security/SecurityTest.php - vulnerability testing
  - tests/Unit/RateLimitTest.php - rate limit behavior
- Create database/fixtures/CreateTestDatabase.php
- Add apps/public/run-tests.php test runner
- All tests passing (4/4 suites)

Update deployment configuration
- Rename justfile 'sync' recipe to 'deploy'
- Create deploy group with separate deploy-public and deploy-admin
- Add test-deploy recipe for test database
- Exclude *.db, tests/, cache/, *.md from production deploy
- Deploy shared/ to both public and admin locations

Stats: +4482 insertions, -654 deletions across 72 files
2026-02-02 18:53:58 +01:00

11 KiB

PHP Testing Best Practices

Standard PHP Testing Structure

Industry Standard: PHPUnit

The de facto standard for PHP testing is PHPUnit. Here's how professional PHP projects handle testing:

Proper Directory Structure

front-backend/
├── src/                    # Application code (or keep in root for small projects)
│   ├── Database.php
│   ├── RateLimit.php
│   └── ...
├── tests/                  # All tests go here
│   ├── Unit/              # Unit tests (test individual methods)
│   │   ├── DatabaseTest.php
│   │   └── RateLimitTest.php
│   ├── Integration/       # Integration tests (test multiple components)
│   │   └── SearchTest.php
│   └── Security/          # Security-specific tests
│       └── SecurityTest.php
├── public/                 # Public-facing files (or web root)
│   ├── index.php
│   ├── search.php
│   └── assets/
├── vendor/                 # Dependencies (git-ignored, not deployed)
├── cache/                  # Runtime cache (not deployed)
├── composer.json           # Dependency management
├── phpunit.xml            # PHPUnit configuration
└── .gitignore             # Excludes tests, vendor, cache from git

What We Currently Have (Non-Standard)

front-backend/
├── test_search.php         ❌ Tests in root
├── test_security.php       ❌ No framework
├── test_rate_limit.php     ❌ Would deploy to production
├── create_test_db.php      ❌ Test fixture in root
└── Database.php            ✓ OK

How Professional Projects Work

1. Composer Configuration

composer.json - Proper setup:

{
    "require": {
        "php": "^7.4|^8.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^9.5",
        "symfony/var-dumper": "^6.0"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    },
    "scripts": {
        "test": "phpunit",
        "test:coverage": "phpunit --coverage-html coverage"
    }
}

Key points:

  • require: Production dependencies
  • require-dev: Development/testing dependencies (not deployed)
  • autoload-dev: Test autoloading (not in production)
  • scripts: Convenient test commands

2. PHPUnit Configuration

phpunit.xml - Test configuration:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
         colors="true"
         verbose="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
        <testsuite name="Security">
            <directory>tests/Security</directory>
        </testsuite>
    </testsuites>
    <coverage>
        <include>
            <directory suffix=".php">src</directory>
        </include>
        <exclude>
            <directory>vendor</directory>
            <directory>tests</directory>
        </exclude>
    </coverage>
</phpunit>

3. Example PHPUnit Test

tests/Unit/DatabaseTest.php:

<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Database;

class DatabaseTest extends TestCase
{
    private $db;

    protected function setUp(): void
    {
        $this->db = Database::getInstance();
    }

    public function testGetPublishedTheses()
    {
        $results = $this->db->getPublishedTheses(10, 0);

        $this->assertIsArray($results);
        $this->assertLessThanOrEqual(10, count($results));
    }

    public function testSearchThesesWithWildcard()
    {
        $results = $this->db->searchTheses(['query' => '%'], 10, 0);

        // Should return 0 results (wildcards are escaped)
        $this->assertCount(0, $results);
    }

    public function testSearchThesesRejectsLongInput()
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Search query too long');

        $longQuery = str_repeat('a', 201);
        $this->db->searchTheses(['query' => $longQuery]);
    }

    public function testSearchThesesRejectsInvalidYear()
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Invalid year');

        $this->db->searchTheses(['year' => 999999]);
    }
}

4. Running Tests

# Install dependencies (including dev dependencies)
composer install

# Run all tests
composer test
# or
./vendor/bin/phpunit

# Run specific test suite
./vendor/bin/phpunit --testsuite Unit

# Run specific test file
./vendor/bin/phpunit tests/Unit/DatabaseTest.php

# Run with coverage report
composer test:coverage

5. .gitignore Configuration

.gitignore:

# Dependencies
/vendor/

# Test artifacts
/coverage/
/.phpunit.cache/
/phpunit.xml.local

# Cache
/cache/

# Environment
.env
.env.local

# IDE
/.idea/
/.vscode/
*.swp

# OS
.DS_Store
Thumbs.db

# Logs
*.log
error.log

Important: Tests themselves ARE committed to git, but:

  • vendor/ is excluded (regenerated via composer install)
  • Test coverage reports are excluded
  • Cache is excluded

Production Deployment

What Gets Deployed

# Option 1: composer install without dev dependencies
composer install --no-dev --optimize-autoloader

# This installs ONLY 'require' packages, NOT 'require-dev'
# Result: No PHPUnit, no test dependencies

Deployed:

  • Application code (src/ or root PHP files)
  • Production dependencies (vendor/ - only require)
  • Public assets (public/, assets/)

NOT Deployed:

  • tests/ directory (excluded via deployment config)
  • Dev dependencies (PHPUnit, etc.)
  • cache/ directory
  • .git/ directory

Deployment Configurations

Option 1: .deployignore (custom deploy scripts):

/tests/
/coverage/
/.git/
/.github/
/cache/
phpunit.xml
phpunit.xml.dist
.env.example
README*.md
*.md

Option 2: rsync with excludes (like your justfile):

rsync -avz \
  --exclude 'tests/' \
  --exclude 'coverage/' \
  --exclude 'cache/' \
  --exclude '.git/' \
  --exclude 'phpunit.xml' \
  --exclude '*.md' \
  ./ server:/var/www/html/

Option 3: Build artifact (best for large projects):

# Build step
composer install --no-dev --optimize-autoloader
# Creates clean vendor/ with only production deps

# Then deploy only necessary files

Continuous Integration (CI/CD)

Professional projects run tests automatically:

GitHub Actions (.github/workflows/tests.yml):

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'

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Run tests
        run: composer test

      - name: Check security
        run: ./vendor/bin/phpunit --testsuite Security

Test Types

Unit Tests

Test individual methods in isolation:

public function testEscapeLikeString()
{
    $db = new Database();
    $reflection = new ReflectionClass($db);
    $method = $reflection->getMethod('escapeLikeString');
    $method->setAccessible(true);

    $result = $method->invoke($db, 'test%value_here');
    $this->assertEquals('test\%value\_here', $result);
}

Integration Tests

Test multiple components together:

public function testSearchWithMultipleFilters()
{
    $db = Database::getInstance();
    $results = $db->searchTheses([
        'query' => 'urbain',
        'year' => 2024,
        'orientation' => 'Arts Numériques'
    ]);

    $this->assertNotEmpty($results);
    foreach ($results as $result) {
        $this->assertEquals(2024, $result['year']);
    }
}

Security Tests

Test security measures:

public function testSqlInjectionPrevention()
{
    $db = Database::getInstance();

    // These should not cause errors or expose data
    $malicious = ["' OR 1=1--", "'; DROP TABLE theses;--"];

    foreach ($malicious as $injection) {
        $results = $db->searchTheses(['query' => $injection]);
        // Treated as literal strings, returns valid results or empty
        $this->assertIsArray($results);
    }
}

Comparison: Current vs. Standard

Aspect Current Approach Standard Approach
Location Root directory tests/ directory
Framework Raw PHP scripts PHPUnit
Naming test_*.php *Test.php
Running php test_file.php composer test
CI/CD Manual Automated
Production Must manually exclude Auto-excluded
Coverage None Built-in reporting
Assertions Manual echoing PHPUnit assertions

Migration Path for Your Project

Minimal Changes (Keep it Simple)

If you want to keep the current simple approach but make it safer:

  1. Move tests to tests/ directory:

    mkdir tests
    mv test_*.php tests/
    mv create_test_db.php tests/fixtures/
    
  2. Update justfile to exclude tests:

    deploy:
        rsync -vur --progress \
          --exclude 'tests/' \
          --exclude 'cache/' \
          --exclude '*.db' \
          ./front-backend/ server:/var/www/html/
    
  3. Add .gitignore:

    /cache/
    /vendor/
    *.log
    test.db
    

For a more professional setup:

  1. Install PHPUnit:

    composer require --dev phpunit/phpunit
    
  2. Convert tests to PHPUnit (I can help with this)

  3. Add phpunit.xml configuration

  4. Update deployment to use composer install --no-dev

Benefits of Standard Approach

  1. Automatic Exclusion: Tests never deployed by accident
  2. Better Assertions: PHPUnit provides rich assertion library
  3. Coverage Reports: See which code is tested
  4. CI/CD Integration: Automated testing on every commit
  5. IDE Support: Better integration with PHPStorm, VSCode
  6. Mocking: Easy to mock dependencies
  7. Data Providers: Test same logic with multiple inputs
  8. Professional: Expected by other developers

Quick Decision Guide

Keep Simple Approach If:

  • ✓ Small project (< 10 files)
  • ✓ Solo developer
  • ✓ No CI/CD pipeline
  • ✓ You manually test before deploy

Use PHPUnit If:

  • ✓ Team project
  • ✓ Growing codebase
  • ✓ Want automated testing
  • ✓ Need coverage reports
  • ✓ Planning CI/CD

Recommendation for Your Project

Given your project size, I'd suggest a hybrid approach:

  1. Move tests to tests/ directory (immediate)
  2. Update deployment to exclude tests/ (immediate)
  3. Keep simple PHP test scripts for now (works fine)
  4. Migrate to PHPUnit later (when project grows)

Would you like me to help with any of these approaches?