(Semi)Tutorial de unit testing în WP

A trebuit să dezvolt un plugin pentru un client zilele astea și, dacă tot am avut mai mult timp decât era nevoie, am zis că ar fi bine să încerc o altă abordare a problemei: TDD :slight_smile:

O să încep cu… concluziile și o să povestesc cum a fost:

  • A fost lent? Curios, nu. Adică da, a fost mai lent decât dacă aș fi scris codul fără teste, dar am „pierdut” mult timp la mock-uri, nu la testele în sine. Mi-a luat trei zile în loc de două.
  • În total am scris 23 teste și am code coverage este 79%;
  • Voi continua? Cu siguranță! Voi încerca să negociez termene mai generoase, astfel încât sa scriu și teste, chiar zilele trecute am avut un bug dubios la un site și visam la „ce bine ar fi fost cu teste…”. Da, voi explica și clienților de ce am nevoie de mai mult timp;
  • Mi-a plăcut? Oh, da. A fost unul din acele lucruri care te scoate din rutina zilnică, dar care nu te scoate brusc (și prea mult) din zona de confort. În mod surprinzător, schimbarea contextului dintre cod <-> test nu a fost deranjantă, nu mi-am pierdut focusul, nu nimic. Comparativ, testarea manuală, în browser, ar fi durat mai mult (refresh 2-3 secunde, operațiunile din pagină încă 2-3 secunde, save încă 2-3 secunde) și m-ar fi scos mult mai ușor din flow;
  • plugin-ul nu îl pot face public. Cel puțin nu încă.

Cea mai mare problemă pentru mine până acum a fost: cum scriu codul astfel încât să nu depind de metodele WP? Păi… folosind magic methods!

namespace ntz;

interface CmsInterface {}

class WP implements CMSInterface
{
  private static $instance;

  public function __call($callback, $args)
  {
    return call_user_func_array($callback, $args);
  }

  public static function get()
  {
    if (!self::$instance) {
      self::$instance = new SELF;
    }

    return self::$instance;
  }
}

De aici ar trebui să fie simplu: dacă am nevoie de vreo metodă WP într-o clasă, trimit o instanță a \ntz\WP ca parametru în constructor (sau folosesc un container DI). Dacă am nevoie în temă, o apelez static: \ntz\WP::get_permalink().

Funcția __()este un pic mai specială. Funcțiile care încep cu două underscore sunt rezervate claselor, astfel încât orice string folosit nu se va comporta foarte bine în teste. Pentru asta sunt două soluții:

  1. folosești alias-ul translate(), ceea ce va duce la dificultăți în traducerea string-urilor;
  • faci o clasă a cărei responsabilitate este strict aceea de a returna string-uri. Metodele din această clasă n-ar trebui să conțină NIMIC altceva în afară de return __('string');.
  • alternativ, faci o funcție ce conține un array cu toate string-urile din aplicație, astfel încât să o poți invocva la modul getString('string1');.

Nici clasa și nici funcția nu ar avea nevoie de testare (din moment ce nu au nici un fel de logică).

Clasa de mai sus s-ar putea extinde mai departe, ca wrapper pentru clase sau pentru globale din WP. new WP_Query ar fi pusă într-o metodă getWpQuery. Globalele WP (care sunt, slavă monstrului de spaghete, o grămadă!) ar fi puse ori într-o singură metodă (să zicem getGlobal($name)) ori ar fi pereche, globala și metoda (e.g. getWpDb()). Posibilitățile sunt nelimitate!

Următorul pas spre testabilitate este separarea variabilelor globale $_GET, $_POST etc. Pentru asta am ales să folosesc symfony/http-foundation, care îmi permite ca în loc de $_GET['foo'] să scriu $request->query->get('foo'). Dincolo de separarea responsabilităților mai este și avantajul că nu mai trebuie să fac de fiecare dată isset($_GET['foo']) ? $_GET['foo'] : 'bar'.

Pentru a face clasa unui plugin testabilă, este nevoie să mutăm toate hook-urile într-o metodă separată, ce NU este apelată la instanțierea clasei:

namespace ntz\MyPlugin;

class Plugin
{
  public function __construct($WP)
  {
    $this->WP = $WP;
  }

  public function init()
  {
    $this->WP->add_filter('hook1', [$this, 'callback1']);
    $this->WP->add_action('hook2', [$this, 'callback2']);
    $this->WP->add_action('hook3', [$this, 'callback3']);
  }
}

Clasa este ținută într-un fișier separat de fișierul în care este definit plugin-ul. Eu folosesc o structură de genul:

plugin-name.php <- aici este definit plugin-ul
index.php <- un fișier gol ce previne listarea directorului
inc/MyPlugin/Plugin.php <- clasa de mai sus

În plugin-name.php, am ceva de genul:

require_once 'vendor/autoload.php';
$myPlugin = new \ntz\MyPlugin\Plugin(new ntz\WP);
$myPlugin->init();

Primul test

Primul lucru pe care trebuie să-l testăm este că punem hook-urile unde este nevoie de ele.


use ntz\MyPlugin\Plugin as Plugin;

class MyPluginTest extends PHPUnit_Framework_TestCase
{
  private function getWpMock()
  {
    return $this->getMockBuilder('ntz\CmsInterface');
  }

  public function test_setup_hooks()
  {
    $mock = $this->getWpMock()
      ->setMethods(['add_filter', 'add_action'])
      ->getMock();

    $mock
      ->expects($this->exactly(1))
      ->method('add_filter')
      ->with('hook1')
      ->willReturn(true);

    $mock
      ->expects($this->exactly(2))
      ->method('add_action')
      ->withConsecutive(
        ['hook2'],
        ['hook3']
      )->willReturn(true);

    $plugin = new Plugin($mock, '');
    $plugin->init();
  }
}

Ce se întâmplă?

  1. Facem mock la wrapperul WP. De fapt, facem mock la interfață, nu la clasă;
  2. Setăm cele două metode de care avem nevoie;
  3. Verificăm dacă sunt apelate, cu parametri corespunzători.

De ce mock-ul pentru add_action este testat cu withConsecutive iar cel pentru add_filter este testat cu with? Pentru că primul este apelat de mai multe ori.

Pasul ->expects($this->exactly(2)) poate fi ceva mai vag, astfel încât, în loc să specifici de câte ori aștepți ca respectiva metodă să fie invocată, specifici că vrei să fie invocată cel puțin o datăe.g.: ->expects($this->atLeastOnce()). Eu prefer varianta exactly pentru că altfel este posibil să primești false positives.

Ce putem testa?

Păi… cam orice. La ultimul plugin, am testat așa:

  • wp_register_script & co sunt apelate doar unde este nevoie;
  • wp_localize_script conține ce trebuie;
  • wp_ajax_* este setat;
  • că răspunsul de la metoda setată în hook-ul wp_ajax_* întoarce ce trebuie;

etc etc.

În principiu, testez că totul se întâmplă așa cum vreau eu și, cel mai important, testez fără a avea nevoie de WP, fără a avea nevoie de DB sau de… orice altceva. Rulez testele cum trebuie, ca la carte, în perfectă izolare :slight_smile:

9 Likes