PHPUnit Testing Framework

Understanding and implementing effective tests for PHP applications

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.

mindmap root((PHPUnit)) Test Organization Test Cases Test Suites Test Fixtures Test Types Unit Tests Integration Tests Functional Tests Key Features Assertions Mocking Data Providers Code Coverage Benefits Regression Prevention Confidence in Changes Documentation Design Improvement

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)
graph TD A[Write Test] --> B[Run Test] B --> C{Test Passes?} C -->|No| D[Fix Code or Test] D --> B C -->|Yes| E[Move to Next Feature] E --> A

PHPUnit Test Structure

Test Case Basics

A test case in PHPUnit is a class that extends PHPUnit\Framework\TestCase. Each test method should:

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:

// 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]
        ];
    }
}
flowchart TD A[Data Provider Method] -->|Returns test data| B[Test Method] B -->|Runs once for each data set| C{Assertion} C -->|Pass| D[Next Data Set] C -->|Fail| E[Test Failure] D --> C

Benefits of data providers:

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

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:

pie title Code Coverage Example "Covered Lines" : 85 "Uncovered Lines" : 15

Coverage Metrics

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:

  1. Write a complete test suite for the ShoppingCart class
  2. Test all methods including error cases
  3. Use data providers where appropriate
  4. Verify the cart's behavior with multiple products

Summary

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:

  1. 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
  2. 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.