# 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: ```json { "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 tests/Unit tests/Integration tests/Security src vendor tests ``` ### 3. Example PHPUnit Test **tests/Unit/DatabaseTest.php**: ```php 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 ```bash # 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 ```bash # 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): ```bash 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): ```bash # 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): ```yaml 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: ```php 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: ```php 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: ```php 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:** ```bash mkdir tests mv test_*.php tests/ mv create_test_db.php tests/fixtures/ ``` 2. **Update justfile to exclude tests:** ```just deploy: rsync -vur --progress \ --exclude 'tests/' \ --exclude 'cache/' \ --exclude '*.db' \ ./front-backend/ server:/var/www/html/ ``` 3. **Add .gitignore:** ``` /cache/ /vendor/ *.log test.db ``` ### Recommended Approach (Industry Standard) For a more professional setup: 1. **Install PHPUnit:** ```bash 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?