Тестирование хуков и их колбэков

11 окт. 2014 г., 12:59:02
Просмотры: 13.8K
Голосов: 45

Я разрабатываю плагин, используя TDD, и одна вещь, которую мне никак не удаётся протестировать - это хуки.

То есть, ладно, я могу протестировать колбэк хука, но как мне проверить, что хук действительно срабатывает (и кастомные хуки, и стандартные хуки WordPress)? Предполагаю, что здесь поможет мокирование, но я просто не могу понять, что упускаю.

Я установил тестовый набор через WP-CLI. Согласно этому ответу, хук init должен срабатывать, но... он не срабатывает; при этом код работает внутри WordPress.

Насколько я понимаю, бутстрап загружается последним, поэтому в каком-то смысле логично, что init не срабатывает. Остаётся вопрос: как же мне тестировать срабатывание хуков?

Спасибо!

Файл bootstrap выглядит так:

$_tests_dir = getenv('WP_TESTS_DIR');
if ( !$_tests_dir ) $_tests_dir = '/tmp/wordpress-tests-lib';

require_once $_tests_dir . '/includes/functions.php';

function _manually_load_plugin() {
  require dirname( __FILE__ ) . '/../includes/RegisterCustomPostType.php';
}
tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' );

require $_tests_dir . '/includes/bootstrap.php';

Тестируемый файл:

class RegisterCustomPostType {
  function __construct()
  {
    add_action( 'init', array( $this, 'register_post_type' ) );
  }

  public function register_post_type()
  {
    register_post_type( 'foo' );
  }
}

И сам тест:

class CustomPostTypes extends WP_UnitTestCase {
  function test_custom_post_type_creation()
  {
    $this->assertTrue( post_type_exists( 'foo' ) );
  }
}

Спасибо!

5
Комментарии

Если вы запускаете phpunit, видите ли вы проваленные или пройденные тесты? Вы установили bin/install-wp-tests.sh?

Sven Sven
11 окт. 2014 г. 16:01:33

Я думаю, что часть проблемы в том, что возможно RegisterCustomPostType::__construct() никогда не вызывается при загрузке плагина для тестов. Также возможно, что вы столкнулись с багом #29827; попробуйте обновить вашу версию тестового набора WP.

J.D. J.D.
11 окт. 2014 г. 16:05:24

@Sven: да, тесты падают; я установил bin/install-wp-tests.sh (поскольку использовал wp-cli)

@J.D.: RegisterCustomPostType::__construct действительно вызывается (я добавил die() и phpunit останавливается там)

Ionut Staicu Ionut Staicu
11 окт. 2014 г. 16:25:54

Я не слишком силен в юнит-тестировании (не моя сильная сторона), но с чисто технической точки зрения вы можете использовать did_action() для проверки, сработали ли действия.

Rarst Rarst
11 окт. 2014 г. 16:41:35

@Rarst: спасибо за предложение, но это все равно не работает. По какой-то причине, я думаю, что проблема во времени выполнения (тесты запускаются до хука init).

Ionut Staicu Ionut Staicu
11 окт. 2014 г. 16:45:47
Все ответы на вопрос 1
10
113

Test in isolation

When developing a plugin, the best way to test it is without loading the WordPress environment.

If you write code that can be easily tested without WordPress, your code becomes better.

Every component that is unit tested, should be tested in isolation: when you test a class, you only have to test that specific class, assuming all other code is working perfectly.

The Isolator

This is the reason why unit tests are called "unit".

As an additional benefit, without loading core, your test will run much faster.

Avoid hooks in constructor

A tip I can give you is to avoid putting hooks in constructors. That's one of the things that will make your code testable in isolation.

Let's see test code in OP:

class CustomPostTypes extends WP_UnitTestCase {
  function test_custom_post_type_creation() {
    $this->assertTrue( post_type_exists( 'foo' ) );
  }
}

And let's assume this test fails. Who is the culprit?

  • the hook was not added at all or not properly?
  • the method that register the post type was not called at all or with wrong arguments?
  • there is a bug in WordPress?

How it can be improved?

Let's assume your class code is:

class RegisterCustomPostType {

  function init() {
    add_action( 'init', array( $this, 'register_post_type' ) );
  }

  public function register_post_type() {
    register_post_type( 'foo' );
  }
}

(Note: I will refer to this version of the class for the rest of the answer)

The way I wrote this class allows you to create instances of the class without calling add_action.

In the class above there are 2 things to be tested:

  • the method init actually calls add_action passing to it proper arguments
  • the method register_post_type actually calls register_post_type function

I didn't say that you have to check if post type exists: if you add the proper action and if you call register_post_type, the custom post type must exist: if it doesn't exists it's a WordPress problem.

Remember: when you test your plugin you have to test your code, not WordPress code. In your tests you have to assume that WordPress (just like any other external library you use) works well. That's the meaning of unit test.

But... in practice?

