Principles of Unit Testing

by Joseph D. Purcell

at BADCamp

October 20, 2017

Who am I?

  • PHP developer since 2003
  • Development Team Lead at Digital Bridge Solutions
  • Making a difference for B2B companies in the midwest

Who are you?

"Tar Pit" in Mythical Man Month by Fred Brooks

Two takeaways:

  1. You will understand WHAT unit testing is.
  2. You will understand WHEN unit testing is valuable.

Overview

  1. Definition
  2. Principles
    1. Test Doubles
    2. Solitary vs Sociable
  3. Principles by Example
  4. Test Smells
  5. Motivations

I. Definition

What is a "unit test"?

"[I]t's a situational thing - the team decides what makes sense to be a unit for the purposes of their understanding of the system and its testing."

— Martin Fowler, martinfowler.com

A unit test verifies a function's correctness
against a specification.

A unit is...

  • a function (e.g. t())
  • a method on a class (e.g. function build())
  • a script (e.g. update.php)

...one thing

Example

A Unit

  function increment($a) {
    return $a + 1;
  }
            
A Test

  use PHPUnit\Framework\TestCase;

  class IncrementTest extends TestCase {
    public function testIncrementAddsOne() {
      $result = increment(1);

      $this->assertEquals(2, $result);
    }
  }
            

Consider levels of testing

  1. Solitary Unit Testing (e.g. UnitTestCase)
  2. Sociable Unit Testing (e.g. KernelTestBase)
  3. Functional Testing (e.g. WebTestBase)


Point of confusion: these can all be written in PHPUnit.

This is not a unit test.

"Man of Faith", photo from Popular Science

II. Principles

1. Test Doubles

There are two challenges with testing a unit:

  1. indirect inputs (shared state)
  2. indirect outputs (side effects)


... anything that makes it not a pure function.

Example 1

A Unit with an Indirect Input

  function get_product($id) {
    $product_storage = \Drupal::service('entity.manager')->getStorage('product');
    return $product_storage->load($id);
  }
            
A Test

  public function testGetProductRetrievesId() {
    $product_id = 1;

    $result = get_product($product_id);

    $this->assertEquals($product_id, $result->id());
  }
            
Exception: no database to read from!

Example 2

A Unit with a Side Effect

  function increment_product_favorite_amount(Product $product) {
    $product->favorite_count = $product->favorite_amount + 1;
    $product->save();
    return $product;
  }
            
A Test

  public function testIncrementProductFavoriteAmountAddsOne() {
    $product = new Product();
    $product->favorite_count = 1;

    $result = increment($product);

    $this->assertEquals(2, $result->favorite_count);
  }
            
Exception: no database to write to!

Test doubles are the tools we use
to test code that has shared state or side effects.

Test Doubles

Dummy
never called
Fake
is called
Stub
provide indirect inputs
Spy
provide indirect inputs, capture indirect outputs
Mock
provide indirect inputs, verify indirect outputs

Dummy


  $token_dummy = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface');
  $object_dummy = new \stdClass();

  $voter = new Voter();

  $this->assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token_dummy, $object_dummy, ['CREATE']));
            

A dummy is never called, it has no state.

... just use NULL?

Fake


  $file_fake = new FakeFile($path);
  $response = new BinaryFileResponse($file_fake, 200);

  $response->prepare();

  $this->assertEquals($path, $response->headers->get('X-Accel-Redirect'));
            

A concrete class that fakes another concrete class.

Stub


  $authorization_checker = $this->getMock('Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface');
  $authorization_checker->method('isGranted')->willReturn(true);

  $controller = new TestController($authorization_checker);

  $this->assertTrue($controller->isGranted('foo'));
            

Like a dummy, but is called and returns values.

Spy


  $user_spy = $this->prophesize(User::class);
  $profile = new Profile($user_spy->reveal());

  $profile->getFullName();

  $user_spy->getFirstName()->shouldHaveBeenCalled();
  $user_spy->getLastName()->shouldHaveBeenCalled();
            

Like a stub, but captures calls.

Mock


  $user_mock = $this->getMockBuilder('User')->getMock();
  $user_mock->expects($this->once())->method('getFirstName')->willReturn('First');
  $user_mock->expects($this->once())->method('getLastName')->willReturn('Last');
  $profile = new Profile($user_mock);

  $profile->getFullName();
            

