Testing WordPress... without WordPress

Testing WordPress... without WordPress

One of the most known leitmotif for people into code quality assurance is: "run unit tests in isolation".
To apply this principle to code written for WordPress will bring us to write tests to be ran without loading WordPress.
The talk will mostly pivot on real-world examples of unit-testing WordPress plugins in isolation.

Fac44f6299128cc787cfaa20941f2233?s=128

Giuseppe Mazzapica

December 16, 2017
Tweet

Transcript

  1. Testing WordPress… ...without WordPress Giuseppe Mazzapica

  2. Unit tests are a simple thing. Take the smallest portion

    of code that can be extracted from the rest of the application, execute it, and get feedback about the obtained result being the expected result.
  3. None
  4. <?php function ( , ){ = ( )[ ]; it

    $m $p $d debug_backtrace 0 0 ( ) and = (); is_callable $p $p $p global ; = ||! ; $e $e $e $p = .( ? : ). ; $o "\e[3" "2m✔" "1m✘" " It \e[0m" $p $m echo ? : { [ ]} { [ ]} ; $p " \n" " FAIL in: 'file' # 'line' \n" $o $o $d $d } (function(){global ; and die( );}); register_shutdown_function $e $e 1 #TestFrameworkInATweet 280 characters version 1 2 3 4 5 6 7 8 9
  5. <?php require_once __DIR__. ; '/test-framework-in-a-tweet.php' ( , + === );

    it 'should sum two numbers.' 1 1 2 ( , + === ); it 'should display an X for a failing test.' 1 1 3 ( , function () { it 'should append to an ArrayIterator.' = (); $iterator new ArrayIterator -> ( ); $iterator append 'test' ( ) === ; return count $iterator 1 } ); $ php my-tests.php It should sum two numbers. ✔ ✘ It should display an X for a failing test. FAIL in: /path/to/my-tests.php #6 ✔ It should append to an ArrayIterator. 1 2 3 4 5 6 7 8 9 10 11 12
  6. namespace MyCompany\MyPlugin; final class { Email private ; $email public

    function ( string ) { __construct $email if ( ! ( , ) ) { filter_var $email FILTER_VALIDATE_EMAIL ( { } ); throw new \Exception " isn’t valid." $email } -> = ; $this email $email } public function (): string { __toString -> ; return $this email } public function ( Email ): bool { equals $email (string) === (string) ; return $this $email } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
  7. require_once ; '/path/to/test-framework-in-a-tweet.php' require_once ; '/path/to/code/src/MyCompany/MyPlugin/Email.php' ( , function ()

    { it 'should equal its string value.' = ( ); $email new MyCompany\MyPlugin\Email 'foo@example.com' (string) === ; $email return 'foo@example.com' } ); ( , function () { it 'should equal another instance by value.' = ( ); $email new MyCompany\MyPlugin\Email 'foo@example.com' -> ( new ( ) ); $email return equals Email 'foo@example.com' } ); 1 2 3 4 5 6 7 8 9 10 11 12 13 $ php my-tests.php It should equal its string value. ✔ It should equal another instance by value. ✔
  8. We tested our class with a tweet-sized function Our function

    is part of a WordPress plugin We tested our class without loading WordPress
  9. but what about... namespace MyCompany\MyPlugin; function () { register_product_cpt (

    , [ ] ); register_post_type 'product' /* bunch of args... */ } 1 2 3 4 5 Can we test this function without loading WordPress?
  10. YES, WE CAN. but what about... namespace MyCompany\MyPlugin; function ()

    { register_product_cpt ( , [ ] ); register_post_type 'product' /* bunch of args... */ } 1 2 3 4 5 Can we test this function without loading WordPress?
  11. <?php require_once __DIR__. ; '/test-framework-in-a-tweet.php' require_once ; '/path/to/wordpress/wp-load.php' // YOLO

    it( , function () { 'should register product post type.' = ( ); $exists_before post_type_exists 'product' \ (); MyCompany\MyPlugin register_product_cpt = ( ); $exists_after post_type_exists 'product' === false && = true; return $exists_before $exists_after } ); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 first, let’s try loading WP...
  12. register_post_type works post_type_exists works our function calls WordPress functions in

    the proper way What is that test testing?
  13. Our responsibility, when writing tests for a plugin, is to

    test code works. our The best way to do it, is to write tests assuming WP just works.
  14. function ( ) { post_type_exists $post_type ( { } ,

    ); return array_key_exists "post_type_ " $post_type $GLOBALS } function ( , ) { register_post_type $post_type $args [ { } ] = ; $GLOBALS "post_type_ " $post_type $args } it( , function () { 'should register product post type.' = ( ); $exists_before post_type_exists 'product' (); MyCompany\MyPlugin\register_product_cpt = ( ); $exists_after post_type_exists 'product' === false && = true; return $exists_before $exists_after } ); 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
  15. Test code did not changed. But this time we didn’t

    tested WP code works, but we tested that our code calls WordPress functions in the proper way. And it was easy and fast.
  16. Because WordPress was not loaded, we were able to write

    a “fake”, simplified, version of WordPress functions, for the sole purpose of testing. That’s called . STUB
  17. It was . convenient Without loading WordPress we: avoided scaffolding

    step removed the burden of database and system configuration improved tests performance
  18. To run tests in the context of WordPress is important

    at some point to verify the correctness of the plugin. It is essential for plugins with heavy UI integration.
  19. What about objects? 3 4 5 6 7 8 9

    10 11 12 13 14 15 16 interface { Clock public function (): int; hours public function (): int; minutes public function (): int; seconds } function ( Clock ) { maybeGong $clock if ( -> () === && -> () === ) { $clock $clock minutes seconds 0 0 = -> (); $hours $clock hours = > && <= ? : ( - ); $gongs $h $h $h $h 0 12 12 abs ( ( , ) ); print rtrim str_repeat 'GONG ' $gongs } }
  20. 3 4 5 6 7 8 9 10 11 12

    13 14 15 16 final class implements { SystemClock Clock public function (): int { hours (int) ( () )-> ( ); return new \Datetime format 'G' } public function (): int { minutes (int) ( () )-> ( ); return new \Datetime format 'i' } public function (): int { seconds (int) ( () )-> ( ); return new \Datetime format 's' } } A stub will allow us to test this non-determistic behavior.
  21. 3 4 5 6 7 8 9 10 11 12

    13 14 15 16 17 18 19 20 21 22 23 24 class implements { ClockStub Clock private ; $hours private ; $minutes public function ( int , int ) { __construct $hours $minutes -> = ; $this hours $hours -> = ; $this minutes $minutes } public function (): int { hours -> ; return $this hours } public function (): int { minutes -> ; return $this minutes } public function (): int { seconds ; return 0 } }
  22. 3 4 5 6 7 8 9 10 11 12

    13 14 it( , function () { "should gong 3 times at 3 o'clock." (); ob_start ( ( , ) ); maybeGong new ClockStub 3 0 () === ; return ob_get_clean "GONG GONG GONG" } ); ( , function () { it 'should not gong at 10 past 3.' (); ob_start ( ( , ) ); maybeGong new ClockStub 3 10 () === ; return ob_get_clean "" } );
  23. We leveraged polymorphism to write a stub object that is

    a different, deterministic, implementation of the same interface. We could do that because the maybe_gong() function was written to accept an interface as parameter.
  24. Stubs are great, but stubs are . hard Stubs can

    be written for . objects Stubs can be written for . functions
  25. Stubs are . code And code needs to be written.

    Code needs to be maintained. Code might have has bugs.
  26. Introducing mock objects. A sort of virtual stubs: they are

    not written as real classes in the filesystem, but created on runtime based on a set of expectations.
  27. 6 7 8 9 10 11 12 13 14 15

    16 17 18 19 20 21 22 23 24 25 26 it( , function () { "should gong 3 times at 3 o'clock." = :: ( ); $threeOClock Mockery mock 'Clock' -> ( )-> () $threeOClock shouldReceive once 'hours' -> ()-> ( ); withNoArguments andReturn 3 -> ( )-> () $threeOClock shouldReceive once 'minutes' -> ()-> ( ); withNoArguments andReturn 0 -> ( )-> () $threeOClock shouldReceive once 'seconds' -> ()-> ( ); withNoArguments andReturn 0 (); ob_start ( ); $threeOClock maybeGong = () === ; $result ob_get_clean "GONG GONG GONG" :: (); Mockery close ; $result return });
  28. Mock Objects respond to method calls in a pre-defined way

    (like stubs) ensure methods are called a specific number of times with specific type and number of args offer less surface to bugs, moving the complexity to a 3rd party lib
  29. 6 7 8 9 10 11 12 13 14 15

    16 17 18 19 20 21 22 23 24 25 26 27 class { ProductsQuery private ; $query private = ; $paged 0 public function ( = NULL ) { __construct WP_Query $query -> = ? : (); $this query $query new WP_Query } public function (): array { next_products -> ++; $this paged -> -> ( [ $this query query => , 'post_type' 'product' => , 'posts_per_page' 10 => -> , $this 'paged' paged ] ); if ( ! -> -> () ) { $this query have_posts []; return } -> -> ; $this return query posts }
  30. 6 7 8 9 10 11 12 13 14 15

    16 17 18 19 20 21 22 23 24 25 26 27 28 it( , function () { "should get first 10 products." = ( function() { $ten_posts array_map :: ( ); return Mockery mock 'WP_Post' }, ( , ) ); range 0 9 = :: ( ); $query_mock Mockery mock 'WP_Query' $query_mock -> ( )-> () shouldReceive once 'query' -> ( function ( array ) { withArgs $args ( [ ] ?? ) === ; return $args 'post_type' '' 'product' } ) -> ( , ); andSet 'posts' $ten_posts $query_mock -> ( )-> () shouldReceive once 'have_posts' -> ()-> ( true ); withNoArgs andReturn = ( ); $query $query_mock new ProductsQuery -> () === ; return $query $ten_posts next_products } );
  31. When that is done, that fact that actual posts are

    pulled from DB is not a concern to test in our plugin. Thanks to Mockery we tested that WP_Query methods are called the right number of times, with the right arguments.
  32. Set expectations for functions Define functions multiple times What is

    necessary to obtain for functions the same power Mockery provides for objects?
  33. Set expectations for functions Define functions multiple times > Doable

    with a little bit of code > Doable thanks to Patchwork What is necessary to obtain for functions the same power Mockery provides for objects?
  34. Brain Monkey is a library that uses Patchwork to bring

    Mockery object expectations to functions.
  35. 1 2 3 4 5 6 7 8 9 10

    11 12 13 14 15 16 17 18 19 20 21 <?php namespace bm; require_once __DIR__. ; '/vendor/antecedent/patchwork/Patchwork.php' require_once __DIR__. ; '/vendor/autoload.php' require_once __DIR__. ; '/test-framework-in-a-tweet.php' function ( , ) { = ( )[ ]; it $m $p $d debug_backtrace 0 0 try { (); \Brain\Monkey\setUp // before each test (); ob_start \ ( , ); it $m $p // actual test = (); $output ob_get_clean (); \Brain\Monkey\tearDown // after each test echo ; $output } catch(\ ) { Throwable $t [ ] = true; $GLOBALS 'e' echo { [ ]} { [ ]} ; "\e[31m✘ It \e[0m FAIL in: 'file' # 'line' ." $m $d $d echo . -> () . ; ' ' "\n" $t getMessage } } Just a little of configuration
  36. 6 7 8 9 10 11 12 13 14 15

    16 17 18 19 20 21 22 23 24 25 26 27 28 function ( ) { last_post_shortcode $args = [ => 1, => 0, => ]; $def 'date' 'content' 'post_type' 'post' = ( , , ); $atts $def shortcode_atts $args 'last_post' = ([ $posts get_posts => [ ], $atts 'post_type' 'post_type' => , 'posts_per_page' 1 ]); if ( ) { $posts [ ] = ( ); $GLOBALS $posts 'post' reset ( [ ]); $GLOBALS setup_postdata 'post' = ( , ()); $out sprintf '<div id="%s">' get_the_ID if ( [ ]) { $atts 'date' = ( ()); $date esc_html get_the_date .= ( , ); $out $date sprintf '<p class="date">%s</p>' } .= ( , ( ())); $out sprintf '<h1>%s</h1>' esc_html get_the_title [ ] and .= (); $atts $out 'content' get_the_content (); wp_reset_postdata . ; $out return '</div>' } }
  37. 1 2 3 4 5 6 7 8 9 10

    11 12 13 14 15 16 17 18 19 20 21 22 23 <?php require_once __DIR__. ; '/test-framework-in-a-tweet-monkey.php' require_once ; '/path/to/src/MyCompany/MyPlugin/functions.php' bm\ ( , function () { it 'should render shortcode for last product.' = [ => true, => ]; $args 'content' 'post_type' 'products' ( ) Brain\Monkey\Functions\expect 'get_posts' -> () once -> ( function( array ) { andReturnUsing $args if ( [ ] === ) { $args 'post_type' 'products' [ :: ( ) ]; return Mockery mock 'WP_Post' } }); ( ) Brain\Monkey\Functions\expect 'shortcode_atts' -> () once -> ( :: ( ), , ) with type Mockery 'array' 'lastpost' $args -> ( function( , array ) { andReturnUsing $defaults $args ( , ); return array_merge $defaults $args } );
  38. 1 2 3 4 5 6 .. 23 24 25

    26 27 28 29 30 31 <?php require_once __DIR__. ; '/testframeworkinatweet-brainmonkey.php' require_once ; '/path/to/src/MyCompany/MyPlugin/functions.php' bm\ ( , function () { it 'should render shortcode for last product.' ... ( ) Brain\Monkey\Functions\expect 'setup_postdata' -> () once -> ( :: ( ) ); with Mockery type 'WP_Post' ( ) Brain\Monkey\Functions\expect 'wp_reset_postdata' -> () once -> (); withNoArgs
  39. 1 2 3 4 5 6 .. 31 32 33

    34 35 36 37 38 39 <?php require_once __DIR__. ; '/testframeworkinatweet-brainmonkey.php' require_once ; '/path/to/src/MyCompany/MyPlugin/functions.php' bm\ ( , function () { it 'should render shortcode for last product.' ... \ ( [ Brain\Monkey\Functions stubs => , 'get_the_ID' 123 => , 'get_the_date' '15/12/2017' => , 'get_the_title' 'Test Title' => , 'get_the_content' '<p>The content!</p>' => null, 'esc_html' ] );
  40. 1 2 3 4 5 6 7 8 .. 39

    40 41 42 43 44 45 46 47 48 <?php require_once __DIR__. ; '/testframeworkinatweet-brainmonkey.php' require_once ; '/path/to/src/MyCompany/MyPlugin/functions.php' bm\ ( , function () { it 'should render shortcode for last product.' = [ => true, => ]; $args 'content' 'post_type' 'products' ... = ( ); $rendered $args last_post_shortcode = ( , ); $has_id $rendered strpos 'id="123"' = ( , ); $has_date $rendered strpos '15/12/2017</p>' = ( , ); $has_title $rendered strpos '<h1>Test Title</h1>' = ( , ); $has_content $rendered strpos '<p>The content!</p>' && && && ; return $has_id $has_date $has_title $has_content }
  41. 5 6 7 8 9 10 11 12 13 14

    15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 bm\ ( , function () { it 'should render shortcode for last product.' = [ => TRUE, => ]; $args 'content' 'post_type' 'products' \ ( ) Brain\Monkey\Functions expect 'get_posts' -> () once -> ( function ( array ) { andReturnUsing $args if ( [ ] === ) { $args 'post_type' 'products' [ \ :: ( ) ]; return Mockery mock 'WP_Post' } } ); \ ( ) Brain\Monkey\Functions expect 'shortcode_atts' -> () once -> ( \ :: ( ), \ :: ( ), ) with type type Mockery Mockery 'array' 'array' 'last_post' -> ( function ( , array ) { andReturnUsing $defaults $args ( , ); return array_merge $defaults $args } ); \ ( ) Brain\Monkey\Functions expect 'setup_postdata' -> () once -> ( :: ( ) ); with type Mockery 'WP_Post' \ ( ) Brain\Monkey\Functions expect 'wp_reset_postdata' -> () once -> (); withNoArgs \ ( [ Brain\Monkey\Functions stubs => , 'get_the_ID' 123 => , 'get_the_date' '15/12/2017' => , 'get_the_title' 'Test Title' => null, 'esc_html' => , 'get_the_content' '<p>The content!</p>' ] ); = ( ); $rendered $args last_post_shortcode = ( , ); $has_id $rendered strpos 'id="123"' = ( , ); $has_date $rendered strpos '15/12/2017</p>' = ( , ); $has_title $rendered strpos '<h1>Test Title</h1>' = ( , ); $has_content $rendered strpos '<p>The content!</p>' && && && ; $has_id $has_date $has_title $has_content return } ); Quite a lot of code... but the limited features of our test framework do not help here...
  42. There is a set of functions that we find in

    each WordPress plugins, and mock them every time is quite tedious and awkward: plugin API. add_filter() apply_filters() add_action() do_action() current_filter() did_action() and others...
  43. Brain Monkey defines already almost all those functions in a

    way that is 100% compatible with WordPress real code.
  44. 6 7 8 9 10 11 12 13 14 15

    16 17 18 19 20 21 22 23 24 25 26 27 28 <?php namespace MyPlugin; function () { create_initializer if ( ! ( ) ) { did_action 'init' null; return } ( ); do_action 'my_plugin_init' = ( $init_class apply_filters 'my_init_class', Initializer::class ); = (); $initializer $init_class new ( , [ , ] ); add_action 'template_redirect' 'init' $initializer ; return $initializer }
  45. 1 2 3 4 5 6 7 8 9 10

    11 12 13 14 15 16 17 18 19 <?php require_once __DIR__. ; '/testframeworkinatweet-brainmonkey.php' require_once ; '/path/to/src/MyPlugin/functions.php' bm\ ( , function () { it 'should not init if WP is not initialized.' () === null; return MyPlugin\create_initializer } ); bm\ ( , function () { it 'should init if WP is initialized.' ( ); do_action 'init' = (); $initializer MyPlugin\create_initializer instanceof ; return $initializer MyPlugin\Initializer } );
  46. Thanks to Brain Monkey we ran our tests just like

    WordPress was loaded, without any mock or stub, and everything worked as expected. But there’s more...
  47. 1 2 3 4 5 6 7 8 9 10

    11 12 bm\ ( it , 'should not fire my_plugin_init if WP is not initialized.' function () { \ ( ) 'my_plugin_init' Brain\Monkey\Actions expectDone -> (); never (); MyPlugin\create_initializer TRUE; return } );
  48. 1 2 3 4 5 6 7 8 9 10

    11 12 13 14 15 bm\ ( it , 'should fire my_plugin_init once if WP is initialized.' function () { ( ) 'my_plugin_init' Brain\Monkey\Actions\expectDone -> () once -> (); withNoArgs ( ); 'init' do_action (); MyPlugin\create_initializer TRUE; return } );
  49. 1 2 3 4 5 6 7 8 9 10

    11 12 13 14 15 16 17 18 19 bm\ ( it , 'should add hook initializer method to template redirect' function () { ( ) 'template_redirect' Brain\Actions\expectAdded -> () once -> ( function ( array ) { withArgs $cb return ( [ ], ::class ) is_a $cb 0 MyPlugin\Initializer && [ ] === ; 'init' $cb 1 } ); ( ); 'init' do_action (); MyPlugin\create_initializer true; return } );
  50. 1 2 3 4 5 6 7 8 9 10

    11 12 13 14 15 16 17 bm\ ( it , 'should use a different initializer class if filtered.' function () { = :: ( ::class ); $mock Mockery MyPlugin\Initializer mock = ( ); $mock_class $mock get_class ( ) 'my_plugin_init_class' Filters\expectApplied -> () once -> ( ::class ) with MyPlugin\Initializer -> ( ); andReturn $mock_class ( ); 'init' do_action return ( (), ); is_a MyPlugin\create_initializer $mock_class } );
  51. Brain Monkey allows to test plugin API functions with the

    finest level of control, and with a syntax that is readable and consistent with the one used to create mock objects with Mockery.
  52. Brain Monkey defines following logicless functions: __return_true() __return_false() __return_null() __return_empty_array()

    __return_empty_string() __return_zero() trailingslashit() untrailingslashit()
  53. Thanks to Mockery and Brain Monkey we have the possibility

    to write unit tests for plugins without loading WordPress in a way that is very easy to start with and produces maintainable tests.
  54. Unit tests are a simple concept Do not load WP

    for plugins unit tests is often convenient Mockery and Brain Monkey can help with that. Takeaways:
  55. Giuseppe Mazzapica Biggest German WordPress Agency WordPress.com Featured Service Partner

    Gold Certified WooCommerce Expert https://gmazzap.me @gmazzap github.com/gmazzap UESTIONS Q ?