Najlepsze praktyki testowania chronionych metod za pomocą PHPUnit

287

Dyskusja na temat Czy testujesz metodę prywatną ma charakter informacyjny.

Zdecydowałem, że na niektórych zajęciach chcę mieć metody chronione, ale je przetestuj. Niektóre z tych metod są statyczne i krótkie. Ponieważ większość publicznych metod korzysta z nich, prawdopodobnie będę mógł później bezpiecznie usunąć testy. Ale aby zacząć od podejścia TDD i uniknąć debugowania, naprawdę chcę je przetestować.

Myślałem o następujących kwestiach:

  • Metoda Obiekt wskazana w odpowiedzi wydaje się być nadmierną umiejętnością w tym zakresie.
  • Zacznij od metod publicznych, a gdy kod zostanie objęty testami wyższego poziomu, włącz ich ochronę i usuń testy.
  • Dziedzicz klasę z testowalnym interfejsem upubliczniającym chronione metody

Jaka jest najlepsza praktyka? Czy jest coś jeszcze?

Wygląda na to, że JUnit automatycznie zmienia chronione metody na publiczne, ale nie przyjrzałem się temu głębiej. PHP nie pozwala na to poprzez refleksję .

GrGr
źródło
Dwa pytania: 1. dlaczego powinieneś zawracać sobie głowę testowaniem funkcjonalności, których Twoja klasa nie udostępnia? 2. Jeśli powinieneś to przetestować, dlaczego jest prywatny?
nad2000
2
Może chce sprawdzić, czy własność prywatna jest ustawiana poprawnie, a jedynym sposobem testowania przy użyciu tylko funkcji ustawiającej jest upublicznienie własności prywatnej i sprawdzenie danych
AntonioCS
4
I to jest w stylu dyskusji, a zatem nie jest konstruktywne. Znowu :)
mlvljr
72
Możesz nazwać to wbrew regułom witryny, ale samo nazwanie go „niekonstruktywnym” jest… obraźliwe.
Andy V,
1
@Visser, sam siebie obraża;)
Pacerier

Odpowiedzi:

417

Jeśli używasz PHP5 (> = 5.3.2) z PHPUnit, możesz przetestować swoje prywatne i chronione metody, używając refleksji, aby ustawić je jako publiczne przed uruchomieniem testów:

protected static function getMethod($name) {
  $class = new ReflectionClass('MyClass');
  $method = $class->getMethod($name);
  $method->setAccessible(true);
  return $method;
}

public function testFoo() {
  $foo = self::getMethod('foo');
  $obj = new MyClass();
  $foo->invokeArgs($obj, array(...));
  ...
}
uckelman
źródło
27
Cytując link do blogu sebastianów: „Więc: To, że możliwe jest testowanie chronionych i prywatnych atrybutów i metod, nie oznacza, że ​​jest to„ dobra rzecz ”. -
Żeby o
10
Zakwestionowałbym to. Jeśli nie potrzebujesz do pracy chronionych lub prywatnych metod, nie testuj ich.
uckelman
10
Aby to wyjaśnić, nie musisz używać PHPUnit, aby to działało. Będzie również działał z SimpleTest lub czymkolwiek. W odpowiedzi nie ma nic zależnego od PHPUnit.
Ian Dunn
84
Nie należy testować członków chronionych / prywatnych bezpośrednio. Należą one do wewnętrznej implementacji klasy i nie powinny być łączone z testem. To sprawia, że ​​refaktoryzacja jest niemożliwa i ostatecznie nie testujesz tego, co należy przetestować. Musisz je przetestować pośrednio przy użyciu metod publicznych. Jeśli uznasz to za trudne, prawie na pewno masz problem ze składem klasy i musisz podzielić ją na mniejsze klasy. Pamiętaj, że Twoja klasa powinna być czarną skrzynką do testu - wrzucasz coś i dostajesz coś z powrotem, i to wszystko!
gphilip
24
@gphilip Dla mnie protectedmetoda jest również częścią publicznego interfejsu API, ponieważ każda klasa strony trzeciej może ją rozszerzyć i używać bez żadnej magii. Myślę więc, że tylko privatemetody należą do kategorii metod, które nie mogą być bezpośrednio testowane. protectedi publicpowinny być bezpośrednio testowane.
Filip Halaxa
48

