Introduction to PHP Testing
Introduction to Testing in PHP
Testing is a crucial part of software development that ensures your code works as expected, prevents regressions, and makes refactoring safer. PHP offers several testing frameworks and methodologies to help you write reliable, maintainable applications.
Why Testing Matters
Benefits of Testing
- Quality Assurance: Ensures your code works correctly
- Regression Prevention: Catches bugs when making changes
- Documentation: Tests serve as living documentation
- Refactoring Safety: Makes code changes safer
- Design Improvement: Forces you to write better code
- Confidence: Gives confidence in deployments
Types of Testing
<?php
// Unit Test - Tests individual components in isolation
class UserTest extends PHPUnit\Framework\TestCase {
public function testUserCreation() {
$user = new User('John', '[email protected]');
$this->assertEquals('John', $user->getName());
}
}
// Integration Test - Tests how components work together
class UserServiceTest extends PHPUnit\Framework\TestCase {
public function testUserRegistrationWithDatabase() {
$userService = new UserService($this->database);
$user = $userService->register('John', '[email protected]', 'password');
$this->assertInstanceOf(User::class, $user);
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
}
}
// Functional Test - Tests complete features from user perspective
class RegistrationTest extends PHPUnit\Framework\TestCase {
public function testCompleteUserRegistrationFlow() {
// Simulate HTTP request
$response = $this->post('/register', [
'name' => 'John',
'email' => '[email protected]',
'password' => 'password123'
]);
$response->assertStatus(201);
$response->assertJsonFragment(['message' => 'User registered successfully']);
}
}
?>
Testing Pyramid
The testing pyramid represents the ideal distribution of different types of tests:
/\
/ \ E2E Tests (Few, Slow, Expensive)
/____\
/ \ Integration Tests (Some, Medium)
/__________\ Unit Tests (Many, Fast, Cheap)
Unit Tests (Base of Pyramid)
- Test individual functions/methods in isolation
- Fast execution (milliseconds)
- Easy to write and maintain
- Should make up 70-80% of your test suite
Integration Tests (Middle)
- Test how multiple components work together
- Moderate execution time
- Test database interactions, API calls, etc.
- Should make up 15-25% of your test suite
End-to-End Tests (Top)
- Test complete user workflows
- Slow execution (seconds/minutes)
- Test the entire application stack
- Should make up 5-10% of your test suite
Popular PHP Testing Frameworks
PHPUnit
The most popular PHP testing framework, included with many frameworks by default.
# Install PHPUnit
composer require --dev phpunit/phpunit
# Run tests
vendor/bin/phpunit
<?php
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase {
private $calculator;
protected function setUp(): void {
$this->calculator = new Calculator();
}
public function testAddition() {
$result = $this->calculator->add(2, 3);
$this->assertEquals(5, $result);
}
public function testDivisionByZero() {
$this->expectException(DivisionByZeroError::class);
$this->calculator->divide(10, 0);
}
}
?>
Pest
A modern testing framework with elegant syntax.
# Install Pest
composer require --dev pestphp/pest
<?php
// Pest syntax
test('calculator can add numbers', function () {
$calculator = new Calculator();
expect($calculator->add(2, 3))->toBe(5);
});
it('throws exception when dividing by zero', function () {
$calculator = new Calculator();
$calculator->divide(10, 0);
})->throws(DivisionByZeroError::class);
?>
Codeception
Full-stack testing framework supporting unit, functional, and acceptance tests.
# Install Codeception
composer require --dev codeception/codeception
<?php
// Codeception acceptance test
class UserRegistrationCest {
public function registerNewUser(AcceptanceTester $I) {
$I->amOnPage('/register');
$I->fillField('name', 'John Doe');
$I->fillField('email', '[email protected]');
$I->fillField('password', 'password123');
$I->click('Register');
$I->see('Registration successful');
}
}
?>
Writing Your First Test
Setting Up PHPUnit
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
verbose="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">./src</directory>
</include>
</coverage>
</phpunit>
Basic Test Structure
<?php
use PHPUnit\Framework\TestCase;
class UserTest extends TestCase {
// Setup method runs before each test
protected function setUp(): void {
parent::setUp();
// Initialize test data
}
// Teardown method runs after each test
protected function tearDown(): void {
// Clean up after test
parent::tearDown();
}
// Test methods must start with 'test' or use @test annotation
public function testUserCanBeCreated() {
// Arrange
$name = 'John Doe';
$email = '[email protected]';
// Act
$user = new User($name, $email);
// Assert
$this->assertInstanceOf(User::class, $user);
$this->assertEquals($name, $user->getName());
$this->assertEquals($email, $user->getEmail());
}
/**
* @test
*/
public function user_email_must_be_valid() {
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid email format');
new User('John', 'invalid-email');
}
}
?>
Common Testing Patterns
Arrange-Act-Assert (AAA)
<?php
public function testUserCanUpdateProfile() {
// Arrange - Set up test data
$user = new User('John', '[email protected]');
$newName = 'John Doe';
// Act - Execute the action being tested
$user->updateName($newName);
// Assert - Verify the results
$this->assertEquals($newName, $user->getName());
}
?>
Data Providers
<?php
class MathTest extends TestCase {
/**
* @dataProvider additionProvider
*/
public function testAddition($a, $b, $expected) {
$calculator = new Calculator();
$this->assertEquals($expected, $calculator->add($a, $b));
}
public function additionProvider() {
return [
[2, 3, 5],
[0, 0, 0],
[-1, 1, 0],
[100, 200, 300],
];
}
}
?>
Fixtures and Factories
<?php
class UserFactory {
public static function create($overrides = []) {
$defaults = [
'name' => 'John Doe',
'email' => '[email protected]',
'age' => 30,
'created_at' => new DateTime()
];
$attributes = array_merge($defaults, $overrides);
return new User(
$attributes['name'],
$attributes['email'],
$attributes['age'],
$attributes['created_at']
);
}
}
// Usage in tests
public function testUserWithCustomAttributes() {
$user = UserFactory::create([
'name' => 'Jane Smith',
'age' => 25
]);
$this->assertEquals('Jane Smith', $user->getName());
$this->assertEquals(25, $user->getAge());
}
?>
Mocking and Stubs
Creating Mocks
<?php
class UserServiceTest extends TestCase {
public function testUserRegistrationSendsEmail() {
// Create mock objects
$emailService = $this->createMock(EmailService::class);
$userRepository = $this->createMock(UserRepository::class);
// Set expectations
$emailService->expects($this->once())
->method('sendWelcomeEmail')
->with($this->isInstanceOf(User::class));
$userRepository->expects($this->once())
->method('save')
->willReturn(true);
// Test the service
$userService = new UserService($userRepository, $emailService);
$userService->register('John', '[email protected]', 'password');
}
}
?>
Using Prophecy (Alternative Mocking)
<?php
use Prophecy\PhpUnit\ProphecyTrait;
class UserServiceTest extends TestCase {
use ProphecyTrait;
public function testUserRegistration() {
// Create prophecies
$emailService = $this->prophesize(EmailService::class);
$userRepository = $this->prophesize(UserRepository::class);
// Set expectations
$userRepository->save(Argument::type(User::class))
->shouldBeCalled()
->willReturn(true);
$emailService->sendWelcomeEmail(Argument::type(User::class))
->shouldBeCalled();
// Test the service
$userService = new UserService(
$userRepository->reveal(),
$emailService->reveal()
);
$userService->register('John', '[email protected]', 'password');
}
}
?>
Database Testing
In-Memory Database
<?php
class DatabaseTestCase extends TestCase {
protected $pdo;
protected function setUp(): void {
// Use in-memory SQLite for fast tests
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Create test schema
$this->createSchema();
$this->seedTestData();
}
protected function createSchema() {
$this->pdo->exec('
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
');
}
protected function seedTestData() {
$this->pdo->exec("
INSERT INTO users (name, email) VALUES
('John Doe', '[email protected]'),
('Jane Smith', '[email protected]')
");
}
}
class UserRepositoryTest extends DatabaseTestCase {
public function testFindUserByEmail() {
$repository = new UserRepository($this->pdo);
$user = $repository->findByEmail('[email protected]');
$this->assertNotNull($user);
$this->assertEquals('John Doe', $user['name']);
}
}
?>
Database Transactions for Isolation
<?php
class DatabaseTest extends TestCase {
protected $pdo;
protected function setUp(): void {
$this->pdo = new PDO('mysql:host=localhost;dbname=test_db', $user, $pass);
$this->pdo->beginTransaction();
}
protected function tearDown(): void {
$this->pdo->rollBack();
}
public function testUserCreation() {
$repository = new UserRepository($this->pdo);
$userId = $repository->create([
'name' => 'Test User',
'email' => '[email protected]'
]);
$this->assertIsInt($userId);
$user = $repository->find($userId);
$this->assertEquals('Test User', $user['name']);
// Transaction will be rolled back in tearDown()
}
}
?>
Testing Best Practices
1. Test Structure and Naming
<?php
// Good - Descriptive test names
public function testUserCannotRegisterWithInvalidEmail() { }
public function testOrderCalculatesTotalCorrectlyWithDiscount() { }
public function testPasswordMustMeetSecurityRequirements() { }
// Bad - Vague test names
public function testUser() { }
public function testCalculation() { }
public function testValidation() { }
?>
2. One Assertion Per Test (When Possible)
<?php
// Good - Single concern per test
public function testUserNameCanBeUpdated() {
$user = new User('John', '[email protected]');
$user->updateName('Jane');
$this->assertEquals('Jane', $user->getName());
}
public function testUserEmailCanBeUpdated() {
$user = new User('John', '[email protected]');
$user->updateEmail('[email protected]');
$this->assertEquals('[email protected]', $user->getEmail());
}
// Acceptable - Related assertions
public function testUserConstructorSetsProperties() {
$user = new User('John', '[email protected]');
$this->assertEquals('John', $user->getName());
$this->assertEquals('[email protected]', $user->getEmail());
}
?>
3. Test Independence
<?php
class UserTest extends TestCase {
// Good - Each test is independent
public function testUserCreation() {
$user = new User('John', '[email protected]');
$this->assertInstanceOf(User::class, $user);
}
public function testUserEmailUpdate() {
$user = new User('John', '[email protected]');
$user->updateEmail('[email protected]');
$this->assertEquals('[email protected]', $user->getEmail());
}
// Bad - Tests depend on each other
private static $globalUser;
public function testCreateUser() {
self::$globalUser = new User('John', '[email protected]');
$this->assertInstanceOf(User::class, self::$globalUser);
}
public function testUpdateUserEmail() {
// This test depends on testCreateUser running first
self::$globalUser->updateEmail('[email protected]');
$this->assertEquals('[email protected]', self::$globalUser->getEmail());
}
}
?>
4. Use Descriptive Test Data
<?php
// Good - Meaningful test data
public function testDiscountCalculationForSeniorCustomer() {
$customer = new Customer('John Doe', 70); // 70 years old
$order = new Order($customer);
$order->addItem(new Product('Book', 20.00));
$discount = $order->calculateSeniorDiscount();
$this->assertEquals(3.00, $discount); // 15% discount
}
// Bad - Magic numbers and unclear data
public function testDiscount() {
$customer = new Customer('A', 70);
$order = new Order($customer);
$order->addItem(new Product('B', 20));
$discount = $order->calculateSeniorDiscount();
$this->assertEquals(3, $discount);
}
?>
Code Coverage
Generating Coverage Reports
# Generate HTML coverage report
vendor/bin/phpunit --coverage-html coverage
# Generate text coverage report
vendor/bin/phpunit --coverage-text
# Generate coverage for specific directories
vendor/bin/phpunit --coverage-html coverage --whitelist src/
Coverage Configuration
<!-- phpunit.xml -->
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./src</directory>
</include>
<exclude>
<directory suffix=".php">./src/config</directory>
<file>./src/bootstrap.php</file>
</exclude>
<report>
<html outputDirectory="coverage-html"/>
<text outputFile="coverage.txt"/>
<clover outputFile="coverage.xml"/>
</report>
</coverage>
Understanding Coverage Metrics
<?php
class Calculator {
public function add($a, $b) {
return $a + $b; // Line covered if tested
}
public function divide($a, $b) {
if ($b === 0) { // Branch coverage - both true/false paths should be tested
throw new DivisionByZeroError();
}
return $a / $b;
}
}
class CalculatorTest extends TestCase {
public function testAdd() {
$calc = new Calculator();
$this->assertEquals(5, $calc->add(2, 3)); // Covers add() method
}
public function testDivide() {
$calc = new Calculator();
$this->assertEquals(2, $calc->divide(4, 2)); // Covers divide() normal path
}
public function testDivideByZero() {
$calc = new Calculator();
$this->expectException(DivisionByZeroError::class);
$calc->divide(4, 0); // Covers divide() exception path
}
}
?>
Continuous Integration
GitHub Actions Example
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: [7.4, 8.0, 8.1, 8.2]
steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: mbstring, intl, pdo_sqlite
coverage: xdebug
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run tests
run: vendor/bin/phpunit --coverage-clover coverage.xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
with:
file: ./coverage.xml
Testing Tools and Utilities
PHPStan for Static Analysis
# Install PHPStan
composer require --dev phpstan/phpstan
# Run analysis
vendor/bin/phpstan analyse src tests --level max
Psalm for Type Checking
# Install Psalm
composer require --dev vimeo/psalm
# Initialize and run
vendor/bin/psalm --init
vendor/bin/psalm
Infection for Mutation Testing
# Install Infection
composer require --dev infection/infection
# Run mutation tests
vendor/bin/infection
Testing is an essential skill for PHP developers. Start with unit tests for your core business logic, gradually add integration tests for component interactions, and use end-to-end tests sparingly for critical user journeys. Remember that good tests not only catch bugs but also document your code's intended behavior and make refactoring safer.