D8FTW: PHP-industry standard PHPUnit testing framework đã release

D8FTW: PHP-industry standard PHPUnit testing framework đã release

Code testing: It's like exercise. It's good for you, but most people really don't want to do it. Unless you develop a habit for it and feel the benefit, most people will try to avoid it. And if it's too hard to do it becomes easy to avoid.

That's really unfortunate, as good testing can have a huge improvement on the quality of a system and, over time, even improve how fast a system can be developed because you spend less time finding and fighting old bugs and more time building things right the first time.

Drupal 7 introduced testing to the Drupal world for the first time, but not really unit testing. Unit testing, specifically, is testing one "unit" of a larger system in isolation. If you can verify that one unit works as designed you can confirm that it doesn't have a bug and move on to finding bugs elsewhere. If your tests are automated, you can get notified that you have a bug early, before you even commit code.

The problem with Drupal 7 is that it doesn't really have units. Drupal's historically procedural codebase has made isolating specific portions of code to test extremely difficult, meaning that to test one small bit of functionality we've needed to automate installing the entirety of Drupal, create for-reals content, test that a button works, and then tear the whole thing down again. That is horribly inefficient as well as quite challenging to do at times, especially given the poor home-grown testing framework Drupal 7 ships with. As a result, many many developers simply don't bother. It's too hard, so it's easier to avoid.

