Skip to content
intermediate Phase 12 · Testing & Quality

PHPUnit Unit Testing

PHPUnit test structure, assertions, data providers, setUp/tearDown, and writing tests for PHP classes

1h
0 problems
Topic Progress 0%

PHPUnit Test Structure

Basic Test Class

namespace Vendor\Catalog\Test\Unit\Model;

use PHPUnit\Framework\TestCase;
use Vendor\Catalog\Model\PriceCalculator;

class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new PriceCalculator();
    }

    public function testCalculateReturnsCorrectPrice(): void
    {
        $result = $this->calculator->calculate(100, 0.20);
        $this->assertEquals(120.0, $result);
    }

    public function testCalculateWithZeroTax(): void
    {
        $result = $this->calculator->calculate(100, 0.0);
        $this->assertEquals(100.0, $result);
    }
}

Test Naming Convention

// Method name: test + Description of expected behavior
public function testCalculateWithNegativePriceThrowsException(): void { /* ... */ }
public function testCalculateAppliesTaxCorrectly(): void { /* ... */ }
public function testGetPriceReturnsBasePriceWhenNoDiscount(): void { /* ... */ }

// Or use @test annotation
/**
 * @test
 */
public function discountIsAppliedBeforeTax(): void { /* ... */ }

Exception Testing

public function testNegativePriceThrowsInvalidArgumentException(): void
{
    $this->expectException(\InvalidArgumentException::class);
    $this->expectExceptionMessage('Price cannot be negative');

    $this->calculator->calculate(-10, 0.20);
}

// Or test that no exception is thrown
public function testValidPriceDoesNotThrow(): void
{
    $this->expectNotToPerformAssertions(); // Just verify no exception
    $this->calculator->calculate(100, 0.20);
}

Mock Objects in Tests

public function testProcessUsesProductRepository(): void
{
    $productRepo = $this->createMock(\Magento\Catalog\Api\ProductRepositoryInterface::class);
    $productRepo->expects($this->once())
        ->method('get')
        ->with('SKU-123')
        ->willReturn($this->createMock(\Magento\Catalog\Api\Data\ProductInterface::class));

    $processor = new ProductProcessor($productRepo);
    $processor->process('SKU-123');
}

Data Providers for Parameterized Tests

What are Data Providers?

Data providers allow running the same test with different input data:

namespace Vendor\Catalog\Test\Unit\Model;

use PHPUnit\Framework\TestCase;

class PriceCalculatorTest extends TestCase
{
    /**
     * @dataProvider priceCalculationProvider
     */
    public function testCalculatePrice(float $price, float $tax, float $expected): void
    {
        $calculator = new PriceCalculator();
        $result = $calculator->calculate($price, $tax);
        $this->assertEquals($expected, $result, '', 0.01); // Delta for float comparison
    }

    public static function priceCalculationProvider(): array
    {
        return [
            'basic tax' => [100, 0.20, 120.0],
            'zero tax' => [100, 0.0, 100.0],
            'high tax' => [100, 0.50, 150.0],
            'small price' => [10, 0.10, 11.0],
            'decimal price' => [29.99, 0.08, 32.39],
        ];
    }
}

External Data Providers

// In a separate file
class TestDataProvider
{
    public static function productData(): array
    {
        return [
            ['sku' => 'SKU-1', 'name' => 'Product 1', 'price' => 29.99],
            ['sku' => 'SKU-2', 'name' => 'Product 2', 'price' => 49.99],
        ];
    }
}

// In test class
/**
 * @dataProvider \Vendor\Catalog\Test\DataProvider::productData
 */
public function testProductCreation(string $sku, string $name, float $price): void
{
    $product = new Product();
    $product->setSku($sku)->setName($name)->setPrice($price);

    $this->assertEquals($sku, $product->getSku());
    $this->assertEquals($name, $product->getName());
    $this->assertEquals($price, $product->getPrice());
}

DataSet Approach for Magento

