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.