Now fast forward to Drupal 8 and we find the situation has changed:

  • That historically procedural codebase is now largely object-oriented, which means every class forms a natural "unit" candidate. (Class and "testable unit" are not always synonymous, but it's a good approximation.)
  • Because most of those classes leverage their constructors properly to pass in dependencies rather than calling out to them we can easily change what objects are passed for testing purposes.
  • In addition to Simpletest, Drupal now includes the PHP-industry standard PHPUnit testing framework.

All of that adds up to the ability to write automated tests for Drupal in less time, that run faster, are more effective, and are, in short, actually worth doing.

Smallest testable unit

Let's try and test the custom breadcrumb builder we wrote back in episode 1 (The Phantom Service). Here it is again, for reference:

<?php
// newsbreadcrumb/src/NewsBreadcrumbBuilder.php
namespace Drupal\newsbreadcrumb;
use Drupal\Core\Breadcrumb\BreadcrumbBuilderBase;

class

NewsBreadcrumbBuilder extends BreadcrumbBuilderBase {
  /**
   * {@inheritdoc}
   */
  public function applies(array $attributes) {
    if ($attributes['_route'] == 'node_page') {
      return $attributes['node']->bundle() == 'news';
    }
  }
/**
   * {@inheritdoc}
   */
  public function build(array $attributes) {
    $breadcrumb[] = $this->l($this->t('Home'), NULL);
    $breadcrumb[] = $this->l($this->t('News'), 'news');
   return $breadcrumb;
  }
}
?>

We want to test that the logic in this class works as expected but without testing the entirety of Drupal, or even the entirety of the breadcrumb system if we can avoid it. We just want to test this code. Can we? With good OOP design and PHPUnit, we can!

First, let's create a new testing class in our newsbreadcrumb module. This class does not go in the same directory as our code itself. Instead, it goes in a separate "tests" directory but follows the same file/name pattern.

<?php
// newsbreadcrumb/tests/src/NewsBreadcrumbBuilderTest.php
namespace Drupal\newsbreadcrumb\Tests;

use

Drupal\Tests\UnitTestCase;

use

Drupal\newsbreadcrumb\NewsBreadcrumbBuilder;

/**

* Tests the News breadcrumb builder.
*/
class NewsBreadcrumbBuilderTest extends UnitTestCase {

  public static function

getInfo() {
    return array(
      'name' => 'News breadcrumb tests',
      'description' => 'Tests the news breadcrumbs',
      'group' => 'News breadcrumb',
    );
  }

}

?>

It should look fairly familiar for anyone who's written a Drupal 7 test. The main difference is that we're extending UnitTestCase, which is a very thin extension of PHPUnit's main base class for tests. Now we can define some test methods. As in Drupal 7, a test method is one that begins with test. Rather than assume we have a complete Drupal install and making actual HTTP calls against it, though, we'll just test our class directly.

<?php
  public function testApplicablePage() {
    $node_stub = $this->getMockBuilder('\Drupal\node\Entity\Node')
      ->disableOriginalConstructor()
      ->getMock();
    $node_stub->expects($this->once())
      ->method('bundle')
      ->will($this->returnValue('news'));   

$attributes = [
      'node' => $node_stub,
      '_route' => 'node_page',
    ];   

$builder = new NewsBreadcrumbBuilder();   

$this->assertEquals(TRUE, $builder->applies($attributes), 'Yay');
  }
?>

This method will test only one thing: That the applies() method works and returns True if the right attributes are passed in. To do so, we create a new instance of our class and call applies() with the attributes that, we think, should cause it to return true. No Drupal needed!

Of course, that requires a node that will return "news" when its bundle() method is called. But we don't have actual nodes; we just have our class! Fortunately we don't need a real node; we just need something that behaves as a node, and behaves as we tell it to. For simple cases we can just construct a class ourselves for testing purposes, but nodes are a rather complex object so that's difficult. Instead, we'll use PHPUnit to create a mock object for us.

A mock object is an object that will act like another object to the extent that we tell it to, but isn't actually that object. Our breadcrumb builder doesn't know the difference, which is fine. The syntax for creating a mock object is a little quirky in PHPUnit but fairly straightfoward. The first few lines of our test method can be read as "get me a fake version of an object that looks like the Node class", then "that object should expect that one time, its 'bundle()' method will be called and when it is it will return the value 'news'". That's as much as we care about, so that's as much as we'll do.

The rest is fairly self-explanatory. Use that mock object to build the attributes array then use it as normal. If we get back True, all is right with the world. (Note: Ideally we'd mock NodeInterface, not the Node class itself, but there's a bug in PHPUnit 3.7 that is incompatible with the way NodeInterface is put together. That will be corrected when Drupal upgrades to PHPUnit 4, which is a work in progress.)

We should also test the negative: That nodes of type other than "news" won't return True. In Drupal 7 it was common to lump both checks into a single test method for performance. However, tests in PHPUnit are doing less than a thousandth of the work that big bulky Simpletest had to in Drupal 7. That makes them fast. How fast? Running all of core's 4900 unit tests (as of this writing) natively on my SSD-based laptop takes less than 20 seconds. (On a slow VM on a spinning-disk hard drive computer, it takes me a whole minute. *gasp*)

Test performance is rarely if ever an issue with proper unit tests, so let's go ahead and make an entirely new test method.

<?php
  public function testNotApplicablePage() {
    $node_stub = $this->getMockBuilder('\Drupal\node\Entity\Node')
      ->disableOriginalConstructor()
      ->getMock();
    $node_stub->expects($this->once())
      ->method('bundle')
      ->will($this->returnValue('article'));   

$attributes = [
      'node' => $node_stub,
      '_route' => 'node_page',
    ];   

$builder = new NewsBreadcrumbBuilder();
    $this->assertEquals(FALSE, $builder->applies($attributes), 'Yay');
  }
?>

Note there's very little different; just a different node type and a different assertion. That's fine. That kind of minor code duplication is entirely acceptable in unit tests; if there's a lot of duplication, though, utility methods or test data providers are a viable option.

We should test the build() method, too. At first blush it seems much the same:

<?php
  public function testBuild() {
    $node_stub = $this->getMockBuilder('\Drupal\node\Entity\Node')
      ->disableOriginalConstructor()
      ->getMock();
   
$attributes = [
      'node' => $node_stub,
      '_route' => 'node_page',
    ];
   

$builder = new NewsBreadcrumbBuilder();
    $breadcrumbs = $builder->build($attributes);
    $this->assertEquals(count($breadcrumbs), 2, 'Correct number of crumbs');
  }
?>

If we try to run that, though, we get this exciting error from PHPUnit:

Fatal error: Call to a member function get() on a non-object in /path/to/site/core/lib/Drupal.php on line 138

Huh? Let's back up a bit and explain what's going on. Look back at our build() method and you'll see it calls two methods we didn't define: $this->t() and $this->l(). Those are defined in the parent BreadcrumbBuilderBase class, and are wrappers around other services, specifically the translation service and the link generator service. The actual functionality of those systems is now in service objects, which are themselves testable. Our builder has a dependency on those services, which means it needs copies of them passed to it in order to work.

Normally, most services would be passed to our service's constructor by the Container (see episode 2). That would make mocking them trivially easy. However, that can get tedious to pass services to constructors and then save them to an object property all the time. It can get to be a lot of boilerplate. For that reason, a select few services have been incorporated into base classes like BreadcrumbBuilderBase or, preferably, traits. The main trait that's been converted so far is translation, via StringTranslationTrait. That trait provides a t() implementation that calls out to the Container directly (something that normally you should never, ever do) and retrieves the translation service on-demand. That's all well and good for making developers' lives easier, but really breaks testability. How can we mock that service if it's being retrieved that way?

Fortunately, the trait provides a setStringTranslation() method that lets us, in tests only, override the translation service with a mocked copy. Since translation is such a common service to mock there's a common mock translation service provided by UnitTestCase that will handle placeholder replacement but not do translation, which is exactly what we want. Thus, let's modify our test method like so:

<?php
$builder = new NewsBreadcrumbBuilder();
$builder->setStringTranslation($this->getStringTranslationStub());
$breadcrumbs = $builder->build($attributes);
?>

Now when our build() method calls $this->t(), it will end up with our mocked translation service instead and we're fine.

... Except for that $this->l() call. As of this writing, the l() method still hasn't been split off to a trait. (It should be before Drupal 8 ships, however.) That means the only way to override it for testing is, yuck, to make a subclass just for our test.

<?php
class NewsBreadcrumbSubstitute extends NewsBreadcrumbBuilder {
  public function setLinkGenerator($generator) {
    $this->linkGenerator = $generator;
  }
}
?>

And then we write our test with NewsBreadcrumbSubstitute. Gross, right? That's why that should hopefully change before Drupal 8 is released, and why you should strive to always use constructor injection like a grown up.

That also means you should not overly rely on the trait proxies. Most of the time, it's cleaner and clearer to just inject a service via the constructor rather than wrapping a trait proxy around it. As a general rule, if you're writing a service that you register in the container then you should not use a trait proxy yourself; if there's a base class you're extending it probably will for you already. In the long run it will make testing easier, and that is the entire goal.

Back to our unit test. Now that we have our testable extended breadcrumb builder, the rest of the test process is fairly straightforward:

<?php
  public function testBuild() {
    $node_stub = $this->getMockBuilder('\Drupal\node\Entity\Node')
      ->disableOriginalConstructor()
      ->getMock();

   

$attributes = [
      'node' => $node_stub,
      '_route' => 'node_page',
    ];
   

$generator_stub = $this->getMockBuilder('\Drupal\Core\Utility\LinkGeneratorInterface')
      ->getMock();   
$generator_stub->expects($this->at(0))
      ->method('generate')
      ->with('Home', NULL)
      ->will($this->returnValue('first'));
    $generator_stub->expects($this->at(1))
      ->method('generate')
      ->with('News', 'news')
      ->will($this->returnValue('second'));
   

$builder = new NewsBreadcrumbSubstitute();
    $builder->setStringTranslation($this->getStringTranslationStub());
    $builder->setLinkGenerator($generator_stub);   

$breadcrumbs = $builder->build($attributes);
   

$this->assertEquals(count($breadcrumbs), 2, 'Correct number of crumbs');
    $this->assertEquals($breadcrumbs[0], 'first', 'First breadcrumb added');
    $this->assertEquals($breadcrumbs[1], 'second', 'Second breadcrumb added');
  }
?>

Note that we're now mocking the "link generator" (a service) by interface. We're then saying that the generate() method will be called twice, and what it should be called with. We then return a placeholder value. Note that this has absolutely nothing to do with links; we don't care about links. We're confirming that the mock gets called, and returning a value that we can check to ensure gets passed back in the breadcrumb array. That's it. If generate() is called with a value we don't expect, the mock will raise an error for us. We don't have to do it ourselves.

The PHPUnit documentation has far more information on PHPUnit's capabilities than we could fit into a blog post. It's long, but worth skimming to get an idea of what is possible.

Conclusion

All that code! It looks weird! At first, perhaps, but it's surprising how quickly it becomes second nature. As we said, it's also super-fast. Actual Test-Driven-Development is now possible in Drupal, thanks to the refactoring into injected service objects. Even if you don't want to go full-on test-first TDD (since some people seem to think it's dead for some reason), easy-to-write unit tests still give your code a sense of reliability that is very liberating.

