1. php
  2. /testing

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

  1. Quality Assurance: Ensures your code works correctly
  2. Regression Prevention: Catches bugs when making changes
  3. Documentation: Tests serve as living documentation
  4. Refactoring Safety: Makes code changes safer
  5. Design Improvement: Forces you to write better code
  6. 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

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.