Testing private methods — should you? alternatives
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:
$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:
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
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));
}
}