Wygląda na to, że już jesteś świadomy, ale i tak to powtórzę; To zły znak, jeśli musisz przetestować chronione metody. Celem testu jednostkowego jest przetestowanie interfejsu klasy, a chronione metody są szczegółami implementacji. To powiedziawszy, są przypadki, w których ma to sens. Jeśli używasz dziedziczenia, możesz zobaczyć nadklasę jako interfejs dla podklasy. Więc tutaj musiałbyś przetestować metodę chronioną (ale nigdy prywatną ). Rozwiązaniem tego jest utworzenie podklasy do celów testowych i wykorzystanie jej do ujawnienia metod. Na przykład.:

class Foo {
  protected function stuff() {
    // secret stuff, you want to test
  }
}

class SubFoo extends Foo {
  public function exposedStuff() {
    return $this->stuff();
  }
}

Pamiętaj, że zawsze możesz zastąpić dziedziczenie kompozycją. Podczas testowania kodu zwykle łatwiej jest radzić sobie z kodem korzystającym z tego wzorca, więc warto rozważyć tę opcję.

troelskn
źródło
2
Możesz po prostu bezpośrednio zaimplementować stuff () jako public i zwrócić parent :: stuff (). Zobacz moją odpowiedź. Wygląda na to, że czytam dziś zbyt szybko.
Michael Johnson
Masz rację; Ważne jest, aby zmienić metodę chronioną na publiczną.
troelskn
Tak więc kod sugeruje moją trzecią opcję i „Pamiętaj, że zawsze możesz zastąpić dziedziczenie kompozycją”. idzie w kierunku mojej pierwszej opcji lub refactoring.com/catalog/replaceInheritanceWithDelegation.html
GrGr
34
Nie zgadzam się, że to zły znak. Zróbmy różnicę między TDD a testowaniem jednostkowym. Testy jednostkowe powinny testować prywatne metody imo, ponieważ są to jednostki i przyniosłyby korzyści w taki sam sposób, jak testy jednostkowe Metody publiczne korzystają z testów jednostkowych.
koen,
36
Metody chronione częścią interfejsu klasy, nie są to po prostu szczegóły implementacji. Cały sens chronionych członków jest taki, że podklasenci (użytkownicy sami) mogą korzystać z tych chronionych metod w ekstazach klas. Te oczywiście wymagają przetestowania.
BT
40

teastburn ma właściwe podejście. Jeszcze prostsze jest bezpośrednie wywołanie metody i zwrócenie odpowiedzi:

class PHPUnitUtil
{
  public static function callMethod($obj, $name, array $args) {
        $class = new \ReflectionClass($obj);
        $method = $class->getMethod($name);
        $method->setAccessible(true);
        return $method->invokeArgs($obj, $args);
    }
}

Możesz to nazwać w testach:

$returnVal = PHPUnitUtil::callMethod(
                $this->object,
                '_nameOfProtectedMethod', 
                array($arg1, $arg2)
             );
robert.egginton
źródło
1
To świetny przykład, dzięki. Ta metoda powinna być publiczna zamiast chroniona, prawda?
valk
Słuszna uwaga. Tak naprawdę używam tej metody w mojej klasie bazowej, z której rozszerzam klasy testowe, w którym to przypadku ma to sens. Nazwa klasy byłaby tu jednak błędna.
robert.egginton
Zrobiłem dokładnie ten sam kawałek kodu na podstawie teastburn xD
Nebulosar
23

Chciałbym zaproponować niewielką odmianę metody getMethod () zdefiniowanej w odpowiedzi uckelman .