It's very likely that at times you'll run into code where it's hard to write a good unit test. Generally speaking, that's probably a sign not that testing is a problem but your code should be refactored to make the test easier. Doing so, in most cases, actually makes the code better anyway, as well as easier to understand. And cleaner, easy to understand, easy to test code is how rock solid systems are built.

Bạn thấy bài viết này như thế nào?: 
No votes yet
Ảnh của Tommy Tran

Tommy owner Express Magazine

Drupal Developer having 9+ year experience, implementation and having strong knowledge of technical specifications, workflow development. Ability to perform effectively and efficiently in team and individually. Always enthusiastic and interseted to study new technologies

  • Skype ID: tthanhthuy

Tìm kiếm bất động sản

 

Advertisement

 

jobsora

Dich vu khu trung tphcm

Dich vu diet chuot tphcm

Dich vu diet con trung

Quảng Cáo Bài Viết

 
Hướng dẫn tạo multilingual site với Panopoly

Hướng dẫn tạo multilingual site với Panopoly

Download and enable the prerequisite modules: Variable, Internationalisation, Multilingual content, Path translation, Variable translation

Ý kiến: Người Việt sĩ diện nên bỏ 1 tỉ USD để mua smartphone

Ý kiến: Người Việt sĩ diện nên bỏ 1 tỉ USD để mua smartphone

Để mua một chiếc iPhone 5s, nhiều người phải bỏ ra 30% thu nhập. Chỉ để thỏa mãn thói sĩ diện hão mà nhiều người chẳng dám ăn, chẳng dám tiêu.

Tạo album ảnh độc đáo trên Facebook bằng LifePix

Tạo album ảnh độc đáo trên Facebook bằng LifePix

Sao chỉ đăng những bức ảnh đơn điệu lên Facebook, trong khi đó bạn lại có thể làm nhiều hơn thế với LifePix?

Công ty diệt chuột T&C

 

Diet con trung