// Magento uses data sets in integration tests
class ProductCreationTest extends \Magento\TestFramework\Helper\Bootstrap\AbstractTestCase
{
    /**
     * @magentoDataFixture Magento/Catalog/_files/product.php
     */
    public function testProductExists(): void
    {
        $product = $this->productRepository->get('simple');
        $this->assertNotNull($product->getId());
    }
}

setUp, tearDown, and Test Organization

setUp and tearDown

class CartTest extends TestCase
{
    private Cart $cart;
    private string $tempFile;

    protected function setUp(): void
    {
        $this->cart = new Cart();
        $this->tempFile = sys_get_temp_dir() . '/cart_test_' . uniqid();
    }

    protected function tearDown(): void
    {
        // Clean up after each test
        if (file_exists($this->tempFile)) {
            unlink($this->tempFile);
        }
    }

    public function testAddItem(): void
    {
        $this->cart->add('SKU-1', 2);
        $this->assertCount(1, $this->cart->getItems());
        $this->assertEquals(2, $this->cart->getItemQty('SKU-1'));
    }

    public function testRemoveItem(): void
    {
        $this->cart->add('SKU-1', 2);
        $this->cart->remove('SKU-1');
        $this->assertCount(0, $this->cart->getItems());
    }
}

Test Organization Patterns

// Group related tests with @group annotation
/**
 * @group catalog
 * @group model
 */
class ProductTest extends TestCase { /* ... */ }

// Run specific group: phpunit --group catalog

// Skip tests conditionally
/**
 * @requires PHP 8.1
 */
public function testNewFeature(): void { /* ... */ }

/**
 * @requires extension sodium
 */
public function testEncryption(): void { /* ... */ }

// Mark tests as incomplete
public function testFeatureNotYetImplemented(): void
{
    $this->markTestIncomplete('This feature is not yet implemented');
}

// Mark test as risky (no assertions)
/**
 * @doesNotPerformAssertions
 */
public function testSomethingDoesNotThrow(): void
{
    $this->doSomething(); // Just verify no exception
}

Best Practices

  1. One assertion per test (when practical)
  2. Test behavior, not implementation — don't test private methods
  3. Tests should be independent — no order dependency
  4. Tests should be fast — unit tests in milliseconds
  5. Descriptive test names — testCalculateWithNegativePriceThrowsException

Quiz

1. What is the purpose of setUp() in PHPUnit?

Question 1 options

2. Data providers in PHPUnit are used for:

Question 2 options

3. A test should ideally:

Question 3 options

Flashcards

Question

What does setUp() do?

Answer

Runs before each test method to initialize fixtures and test objects

Question

What is a data provider?

Answer

A method that returns arrays of data to parameterize test methods

Question

How to test exceptions in PHPUnit?

Answer

expectException() + expectExceptionMessage() before the code that throws

Question

Test naming convention?

Answer

test + Description of expected behavior (e.g., testCalculateWithNegativePrice)

Revision Notes

Key Takeaways

  • 1. Extend TestCase, use setUp() for initialization, tearDown() for cleanup
  • 2. Name tests descriptively: test + expected behavior
  • 3. Data providers enable parameterized testing with different inputs
  • 4. Mock objects isolate the class under test from dependencies
  • 5. Tests should be fast, independent, and focused on one behavior

Interview Tips

  • Explain the difference between unit, integration, and functional tests
  • Give an example of a well-structured unit test with mocks
  • Discuss test naming conventions and why they matter

Cheat Sheet

PHPUnit Structure:
  class XyzTest extends TestCase
    setUp()      → before each test
    tearDown()   → after each test
    testSomething() → test method

Assertions:
  assertEquals(expected, actual)
  assertNotNull($var)
  assertTrue($condition)
  expectException(Exception::class)

dataProvider: @dataProvider methodName
  public static function data(): array { return [...]; }

Run: vendor/bin/phpunit --filter testMethod