If WordPress is not loaded, if you try to call class methods above, you get a fatal error, so you need to mock the functions.

The "manual" method

Sure you can write your mocking library or "manually" mock every method. It's possible. I'll tell you how to do that, but then I'll show you an easier method.

If WordPress is not loaded while tests are running, it means you can redefine its functions, e.g. add_action or register_post_type.

Let's assume you have a file, loaded from your bootstrap file, where you have:

function add_action() {
  global $counter;
  if ( ! isset($counter['add_action']) ) {
    $counter['add_action'] = array();
  }
  $counter['add_action'][] = func_get_args();
}

function register_post_type() {
  global $counter;
  if ( ! isset($counter['register_post_type']) ) {
    $counter['register_post_type'] = array();
  }
  $counter['register_post_type'][] = func_get_args();
}

I re-wrote the functions to simply add an element to a global array every time they are called.

Now you should create (if you don't have one already) your own base test case class extending PHPUnit_Framework_TestCase: that allows you to easily configure your tests.

It can be something like:

class Custom_TestCase extends \PHPUnit_Framework_TestCase {

    public function setUp() {
        $GLOBALS['counter'] = array();
    }

}

In this way, before every test, the global counter is reset.

And now your test code (I refer to the rewritten class I posted above):

class CustomPostTypes extends Custom_TestCase {

  function test_init() {
     global $counter;
     $r = new RegisterCustomPostType;
     $r->init();
     $this->assertSame(
       $counter['add_action'][0],
       array( 'init', array( $r, 'register_post_type' ) )
     );
  }

  function test_register_post_type() {
     global $counter;
     $r = new RegisterCustomPostType;
     $r->register_post_type();
     $this->assertSame( $counter['register_post_type'][0], array( 'foo' ) );
  }

}

You should note:

  • I was able to call the two methods separately and WordPress is not loaded at all. This way if one test fails, I know exactly who the culprit is.
  • As I said, here I test that the classes call WP functions with expected arguments. There is no need to test if CPT really exists. If you are testing the existence of CPT, then you are testing WordPress behavior, not your plugin behavior...

Nice.. but it's a PITA!

Yes, if you have to manually mock all the WordPress functions, it's really a pain. Some general advice I can give is to use as few WP functions as possible: you don't have to rewrite WordPress, but abstract WP functions you use in custom classes, so that they can be mocked and easily tested.

E.g. regarding example above, you can write a class that registers post types, calling register_post_type on 'init' with given arguments. With this abstraction you still need to test that class, but in other places of your code that register post types you can make use of that class, mocking it in tests (so assuming it works).

The awesome thing is, if you write a class that abstracts CPT registration, you can create a separate repository for it, and thanks to modern tools like Composer embed it in all the projects where you need it: test once, use everywhere. And if you ever find a bug in it, you can fix it in one place and with a simple composer update all the projects where it is used are fixed too.

For the second time: to write code that is testable in isolation means to write better code.

But sooner or later I need to use WP functions somewhere...

Of course. You never should act in parallel to core, it makes no sense. You can write classes that wraps WP functions, but those classes need to be tested too. The "manual" method described above may be used for very simple tasks, but when a class contains a lot of WP functions it can be a pain.

Luckily, over there there are good people that write good things. 10up, one of the biggest WP agencies, maintains a very great library for people that want to test plugins the right way. It is WP_Mock.

It allows you to mock WP functions an hooks. Assuming you have loaded in your tests (see repo readme) the same test I wrote above becomes:

class CustomPostTypes extends Custom_TestCase {

  function test_init() {
     $r = new RegisterCustomPostType;
     // tests that the action was added with given arguments
     \WP_Mock::expectActionAdded( 'init', array( $r, 'register_post_type' ) );
     $r->init();
  }

  function test_register_post_type() {
     // tests that the function was called with given arguments and run once
     \WP_Mock::wpFunction( 'register_post_type', array(
        'times' => 1,
        'args' => array( 'foo' ),
     ) );
     $r = new RegisterCustomPostType;
     $r->register_post_type();
  }

}

Simple, isn't it? This answer is not a tutorial for WP_Mock, so read the repo readme for more info, but the example above should be pretty clear, I think.

Moreover, you don't need to write any mocked add_action or register_post_type by yourself, or maintain any global variables.

And WP classes?

WP has some classes too, and if WordPress is not loaded when you run tests, you need to mock them.

That's much easier than mocking functions, PHPUnit has an embedded system to mock objects, but here I want to suggest Mockery to you. It's a very powerful library and very easy to use. Moreover, it's a dependency of WP_Mock, so if you have it you have Mockery too.

But what about WP_UnitTestCase?

The WordPress test suite was created to test WordPress core, and if you want to contribute to core it is pivotal, but using it for plugins only makes you test not in isolation.

Put your eyes over WP world: there are a lot of modern PHP frameworks and CMS out there and none of them suggests testing plugin/modules/extensions (or whatever they are called) using framework code.

If you miss factories, a useful feature of the suite, you have to know that there are awesome things over there.

Gotchas and downsides

There is a case when the workflow I suggested here lacks: custom database testing.

In fact, if you use standard WordPress tables and functions to write there (at the lowest level $wpdb methods) you never need to actually write data or test if data is actually in database, just be sure that proper methods are called with proper arguments.

However, you can write plugins with custom tables and functions that build queries to write there, and test if those queries work it's your responsibility.

In those cases WordPress test suite can helps you a lot, and loading WordPress may be needed in some cases to run functions like dbDelta.

(There is no need to say to use a different db for tests, isn't it?)

Luckily PHPUnit allows you to organize your tests in "suites" that can be run separately, so you can write a suite for custom database tests where you load WordPress environment (or part of it) leaving all the rest of your tests WordPress-free.

Only be sure to write classes that abstract as many database operations as possible, in a way that all the other plugin classes make use of them, so that using mocks you can properly test the majority of classes without dealing with database.

For third time, writing code easily testable in isolation means writing better code.

11 окт. 2014 г. 17:37:24
Комментарии

Офигеть, сколько полезной информации! Спасибо! Как-то я упустил всю суть модульного тестирования (до этого момента я практиковал тестирование PHP только внутри Code Dojo). Я также узнал о wp_mock сегодня, но по какой-то причине проигнорировал его.

Что меня бесило, так это то, что любой тест, каким бы маленьким он ни был, занимал минимум две секунды (сначала загрузка окружения WP, потом выполнение теста).

Еще раз спасибо за то, что открыл мне глаза!

Ionut Staicu Ionut Staicu
11 окт. 2014 г. 18:08:12

Спасибо @IonutStaicu, я забыл упомянуть, что отсутствие загрузки WordPress делает ваши тесты намного быстрее

gmazzap gmazzap
11 окт. 2014 г. 18:13:50

Действительно. Потому что в Dojo время выполнения было меньше 1-200 мс (несколько тестов, максимум 20-30); тестовый набор WP? достаточно, чтобы выбить меня из потока.

Ionut Staicu Ionut Staicu
11 окт. 2014 г. 18:21:07

Также стоит отметить, что фреймворк модульного тестирования WP Core — это потрясающий инструмент для проведения ИНТЕГРАЦИОННЫХ тестов, которые представляют собой автоматизированные тесты для проверки корректной интеграции с самим WordPress (например, отсутствие случайных коллизий имён функций и т.д.).

John P Bloch John P Bloch
11 окт. 2014 г. 22:15:10

@JohnPBloch +1 за хорошее замечание. Даже если использование пространства имён достаточно для избежания коллизий имён функций в WordPress, где всё глобально :) Но, конечно, интеграционные / функциональные тесты — это важно. Сейчас я экспериментирую с Behat + Mink, но пока ещё осваиваю этот инструментарий.

gmazzap gmazzap
11 окт. 2014 г. 23:17:36

Спасибо за "вертолётную экскурсию" по лесу модульных тестов WordPress — до сих пор смеюсь над этой эпичной картинкой ;-)

