Introduction to PHPUnit
PHPUnit is the de facto standard for testing PHP applications. Created by Sebastian Bergmann, PHPUnit provides a comprehensive framework for writing and running automated tests, helping developers ensure their code works as expected.
Real-world analogy: Think of PHPUnit as a quality control system in a manufacturing process. Just as a factory performs various checks to ensure that products meet specifications before they're shipped, PHPUnit runs tests to verify that your code behaves correctly before it's deployed.
Setting Up PHPUnit
Installation
The recommended way to install PHPUnit is through Composer, PHP's dependency manager:
# Install as a development dependency
composer require --dev phpunit/phpunit ^10.0
# Alternatively, install globally
composer global require phpunit/phpunit
Project Configuration
PHPUnit is configured using a phpunit.xml file in your project root:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>
Directory Structure
A common project structure for tests:
my-project/
├── src/
│ └── ...
├── tests/
│ ├── Unit/
│ │ └── ...
│ ├── Integration/
│ │ └── ...
│ └── bootstrap.php
├── composer.json
└── phpunit.xml
Real-world example: Laravel, one of PHP's most popular frameworks, is built with testing in mind and includes PHPUnit integration out of the box. Its directory structure and configuration follow these conventions, making it easy for developers to start testing right away.
Writing Your First Test
Let's create a simple test for a calculator class:
The Class Under Test
// src/Calculator.php
namespace App;
class Calculator
{
public function add($a, $b)
{
return $a + $b;
}
public function subtract($a, $b)
{
return $a - $b;
}
public function multiply($a, $b)
{
return $a * $b;
}
public function divide($a, $b)
{
if ($b == 0) {
throw new \InvalidArgumentException("Cannot divide by zero");
}
return $a / $b;
}
}
The Test Case
// tests/Unit/CalculatorTest.php
namespace Tests\Unit;
use App\Calculator;
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase
{
protected $calculator;
protected function setUp(): void
{
$this->calculator = new Calculator();
}
public function testAdd()
{
$result = $this->calculator->add(5, 3);
$this->assertEquals(8, $result);
}
public function testSubtract()
{
$result = $this->calculator->subtract(5, 3);
$this->assertEquals(2, $result);
}
public function testMultiply()
{
$result = $this->calculator->multiply(5, 3);
$this->assertEquals(15, $result);
}
public function testDivide()
{
$result = $this->calculator->divide(6, 3);
$this->assertEquals(2, $result);
}
public function testDivideByZero()
{
$this->expectException(\InvalidArgumentException::class);
$this->calculator->divide(6, 0);
}
}
Running the Test
# Run all tests
./vendor/bin/phpunit
# Run a specific test file
./vendor/bin/phpunit tests/Unit/CalculatorTest.php
# Run a specific test method
./vendor/bin/phpunit --filter testAdd tests/Unit/CalculatorTest.php
The output will look something like this:
PHPUnit 10.0.0 by Sebastian Bergmann and contributors.
..... 5 / 5 (100%)
Time: 0.01 seconds, Memory: 4.00 MB
OK (5 tests, 5 assertions)
PHPUnit Test Structure
Test Case Basics
A test case in PHPUnit is a class that extends PHPUnit\Framework\TestCase. Each test method should:
- Be public
- Typically start with the word "test"
- Describe a single aspect of the class under test
class ExampleTest extends TestCase
{
// Test methods
public function testSomething()
{
// Test code
}
public function testSomethingElse()
{
// More test code
}
}
Setup and Teardown
PHPUnit provides hooks for setting up and cleaning up test environments:
class DatabaseTest extends TestCase
{
protected $db;
// Runs once before the first test in the class
public static function setUpBeforeClass(): void
{
// Initialize shared resources
}
// Runs before each test method
protected function setUp(): void
{
$this->db = new Database();
$this->db->createTable('users');
}
// Runs after each test method
protected function tearDown(): void
{
$this->db->dropTable('users');
$this->db = null;
}
// Runs once after the last test in the class
public static function tearDownAfterClass(): void
{
// Clean up shared resources
}
// Test methods...
}
Naming Conventions
Following consistent naming conventions makes tests more readable:
- Test classes should match the class they're testing with "Test" appended
- Test methods should be descriptive of what they're testing
- Consider using the pattern "test[MethodName][Scenario][ExpectedResult]"
// Good naming examples
class UserRepositoryTest extends TestCase
{
public function testCreateUserWithValidDataReturnsUser()
{
// Test code
}
public function testFindUserByIdReturnsNullWhenUserNotFound()
{
// Test code
}
}
Assertions
Assertions are the heart of PHPUnit tests. They verify that your code behaves as expected.
Basic Assertions
// Equality
$this->assertEquals($expected, $actual);
$this->assertSame($expected, $actual); // Strict comparison (===)
$this->assertNotEquals($expected, $actual);
$this->assertNotSame($expected, $actual);
// Boolean assertions
$this->assertTrue($condition);
$this->assertFalse($condition);
// Emptiness
$this->assertEmpty($value);
$this->assertNotEmpty($value);
// Nullness
$this->assertNull($value);
$this->assertNotNull($value);
// Arrays
$this->assertCount($expectedCount, $array);
$this->assertContains($needle, $haystack);
$this->assertArrayHasKey($key, $array);
Advanced Assertions
// Instance checks
$this->assertInstanceOf(User::class, $user);
// File and directory
$this->assertFileExists($path);
$this->assertDirectoryExists($path);
// String comparisons
$this->assertStringContainsString($needle, $haystack);
$this->assertStringStartsWith($prefix, $string);
$this->assertStringEndsWith($suffix, $string);
// Regular expressions
$this->assertMatchesRegularExpression('/pattern/', $string);
// JSON
$this->assertJson($jsonString);
$this->assertJsonStringEqualsJsonString($expectedJson, $actualJson);
// XML
$this->assertXmlStringEqualsXmlString($expectedXml, $actualXml);
Expecting Exceptions
// Method 1: Use expectException before calling the method
public function testDivideByZero()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("Cannot divide by zero");
$calculator = new Calculator();
$calculator->divide(5, 0);
}
// Method 2: Use the callback-based approach
public function testDivideByZero()
{
$calculator = new Calculator();
$this->assertException(function() use ($calculator) {
$calculator->divide(5, 0);
}, \InvalidArgumentException::class);
}
Real-world example: When testing a payment processing system, you might use assertions to verify that proper exceptions are thrown for declined transactions, that payment amounts are calculated correctly, and that confirmation messages contain the right information.
Data Providers
Data providers allow you to run the same test with multiple sets of data, making it easy to test various scenarios.
class CalculatorTest extends TestCase
{
protected $calculator;
protected function setUp(): void
{
$this->calculator = new Calculator();
}
/**
* @dataProvider additionProvider
*/
public function testAdd($a, $b, $expected)
{
$result = $this->calculator->add($a, $b);
$this->assertEquals($expected, $result);
}
public function additionProvider()
{
return [
'positive numbers' => [5, 3, 8],
'negative numbers' => [-5, -3, -8],
'mixed numbers' => [5, -3, 2],
'zeros' => [0, 0, 0],
'decimals' => [1.5, 2.5, 4.0]
];
}
/**
* @dataProvider divisionProvider
*/
public function testDivide($a, $b, $expected)
{
$result = $this->calculator->divide($a, $b);
$this->assertEquals($expected, $result);
}
public function divisionProvider()
{
return [
'integer division' => [6, 3, 2],
'float result' => [5, 2, 2.5],
'divide by negative' => [6, -3, -2],
'zero dividend' => [0, 5, 0]
];
}
/**
* @dataProvider divisionByZeroProvider
*/
public function testDivideByZero($a, $b)
{
$this->expectException(\InvalidArgumentException::class);
$this->calculator->divide($a, $b);
}
public function divisionByZeroProvider()
{
return [
'positive dividend' => [5, 0],
'negative dividend' => [-5, 0],
'zero dividend' => [0, 0]
];
}
}
Benefits of data providers:
- Reduces code duplication
- Makes it easy to add new test cases
- Provides clear documentation of what's being tested
- Improves test coverage by encouraging testing of edge cases
Testing Exceptions
Testing that your code throws the right exceptions in the right situations is an important part of testing error handling.
Using expectException
public function testDivideByZero()
{
$calculator = new Calculator();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Cannot divide by zero');
$this->expectExceptionCode(0);
$calculator->divide(5, 0);
}
Using the try/catch approach
public function testDivideByZero()
{
$calculator = new Calculator();
try {
$calculator->divide(5, 0);
$this->fail('Expected InvalidArgumentException was not thrown');
} catch (\InvalidArgumentException $e) {
$this->assertEquals('Cannot divide by zero', $e->getMessage());
}
}
Testing no exceptions are thrown
public function testDivideByNonZero()
{
$calculator = new Calculator();
try {
$result = $calculator->divide(6, 3);
$this->assertEquals(2, $result);
} catch (\Exception $e) {
$this->fail('Exception was thrown: ' . $e->getMessage());
}
}
Test Doubles: Mocks, Stubs, and Spies
PHPUnit provides tools for creating test doubles - objects that stand in for real dependencies during testing.
Types of Test Doubles
- Dummy - Objects that are passed around but never actually used
- Stub - Provides predefined answers to method calls
- Spy - Records method calls for verification
- Mock - Pre-programmed with expectations of calls it will receive
- Fake - Has actual working implementations but is simplified
Creating Stubs
public function testUserRepositoryStub()
{
// Create a stub for the UserRepository class
$userRepo = $this->createStub(UserRepository::class);
// Configure the stub to return a specific user when findById is called
$user = new User(['id' => 1, 'name' => 'John Doe']);
$userRepo->method('findById')->willReturn($user);
// Pass the stub to the service we're testing
$userService = new UserService($userRepo);
$result = $userService->getUserName(1);
$this->assertEquals('John Doe', $result);
}
Creating Mocks
public function testUserServiceNotifiesOnUserDeletion()
{
// Create a mock for the Notifier class
$notifier = $this->createMock(Notifier::class);
// Set expectations on the mock
$notifier->expects($this->once())
->method('sendDeletionNotification')
->with($this->equalTo(1));
// Create a stub for the UserRepository
$userRepo = $this->createStub(UserRepository::class);
$userRepo->method('deleteUser')->willReturn(true);
// Pass the mock and stub to the service
$userService = new UserService($userRepo, $notifier);
$userService->deleteUser(1);
// PHPUnit will automatically verify expectations when the test ends
}
Mock Method Expectations
// Control how many times a method is called
$mock->expects($this->never())->method('someMethod');
$mock->expects($this->once())->method('someMethod');
$mock->expects($this->exactly(3))->method('someMethod');
$mock->expects($this->atLeastOnce())->method('someMethod');
$mock->expects($this->atMost(5))->method('someMethod');
// Control the arguments a method is called with
$mock->expects($this->once())
->method('someMethod')
->with($this->equalTo('expected value'));
// More complex argument matchers
$mock->expects($this->once())
->method('someMethod')
->with(
$this->anything(), // Any value
$this->greaterThan(5), // Number greater than 5
$this->stringContains('substring'), // String containing 'substring'
$this->callback(function($value) { // Custom validation logic
return $value % 2 === 0; // Must be even
})
);
Real-world example: When testing an e-commerce checkout service, you might create a mock of the payment processor to verify that it's called with the correct amount and payment details, without actually charging a real credit card.
A Complex Example: User Service
Let's see PHPUnit in action with a more realistic example: a user service that interacts with a repository and a mailer.
The Code Under Test
// src/UserRepository.php
namespace App;
class UserRepository
{
private $users = [];
public function __construct(array $initialUsers = [])
{
$this->users = $initialUsers;
}
public function findById(int $id)
{
return $this->users[$id] ?? null;
}
public function save(User $user)
{
$id = $user->getId() ?: $this->getNextId();
$user->setId($id);
$this->users[$id] = $user;
return $user;
}
public function delete(int $id): bool
{
if (!isset($this->users[$id])) {
return false;
}
unset($this->users[$id]);
return true;
}
private function getNextId(): int
{
return empty($this->users) ? 1 : max(array_keys($this->users)) + 1;
}
}
// src/Mailer.php
namespace App;
class Mailer
{
public function sendWelcomeEmail(User $user)
{
// In a real application, this would send an email
// For simplicity, let's just return true
return true;
}
public function sendDeletionEmail(User $user)
{
// In a real application, this would send an email
// For simplicity, let's just return true
return true;
}
}
// src/User.php
namespace App;
class User
{
private $id;
private $email;
private $name;
public function __construct(array $data = [])
{
$this->id = $data['id'] ?? null;
$this->email = $data['email'] ?? '';
$this->name = $data['name'] ?? '';
}
// Getters and setters
public function getId()
{
return $this->id;
}
public function setId(int $id)
{
$this->id = $id;
return $this;
}
public function getEmail()
{
return $this->email;
}
public function setEmail(string $email)
{
$this->email = $email;
return $this;
}
public function getName()
{
return $this->name;
}
public function setName(string $name)
{
$this->name = $name;
return $this;
}
}
// src/UserService.php
namespace App;
class UserService
{
private $repository;
private $mailer;
public function __construct(UserRepository $repository, Mailer $mailer)
{
$this->repository = $repository;
$this->mailer = $mailer;
}
public function register(array $userData)
{
// Validate email
if (empty($userData['email']) || !filter_var($userData['email'], FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email address");
}
// Create and save user
$user = new User($userData);
$user = $this->repository->save($user);
// Send welcome email
$this->mailer->sendWelcomeEmail($user);
return $user;
}
public function getUserById(int $id)
{
$user = $this->repository->findById($id);
if (!$user) {
throw new \RuntimeException("User not found");
}
return $user;
}
public function deleteUser(int $id)
{
// Get user first to send email
try {
$user = $this->getUserById($id);
} catch (\RuntimeException $e) {
return false;
}
// Delete user
$result = $this->repository->delete($id);
if ($result) {
// Send deletion confirmation
$this->mailer->sendDeletionEmail($user);
}
return $result;
}
}
The Tests
// tests/Unit/UserServiceTest.php
namespace Tests\Unit;
use App\User;
use App\UserRepository;
use App\Mailer;
use App\UserService;
use PHPUnit\Framework\TestCase;
class UserServiceTest extends TestCase
{
private $repository;
private $mailer;
private $service;
protected function setUp(): void
{
$this->repository = $this->createMock(UserRepository::class);
$this->mailer = $this->createMock(Mailer::class);
$this->service = new UserService($this->repository, $this->mailer);
}
public function testRegisterWithValidDataSavesUserAndSendsEmail()
{
// Create a test user
$userData = [
'email' => 'test@example.com',
'name' => 'Test User'
];
$user = new User($userData);
$savedUser = clone $user;
$savedUser->setId(1);
// Set expectations on mocks
$this->repository->expects($this->once())
->method('save')
->with($this->callback(function($arg) use ($userData) {
return $arg instanceof User
&& $arg->getEmail() === $userData['email']
&& $arg->getName() === $userData['name'];
}))
->willReturn($savedUser);
$this->mailer->expects($this->once())
->method('sendWelcomeEmail')
->with($this->equalTo($savedUser));
// Call the service method
$result = $this->service->register($userData);
// Verify result
$this->assertInstanceOf(User::class, $result);
$this->assertEquals(1, $result->getId());
$this->assertEquals($userData['email'], $result->getEmail());
$this->assertEquals($userData['name'], $result->getName());
}
public function testRegisterWithInvalidEmailThrowsException()
{
// Invalid email data
$userData = [
'email' => 'invalid-email',
'name' => 'Test User'
];
// Repository and mailer should not be called
$this->repository->expects($this->never())->method('save');
$this->mailer->expects($this->never())->method('sendWelcomeEmail');
// Expect exception
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("Invalid email address");
// Call the service method
$this->service->register($userData);
}
public function testGetUserByIdReturnsUserWhenFound()
{
// Create a test user
$user = new User([
'id' => 1,
'email' => 'test@example.com',
'name' => 'Test User'
]);
// Repository should return the user
$this->repository->expects($this->once())
->method('findById')
->with($this->equalTo(1))
->willReturn($user);
// Call the service method
$result = $this->service->getUserById(1);
// Verify result
$this->assertSame($user, $result);
}
public function testGetUserByIdThrowsExceptionWhenUserNotFound()
{
// Repository should return null (user not found)
$this->repository->expects($this->once())
->method('findById')
->with($this->equalTo(999))
->willReturn(null);
// Expect exception
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage("User not found");
// Call the service method
$this->service->getUserById(999);
}
public function testDeleteUserDeletesAndNotifiesWhenUserExists()
{
// Create a test user
$user = new User([
'id' => 1,
'email' => 'test@example.com',
'name' => 'Test User'
]);
// Set up repository expectations
$this->repository->expects($this->once())
->method('findById')
->with($this->equalTo(1))
->willReturn($user);
$this->repository->expects($this->once())
->method('delete')
->with($this->equalTo(1))
->willReturn(true);
// Mailer should send deletion email
$this->mailer->expects($this->once())
->method('sendDeletionEmail')
->with($this->equalTo($user));
// Call the service method
$result = $this->service->deleteUser(1);
// Verify result
$this->assertTrue($result);
}
public function testDeleteUserReturnsFalseWhenUserNotFound()
{
// Repository should return null (user not found)
$this->repository->expects($this->once())
->method('findById')
->with($this->equalTo(999))
->willReturn(null);
// Delete and mailer methods should not be called
$this->repository->expects($this->never())->method('delete');
$this->mailer->expects($this->never())->method('sendDeletionEmail');
// Call the service method
$result = $this->service->deleteUser(999);
// Verify result
$this->assertFalse($result);
}
public function testDeleteUserDoesNotNotifyWhenDeleteFails()
{
// Create a test user
$user = new User([
'id' => 1,
'email' => 'test@example.com',
'name' => 'Test User'
]);
// Set up repository expectations
$this->repository->expects($this->once())
->method('findById')
->with($this->equalTo(1))
->willReturn($user);
$this->repository->expects($this->once())
->method('delete')
->with($this->equalTo(1))
->willReturn(false); // Delete fails
// Mailer should not send deletion email
$this->mailer->expects($this->never())
->method('sendDeletionEmail');
// Call the service method
$result = $this->service->deleteUser(1);
// Verify result
$this->assertFalse($result);
}
}
Code Coverage
Code coverage helps you understand how much of your code is being tested.
Generating Coverage Reports
# Install the necessary extension
pecl install pcov
# Run PHPUnit with coverage enabled
./vendor/bin/phpunit --coverage-html coverage
# You can also get a text report
./vendor/bin/phpunit --coverage-text
The HTML report provides detailed information about which lines of code are covered by tests:
Coverage Metrics
- Line Coverage - Percentage of executable lines that are executed
- Function Coverage - Percentage of functions/methods that are called
- Class Coverage - Percentage of classes that are instantiated
- Branch Coverage - Percentage of control structures (if/else, switch) that are executed
Real-world example: Many organizations establish minimum code coverage requirements as part of their quality standards. For instance, Symfony (a popular PHP framework) maintains high code coverage across its components, with many having 90%+ coverage.
Practical Exercise
Exercise: Testing a Shopping Cart
Let's practice by writing tests for a shopping cart class:
// src/Product.php
namespace App;
class Product
{
private $id;
private $name;
private $price;
public function __construct(int $id, string $name, float $price)
{
$this->id = $id;
$this->name = $name;
if ($price <= 0) {
throw new \InvalidArgumentException("Price must be positive");
}
$this->price = $price;
}
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getPrice(): float
{
return $this->price;
}
}
// src/ShoppingCart.php
namespace App;
class ShoppingCart
{
private $items = [];
public function addItem(Product $product, int $quantity = 1): void
{
if ($quantity <= 0) {
throw new \InvalidArgumentException("Quantity must be positive");
}
$productId = $product->getId();
if (isset($this->items[$productId])) {
$this->items[$productId]['quantity'] += $quantity;
} else {
$this->items[$productId] = [
'product' => $product,
'quantity' => $quantity
];
}
}
public function removeItem(int $productId): void
{
if (!isset($this->items[$productId])) {
throw new \InvalidArgumentException("Product not in cart");
}
unset($this->items[$productId]);
}
public function updateQuantity(int $productId, int $quantity): void
{
if ($quantity <= 0) {
throw new \InvalidArgumentException("Quantity must be positive");
}
if (!isset($this->items[$productId])) {
throw new \InvalidArgumentException("Product not in cart");
}
$this->items[$productId]['quantity'] = $quantity;
}
public function getItems(): array
{
return $this->items;
}
public function getItemCount(): int
{
return array_sum(array_column($this->items, 'quantity'));
}
public function getTotal(): float
{
$total = 0.0;
foreach ($this->items as $item) {
$total += $item['product']->getPrice() * $item['quantity'];
}
return $total;
}
public function clear(): void
{
$this->items = [];
}
}
Your task:
- Write a complete test suite for the ShoppingCart class
- Test all methods including error cases
- Use data providers where appropriate
- Verify the cart's behavior with multiple products
Summary
- PHPUnit is the standard testing framework for PHP applications
- Tests are organized in test cases that extend TestCase
- Assertions verify that your code behaves as expected
- Data providers make it easy to test multiple scenarios
- Test doubles (mocks, stubs) help isolate code under test
- Code coverage helps identify untested parts of your codebase
Remember: Writing tests is an investment in code quality. The time spent writing tests pays off through fewer bugs, easier refactoring, and more maintainable code.
Assignment
Create a complete test suite for an order processing system with the following classes:
- Design and implement a simple order processing system with:
- Product class (id, name, price, category)
- Customer class (id, name, email, address)
- Order class (id, customer, products, total, status)
- OrderProcessor class (processes orders, calculates totals, handles status changes)
- PaymentGateway interface and at least one implementation
- ShippingCalculator for determining shipping costs
- Write a comprehensive test suite that includes:
- Unit tests for each class
- Tests for error handling and edge cases
- Mock objects for dependencies like the payment gateway
- Data providers for testing different product and order scenarios
- At least 90% code coverage
Bonus challenge: Implement and test discount rules (e.g., quantity discounts, category discounts) for the order processor.