Like a spy, but runs assertions on execution of CUT.

What should you double?

It depends if you're doing
Sociable or Solitary unit testing.

2. Solitary vs Sociable

"Bay Bridge", photo by California Department of Transportation

Sociable

  1. Cross some boundaries
  2. One or more concrete classes during test

vs

Solitary

  1. Never cross boundaries
  2. One concrete class during test

Source: Jay Fields, "Working Effectively With Unit Tests"

What is a boundary?

A boundary is "a database, a queue,
another system, or even an ordinary class
if that class is 'outside' the area your trying
to work with or are responsible for"

— William E. Caputo, www.williamcaputo.com

What does "one concrete class" mean?

  • Use a double for dependencies.
  • Collaborators that return objects
    should return doubles.
  • No statics.
  • Caveat: value objects don't count.

Recap: Principles of Unit Testing

  • Use Test Doubles to draw the boundary:
    • Dummy
    • Fake
    • Stub
    • Spy
    • Mock
  • A boundary is an indirect input or output.
  • Sociable Unit Testing:
    • Cross some boundaries
    • One or more concrete classes during test
  • Solitary Unit Testing:
    • Never cross boundaries
    • One concrete class during test

III. Principles by Example

Example 1

Drupal\Core\Batch\BatchStorage

  public function load($id) {
    $this->session->start();
    $batch = $this->connection->query("SELECT batch FROM {batch} WHERE bid = :bid AND token = :token", array(
      ':bid' => $id,
      ':token' => $this->csrfToken->get($id),
    ))->fetchField();
    if ($batch) {
      return unserialize($batch);
    }
    return FALSE;
  }
            

Let's test using a mock


  public function testLoadStartsSession() {
    // Handle the session start side effect.
    $session = $this->getMock(SessionInterface::class);
    $session->expects($this->once())->method('start');

    // Handle the query indirect input.
    $statement = $this->getMock(StatementInterface::class);
    $connection = $this->getMockBuilder(Connection::class)
        ->disableOriginalConstructor()
        ->getMock();
    $connection->method('query')->willReturn($statement);

    // Handle the CSRF token indirect input.
    $csrf_token_dummy = $this->getMockBuilder(CsrfTokenGenerator::class)
        ->disableOriginalConstructor()
        ->getMock();
    $batch_storage = new BatchStorage($connection, $session, $csrf_token_dummy);

    $batch_storage->load(123);
  }
            

Can we make the
assertion more apparent?

Let's test using a spy


  public function testLoadStartsSession() {
    $session = $prophet->prophesize(SessionInterface::class);
    $prophet = new Prophet();
    $statement = $this->getMock(StatementInterface::class);
    $connection = $this->getMockBuilder(Connection::class)
        ->disableOriginalConstructor()
        ->getMock();
    $connection->method('query')->willReturn($statement);
    $csrf_token = $this->getMockBuilder(CsrfTokenGenerator::class)
        ->disableOriginalConstructor()
        ->getMock();
    $batch_storage = new BatchStorage($connection, $session->reveal(), $csrf_token);

    $batch_storage->load(123);

    $session->start()->shouldHaveBeenCalled();
  }
            

Example 2

Drupal\Core\EventSubscriber\MaintenanceModeSubscriber

class MaintenanceModePageOverrideSubscriber {
  public function onKernelRequestMaintenance(GetResponseEvent $event) {
    $route_match = RouteMatch::createFromRequest($event->getRequest());
    if ($this->maintenanceMode->applies($route_match)) {
      \Drupal::service('page_cache_kill_switch')->trigger();
      if (!$this->maintenanceMode->exempt($this->account)) {
        drupal_maintenance_theme();
        $content = Xss::filterAdmin(SafeMarkup::format($this->config->get('system.maintenance')->get('message'), array(
          '@site' => $this->config->get('system.site')->get('name'),
        )));
        $response = $this->bareHtmlPageRenderer->renderBarePage(['#markup' => $content], $this->t('Site under maintenance'), 'maintenance_page');
        $response->setStatusCode(503);
        $event->setResponse($response);
      }
      else {
        if ($route_match->getRouteName() != 'system.site_maintenance_mode') {
          if ($this->account->hasPermission('administer site configuration')) {
            $this->drupalSetMessage($this->t('Operating in maintenance mode. Go online.', array('@url' => $this->urlGenerator->generate('system.site_maintenance_mode'))), 'status', FALSE);
          }
          else {
            $this->drupalSetMessage($this->t('Operating in maintenance mode.'), 'status', FALSE);
          }
        }
      }
    }
  }
            

We can't solitary test this.

Let's...