birgire birgire
12 окт. 2014 г. 13:33:00

@gmazzap Этот ответ, кажется, охватывает множество проблем, которые вызывали у меня головную боль. Однако либо я упускаю один момент, либо в нём не рассматривается случай, когда мне нужно протестировать класс, который создаёт экземпляр другого класса (например, WP_Post): я думал, что factory-muffin может помочь, но это работает, когда экземпляр класса передаётся как зависимость. В данном же случае экземпляр класса создаётся внутри тестируемого класса.

Andrea Sciamanna Andrea Sciamanna
25 июл. 2016 г. 15:43:35

@andrea-sciamanna проблема в том, что хороший "тестируемый" код должен быть написан так, чтобы избегать создания экземпляров объектов внутри других объектов (если это не PHP-объекты, такие как ArrayObject или объекты-значения), именно по этой причине. Если это происходит, и вы не можете/не хотите менять код, единственный способ "юнит"-тестирования таких объектов — создание "заглушек". Например, вы можете написать собственный класс WP_Post, который загружается только в тестах. Это нелегко поддерживать, и именно поэтому разработка с учётом тестирования или даже написание тестов до кода (TDD) — это хорошая практика :)

gmazzap gmazzap
25 июл. 2016 г. 15:54:01

Думаю, это заслуживает отдельного вопроса, тем более что здесь недостаточно места для продолжения обсуждения :) Вопрос: http://wordpress.stackexchange.com/questions/233132/unit-tests-dealing-with-dependencies

Andrea Sciamanna Andrea Sciamanna
25 июл. 2016 г. 16:28:12

Какой потрясающий ответ!

maiorano84 maiorano84
25 окт. 2018 г. 09:39:47
Показать остальные 5 комментариев