method_exists, is_callable i Mockery

Zasadą TDD jest to, że najpierw opisujemy wymagania względem kodu za pomocą testów, a dopiero później piszemy kod, który działał będzie tak, że testy będą poprawnie przechodziły. Niby nic wielkiego, ale czasem pojawia się nieciekawa sytuacja, kiedy w trakcie testów trzeba mocno się nagłówkować. Jak się okazuje, mockowanie za pomocą Mockery również potrafi niemile zaskoczyć.

Ale po kolei. Sytuacja wyglądała następująco: stworzyłem klasę, w której znajdowała się metoda filter(). Metoda pobiera filtr z rejestru filtrów, spawdza jego format i wykonuje odpowiedni kod: jeśli filtr jest funkcją, wykonuje go bezpośrednio; jeśli filtr jest obiektem posiadającym metodę filter(), wykonuje tę metodę; jeśli żaden z powyższych warunków nie jest spełniony, rzucany jest wyjątek.

Kod początkowo wyglądał następująco:

public function filter($name, $value)
{
    $filter = $this->engine->getFilter($name);

    if (is_callable($filter)) {
        return $filter($value);
    } else if (is_object($filter) && method_exists($filter, "filter")) {
        return $filter->filter($value);
    }

    throw new InvalidFilterException("The filter \"{$name}\" is not a valid filter.");
}

Niby proste i zrozumiałe. Do przeprowadzenia testu jednostkowego metody również wiele nie potrzeba. Jest obiekt $this->engine i wołana na nim metoda getFilter(), ale ten obiekt jest wstrzykiwany w konstruktorze klasy, więc jego zamockowanie jest banalnie proste. Obiekt ów dostarcza nam jednak coś, co przypisujemy do zmiennej $filter. To coś może być czymkolwiek, więc wymagania względem metody opisałem za pomocą trzech testów, z których każdy sprawdza jedną z trzech ścieżek wykonania.

Otestowanie filtra, gdzie $filter jest funkcją, było łatwe, gdyż wywołanie is_callable() zwróciło true i wykonała się instrukcja z pierwszego wyrażenia warunkowego.

W przypadku wartości niebędącej ani funkcją, ani obiektem z metodą filter() sprawa również miała się łatwo i rzucenie wyjątku w oczekiwanym momencie nie przysporzyło problemu.

Gorzej natomiast wyszło z trzecią możliwością. Test (w uproszczeniu) wyglądał następująco.

$filter = \Mockery::mock("Alnum");
$filter->shouldReceive("filter")->with("alnum", "Hello, world!")
    ->andReturn("Hello world");

$engine = \Mockery::mock("\\uTemplate\\Engine");
$engine->shouldReceive("getFilter")->with("alnum")->andReturn($filter);

$view = new View($engine, "iDontExist.phtml");
$this->assertEquals("Hello world", $view->filter("alnum", "Hello, world!"));

Niby proste: mock filtra, oczekiwanie wywołania metody filtra, mock silnika, oczekiwanie wywołania metody na silniku i zwrócenie filtra. To musi działać.

A jednak nie działa. Efektem było rzucenie wyjątku, zupełnie jakby przekazany filtr nie spełniał założeń kwalifikujących go do bycia poprawnym filtrem. A przyczyną okazało się wywołanie funkcji method_exists().

Przyczyna jest oczywista (choć nie pomyślałem o tym początkowo): method_exists() sprawdza istnienie danej metody w obiekcie, a nasz filtr jest przecież tylko wyplutym przez Mockery obiektem proxy, który nie posiada wymaganej metody, a jedynie czeka, aż się ją zawoła. A zatem method_exists() zwraca false, więc druga ścieżka instrukcji warunkowej nie może zostać wywołana.

Problem zauważyłem nie tylko ja.

Rozwiązanie jest natomiast nie dość, że proste, to jeszcze naprawia pewien potencjalny błąd, o którym nie pomyślałem początkowo, a który zauważyłem dopiero po lekturze tego posta (w razie, gdyby post zniknął z sieci: traktuje on o różnicy między method_exists() a is_callable()). Otóż method_exists() nieprawidłowo zwróci true w momencie, gdy przekazany doń filtr będzie miał metodę prywatną lub chronioną filter(). Metoda istnieje, tego nie zanegujemy — ale wywołać się jej nie da. Druga sytuacja to wywołanie filter() za pomocą magicznej metody __call(). W takiej sytuacji method_exists() niepoprawnie zwróci false — bo metoda w zasadzie nie istnieje, istnieje tylko sposób, aby takie wywołanie nieistniejącej metody obiekt nam przetłumaczył na coś sensownego.

Sprawę załatwia nam zastąpienie wywołania method_exists() funkcją is_callable(). Działa ona tak samo, jak method_exists(), z tą różnicą, że nie sprawdza istnienia metody, a to, czy da się ją wywołać. W obu problematycznych przypadkach zachowa się zgodnie z oczekiwaniami i zadziała również w przypadku proxy zwróconego przez Mockery. Po przebojach z niedziałającym testem, kod produkcyjny został zatem zmieniony na coś takiego:

public function filter($name, $value)
{
    $filter = $this->engine->getFilter($name);

    if (is_callable($filter)) {
        return $filter($value);
    } else if (is_object($filter) && is_callable(array($filter, "filter"))) {
        return $filter->filter($value);
    }

    throw new InvalidFilterException("The filter \"{$name}\" is not a valid filter.");
}

Napisz komentarz


Szukaj wpisów


Chmura tagów