0

Testing private methods — should you? alternatives

Advanced5 min read·php-14-012
interviewsolid

Concept

Testing private and protected methods is a contested topic. The conventional wisdom is: don't test private methods directly — they're implementation details. Test the public behavior that uses them. If a private method is complex enough that you feel compelled to test it directly, that's a signal it should be extracted into its own class with a public interface.

When you might need to test private/protected anyway:

  • Legacy code without the luxury of refactoring.
  • Abstract base classes with protected methods intended for subclasses to override (test via a concrete subclass or anonymous class extending the abstract class).
  • Specific edge cases that are hard to trigger through the public API.

Reflection API approach: ReflectionMethod can forcibly expose any method regardless of visibility:

php
$method = new \ReflectionMethod($object, 'privateMethod');
$method->setAccessible(true);
$result = $method->invoke($object, $arg1, $arg2);

PHP 8.1 made setAccessible() a no-op (reflection already bypasses visibility). In PHP 8.2+, setAccessible() is deprecated — remove the call.

Testing protected via subclass: For abstract classes or classes with protected methods, create a TestableSubclass that makes the protected method public:

php
class TestableOrderProcessor extends OrderProcessor {
    public function publicCalculate(array $items): int {
        return $this->calculateTotal($items); // calls protected method
    }
}

The right answer: Refactor. If private calculateDiscount() is complex, extract it to DiscountCalculator::calculate() — now it's publicly testable and the original class depends on it.

Code Example

php
<?php
declare(strict_types=1);

// Class under test
class PriceCalculator
{
    public function calculateFinal(float $price, int $qty): float
    {
        $discounted = $this->applyDiscount($price, $qty);
        return round($discounted * $qty, 2);
    }

    private function applyDiscount(float $price, int $qty): float
    {
        if ($qty >= 10) return $price * 0.9;
        if ($qty >= 5)  return $price * 0.95;
        return $price;
    }
}

// WRONG approach: testing via Reflection — avoid this
class PriceCalculatorTest extends \PHPUnit\Framework\TestCase
{
    public function test_apply_discount_via_reflection(): void
    {
        $calc = new PriceCalculator();
        $method = new \ReflectionMethod($calc, 'applyDiscount');
        // No setAccessible needed in PHP 8.1+ (reflection already bypasses visibility)
        $result = $method->invoke($calc, 100.0, 10);
        $this->assertSame(90.0, $result);
    }
}

// BETTER approach: test via public API
class PriceCalculatorTest2 extends \PHPUnit\Framework\TestCase
{
    public function test_bulk_discount_applied_at_10_items(): void
    {
        $calc = new PriceCalculator();
        // 100 * 10 items * 0.90 discount = 900.00
        $this->assertSame(900.0, $calc->calculateFinal(100.0, 10));
    }

    public function test_no_discount_below_5_items(): void
    {
        $calc = new PriceCalculator();
        $this->assertSame(400.0, $calc->calculateFinal(100.0, 4));
    }
}

// BEST approach: extract private method to its own class
class DiscountCalculator
{
    public function apply(float $price, int $qty): float
    {
        if ($qty >= 10) return $price * 0.9;
        if ($qty >= 5)  return $price * 0.95;
        return $price;
    }
}

// Now DiscountCalculator is independently tested — no reflection needed
class DiscountCalculatorTest extends \PHPUnit\Framework\TestCase
{
    private DiscountCalculator $calc;
    protected function setUp(): void { $this->calc = new DiscountCalculator(); }

    public function test_ten_percent_discount_at_10_items(): void
    {
        $this->assertSame(90.0, $this->calc->apply(100.0, 10));
    }
}