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 dependenciesrequire-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 viacomposer 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/- onlyrequire) - 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:
-
Move tests to
tests/directory:mkdir tests mv test_*.php tests/ mv create_test_db.php tests/fixtures/ -
Update justfile to exclude tests:
deploy: rsync -vur --progress \ --exclude 'tests/' \ --exclude 'cache/' \ --exclude '*.db' \ ./front-backend/ server:/var/www/html/ -
Add .gitignore:
/cache/ /vendor/ *.log test.db
Recommended Approach (Industry Standard)
For a more professional setup:
-
Install PHPUnit:
composer require --dev phpunit/phpunit -
Convert tests to PHPUnit (I can help with this)
-
Add phpunit.xml configuration
-
Update deployment to use
composer install --no-dev
Benefits of Standard Approach
- Automatic Exclusion: Tests never deployed by accident
- Better Assertions: PHPUnit provides rich assertion library
- Coverage Reports: See which code is tested
- CI/CD Integration: Automated testing on every commit
- IDE Support: Better integration with PHPStorm, VSCode
- Mocking: Easy to mock dependencies
- Data Providers: Test same logic with multiple inputs
- 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:
- Move tests to
tests/directory (immediate) - Update deployment to exclude
tests/(immediate) - Keep simple PHP test scripts for now (works fine)
- Migrate to PHPUnit later (when project grows)
Would you like me to help with any of these approaches?