Ta wersja zmienia getMethod () poprzez usunięcie zakodowanych wartości i uproszczenie użytkowania. Polecam dodanie go do swojej klasy PHPUnitUtil, jak w poniższym przykładzie, lub do klasy rozszerzającej PHPUnit_Framework_TestCase (lub, jak sądzę, globalnie do pliku PHPUnitUtil).

Ponieważ i tak tworzona jest instancja MyClass, a ReflectionClass może pobrać ciąg znaków lub obiekt ...

class PHPUnitUtil {
    /**
     * Get a private or protected method for testing/documentation purposes.
     * How to use for MyClass->foo():
     *      $cls = new MyClass();
     *      $foo = PHPUnitUtil::getPrivateMethod($cls, 'foo');
     *      $foo->invoke($cls, $...);
     * @param object $obj The instantiated instance of your class
     * @param string $name The name of your private/protected method
     * @return ReflectionMethod The method you asked for
     */
    public static function getPrivateMethod($obj, $name) {
      $class = new ReflectionClass($obj);
      $method = $class->getMethod($name);
      $method->setAccessible(true);
      return $method;
    }
    // ... some other functions
}

Stworzyłem również funkcję aliasu getProtectedMethod (), aby wyraźnie określić, czego się oczekuje, ale to zależy od ciebie.

Twoje zdrowie!

teastburn
źródło
+1 za używanie interfejsu API klasy refleksji.
Bill Ortell,
10

Myślę, że troelskn jest blisko. Zrobiłbym to zamiast tego:

class ClassToTest
{
   protected function testThisMethod()
   {
     // Implement stuff here
   }
}

Następnie zaimplementuj coś takiego:

class TestClassToTest extends ClassToTest
{
  public function testThisMethod()
  {
    return parent::testThisMethod();
  }
}

Następnie uruchamiasz testy przeciwko TestClassToTest.

Powinna istnieć możliwość automatycznego generowania takich klas rozszerzeń przez analizowanie kodu. Nie zdziwiłbym się, gdyby PHPUnit już oferował taki mechanizm (chociaż nie sprawdziłem).

Michael Johnson
źródło
Heh ... wygląda na to, że mówię, użyj trzeciej opcji :)
Michael Johnson
2
Tak, to dokładnie moja trzecia opcja. Jestem całkiem pewien, że PHPUnit nie oferuje takiego mechanizmu.
GrGr
To nie zadziała, nie można zastąpić funkcji chronionej funkcją publiczną o tej samej nazwie.
Koen.
Mogę się mylić, ale nie sądzę, aby to podejście mogło zadziałać. PHPUnit (o ile kiedykolwiek go użyłem) wymaga, aby klasa testowa rozszerzyła inną klasę, która zapewnia rzeczywistą funkcjonalność testowania. Chyba, że ​​istnieje sposób, aby obejść to, że nie jestem pewien, czy widzę, jak można użyć tej odpowiedzi. phpunit.de/manual/current/en/…
Cypher
1
Do twojej wiadomości onl działa dla metod chronionych , a nie dla prywatnych
Sliq
5

Wrzucę tutaj kapelusz na ring:

Użyłem hacka __call z różnym powodzeniem. Alternatywą, którą wymyśliłem, było użycie wzoru Visitor:

1: wygeneruj stdClass lub klasę niestandardową (aby wymusić typ)

2: zagruntuj to wymaganą metodą i argumentami

3: upewnij się, że twój SUT ma metodę acceptVisitor, która wykona metodę z argumentami określonymi w klasie odwiedzającej

4: wstrzyknij do klasy, którą chcesz przetestować

5: SUT wstrzykuje odwiedzającemu wynik operacji

6: zastosuj warunki testu do atrybutu wyniku Odwiedzającego

sunwukung
źródło
1
+1 za ciekawe rozwiązanie
jsh
5