  1. Delete the untestable code and re-implement later.
  2. Break the class into two responsibilities:
    • setting response
    • showing a message

1. Create an interface to implement later.


namespace Drupal\Core\Site;

/**
 * Defines the interface for a maintenance mode page.
 */
interface MaintenanceModePageInterface {

  /**
   * Returns a Response object.
   *
   * @return \Drupal\Core\Render\HtmlResponse
   *   The response object for a maintenance page.
   */
  public function getResponse();

}
          

2. Create a subscriber to override page rendering


  class MaintenanceModePageOverrideSubscriber {

    public function __construct(MaintenanceModePageInterface $maintenance_mode_page) {
      $this->maintenanceModePage = $maintenance_mode_page;
    }

    public function onKernelRequestMaintenance(GetResponseEvent $event) {
      $route_match = RouteMatch::createFromRequest($event->getRequest());
      if ($this->maintenanceModePage->applies($route_match)) {
        if (!$this->maintenanceModePage->exempt($this->account)) {
          $response = $this->maintenanceModePage->getResponse();
          $event->setResponse($response);
        }
      }
    }

  }
            

Now we can test!

And look at the improvements:

  • Now our class does one thing
  • Which means we can test one thing
  • The class now has 3 dependencies instead of 6
  • It's more readable
  • It's easier to test
  • It's abstracted so non-HTML responses can be returned (hint, hint)

  public function testApplicablePageSetsMaintenancePageResponse() {
    $prophet = new Prophet();
    $account = $this->getMock(AccountInterface::class);
    $maintenance_mode = $this->getMock(MaintenanceModeInterface::class);
    $maintenance_mode->method('applies')->willReturn(true);
    $maintenance_page = $this->getMock(MaintenanceModePageInterface::class);
    $response = $this->getMock(HtmlResponse::class);
    $maintenance_page->method('getResponse')->willReturn($response);
    $subscriber = new MaintenanceModePageOverrideSubscriber($maintenance_mode, $account, $maintenance_page);
    $request = $this->getMock(Request::class);
    $request->attributes = $this->getMock(ParameterBag::class);
    $event = $prophet->prophesize(GetResponseEvent::class);
    $event->setResponse(Argument::cetera())->willReturn();
    $event->getRequest()->willReturn($request);

    $subscriber->onKernelRequestMaintenance($event->reveal());

    $event->setResponse($response)->shouldHaveBeenCalled();
  }
            

Solitary unit testing forces you to write good code.

But, it's not about good code.

 

It's about a healthy project.

IV. Test Smells

Obscure Test

What is this test doing!?

Try: Object Mother


  class UserMother {
    public function getDisabledUser() {
      $user = User::create([
        'name' => 'John Doe',
        'status' => FALSE,
      ]);
      return $user;
    }
  }
            

Try: Data Builder


  class UserBuilder {
    protected $user;

    public function __construct() {
      $this->user = new User();
    }

    public function disabled() {
      $this->user->status = FALSE;
      return $this;
    }

    public function withPosts($count = 10) {
      for (var $i = 0; $i < $count; $i++) {
        $this->user->addPost(new Post());
      }
      return $this;
    }

    public function build() {
      return $this->user;
    }
  }
            

Try: Custom Assertion


  class LinkGeneratorTest extends TestCase {

    public function testGenerateHrefs() {
      $url_generator = new Drupal\Core\Routing\UrlGenerator();
      $url = new Url('test_route_1', [], ['absolute' => FALSE]);
      $url->setUrlGenerator($url_generator);

      $result = $this->linkGenerator->generate('Test', $url);

      $this->assertLink(['attributes' => ['href' => '/test-route-1']], $result);
    }

    protected function assertLink(array $properties, MarkupInterface $html) {
      // Complex assertion logic goes here...
    }

  }
            

Try: Expect Literals

Bad:


  $this->assertEquals('text', $output);
            

Good:


  $this->assertEquals('text', $output);
            

Try: Arrange-Act-Assert

Arrange
Arrange all necessary preconditions and inputs.
Act
Act on the object or method under test.
Assert
Assert that the expected results have occurred.

Source: Cunningham Ward, c2.com

Try: Arrange-Act-Assert


  class IncrementTest extends TestCase {
    public function testIncrement() {
      // Arrange.
      $product = new Product();
      $product->favorite_count = 1;

      // Act.
      $result = increment($product);

      // Assert.
      $this->assertEquals(2, $result->favorite_count);
    }
  }
            

Assertion Roulette

Which assertion failed? I have no idea.

Try: One assertion per test

Bad:


  public function testConstructWriteSafeSessionHandlerDefaultArgs() {
    $this->assertSame($this->sessionHandler->isSessionWritable(), TRUE);

    $this->wrappedSessionHandler
      ->expects($this->at(0))
      ->method('write')
      ->with('some-id', 'serialized_session_data')
      ->will($this->returnValue(TRUE));

    $this->wrappedSessionHandler
      ->expects($this->at(1))
      ->method('write')
      ->with('some-id', 'serialized_session_data')
      ->will($this->returnValue(FALSE));

    $result = $this->sessionHandler->write('some-id', 'serialized_session_data');
    $this->assertSame($result, TRUE);

    $result = $this->sessionHandler->write('some-id', 'serialized_session_data');
    $this->assertSame($result, FALSE);
  }
            

Try: One assertion per test

Good:


  public function testSessionHandlerIsWritable() {
    $this->assertSame($this->sessionHandler->isSessionWritable(), TRUE);
  }

  public function testReturnValuePassesToCaller() {
    $this->wrappedSessionHandler
      ->method('write')
      ->with('some-id', 'serialized_session_data')
      ->will($this->returnValue(TRUE));

    $result = $this->sessionHandler->write('some-id', 'serialized_session_data');

    $this->assertSame($result, TRUE);
  }
            

Fragile Test

I didn't change this class!? How is it now erroring?

Try:

  • Eliminate dependencies
  • Eliminate side effects
  • Eliminate shared state
  • Avoid test over-specification

Test Code Duplication

Why am I repeating myself so much?

Writing these tests takes way too long.

Try:

  • Object Mother
  • Data Builder
  • Add abstractions where there is high afferent coupling

High Test Maintenance Cost

I spend more time changing tests than code!!!

Try:

  • Use the right test strategy for the job
    Unit testing isn't a panacea!
  • Remove negative or low ROI tests
  • Object Mother / Data Builder
  • Add abstractions where there is high coupling
  • Avoid test over-specification

Unit testing provides low level of benefit.

Photo of wooden bridge in eastern Congo, photo courteousy of Lori Babcock.

Unit testing provides high level of benefit.

Photo of two-level bascule bridge in Chicago, IL, photo courteousy of Lori Babcock.

IV. Motivations

Sociable unit testing covers
more functionality.

See also kernel tests.

Sociable unit testing is
easier to maintain, but harder to debug.

Solitary unit testing enables
higher code coverage.

Solitary unit testing promotes decoupling.

Solitary unit testing executes faster
than sociable unit testing.

Solitary unit testing enables
better software designs.

"Writing unit tests is reinventing
functional programming
in non-functional languages."

— Christian S., noss.github.io

Solitary unit testing motivates us towards pure functions while allowing us to keep the benefits of OOP.

"It should not be underestimated how much easier these functions are to test than their state-filled counterparts."

— Simon Honeywell, "Functional Programming in PHP"

Ok, but where do I start?

Drupal 8.3 code metrics generated by phpmetrics.org, see results.

Why is Symfony so successful for re-use?

  • It's good object oriented code.
  • No surprise: the tests are mostly (entirely?) solitary.

Unit testable code means lower coupling

  means fewer merge conflicts

  means higher collaboration

  means faster development

  means lower development cost

  means quicker innovation

  means higher accuracy in deliverables


This is especially true for solitary unit testable code!

Remember the Principles of Unit Testing:
test doubles   and   solitary vs sociable

Thank you!


Slides are available:
github.com/josephdpurcell/principles-of-unit-testing

Let's talk! @josephdpurcell

References

Further Reading