Rzeczywiście możesz użyć __call () w sposób ogólny, aby uzyskać dostęp do chronionych metod. Aby móc przetestować tę klasę

class Example {
    protected function getMessage() {
        return 'hello';
    }
}

tworzysz podklasę w ExampleTest.php:

class ExampleExposed extends Example {
    public function __call($method, array $args = array()) {
        if (!method_exists($this, $method))
            throw new BadMethodCallException("method '$method' does not exist");
        return call_user_func_array(array($this, $method), $args);
    }
}

Zauważ, że metoda __call () nie odwołuje się w żaden sposób do klasy, więc możesz skopiować powyższe dla każdej klasy za pomocą chronionych metod, które chcesz przetestować, i po prostu zmienić deklarację klasy. Możesz być w stanie umieścić tę funkcję we wspólnej klasie bazowej, ale ja jej nie wypróbowałem.

Teraz sam przypadek testowy różni się tylko tym, w którym konstruuje się obiekt do testowania, zamieniając w PrzykładExposed na Przykład.

class ExampleTest extends PHPUnit_Framework_TestCase {
    function testGetMessage() {
        $fixture = new ExampleExposed();
        self::assertEquals('hello', $fixture->getMessage());
    }
}

Wierzę, że PHP 5.3 pozwala używać refleksji do bezpośredniej zmiany dostępności metod, ale zakładam, że będziesz musiał to zrobić dla każdej metody osobno.

David Harkness
źródło
1
Implementacja __call () działa świetnie! Próbowałem głosować, ale anulowałem głos, dopóki nie przetestowałem tej metody i teraz nie mogę głosować z powodu limitu czasu w SO.
Adam Franco
call_user_method_array()Funkcja jest przestarzała od PHP 4.1.0 ... używać call_user_func_array(array($this, $method), $args)zamiast. Zauważ, że jeśli używasz PHP 5.3.2+, możesz użyć Reflection, aby uzyskać dostęp do chronionych / prywatnych metod i atrybutów
nuqqsa
@nuqqsa - Dzięki, zaktualizowałem swoją odpowiedź. Od tego czasu napisałem ogólny Accessiblepakiet, który używa refleksji, aby umożliwić testom dostęp do prywatnych / chronionych właściwości oraz metod klas i obiektów.
David Harkness,
Ten kod nie działa dla mnie w PHP 5.2.7 - metoda __call nie jest wywoływana dla metod zdefiniowanych przez klasę podstawową. Nie mogę znaleźć tego udokumentowanego, ale zgaduję, że to zachowanie zostało zmienione w PHP 5.3 (gdzie potwierdziłem, że działa).
Russell Davis,
@ Russell - __call()wywoływany jest tylko wtedy, gdy dzwoniący nie ma dostępu do metody. Ponieważ klasa i jej podklasy mają dostęp do metod chronionych, wywołania do nich nie będą realizowane __call(). Czy możesz opublikować kod, który nie działa w 5.2.7 w nowym pytaniu? Użyłem powyższego w 5.2 i przeszedłem do używania refleksji z 5.3.2.
David Harkness,
2

Proponuję następujące obejście obejścia / pomysłu Henrika Paula :)

Znasz nazwy prywatnych metod swojej klasy. Na przykład są one jak _add (), _edit (), _delete () itp.

Dlatego jeśli chcesz go przetestować pod kątem testowania jednostkowego, po prostu wywołaj metody prywatne przez prefiks i / lub sufiks jakiegoś wspólnego słowa (na przykład _addPhpunit), aby po wywołaniu metody __call () (ponieważ metoda _addPhpunit () nie istnieje) klasy właściciela, wystarczy wstawić potrzebny kod w metodzie __call (), aby usunąć prefiks / sufiks słowo / s (Phpunit), a następnie wywołać stamtąd wywnioskowaną metodę prywatną. To kolejne dobre użycie magicznych metod.

Wypróbuj to.

Anirudh Zala
źródło