Principles of Unit Testing

by Joseph D. Purcell

at DrupalCon Nashville

April 11, 2018

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

"The software development community simply hasn't managed to settle on well-defined terms around testing."

— Martin Fowler, martinfowler.com

A unit test verifies a unit'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

Think of it as a single frame in a call stack...

Example

A Unit

  function increment($a) {
    return $a + 1;
  }
            
A Unit 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. BrowserTestBase)


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

This is NOT like a unit test.

"Man of Faith", photo from Popular Science

This is like a unit test.

Steel Beam Test (YouTube)

II. Principles

1. Test Doubles

Source: Fandom (link)

Source: Jeneshia via CBC News (link)

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 Unit 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 Unit Test

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

    $result = increment_product_favorite_amount($product);

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

Test doubles are the tools we use
to test units that have 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 Class Under Test (CUT).

What should you double?

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

2. Solitary vs Sociable

"Korean War Veterans Memorial Bridge", photo from Wikipedia

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

  • A unit test verifies a unit's correctness against a specification.
  • Use Test Doubles for indirect inputs or outputs:
    • 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);
  }
            

Mock the session start (this is our test specification)

Stub the indirect input from the database

Dummy the CSRF token

Execute the class under test

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();
  }
            

Execute the class under test

Use a spy to assert our test specification

Example 2

Drupal\Core\EventSubscriber\MaintenanceModeSubscriber

class MaintenanceModeSubscriber {
  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 MaintenanceModeSubscriber {

    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->maintenanceMode->applies($route_match)) {
        if (!$this->maintenanceMode->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)

Test that an applicable maintenance mode page
can set the response.


  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 MaintenanceModeSubscriber($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();
  }
            

Dummy the account

Stub the "applies" method

Stub the getResponse method

Stub the getRequest method

Execute the class under test

Verify the unit meets our specification

IV. Test Smells

"If it stinks, change it."

— Grandma Beck, discussing child-rearing philosophy, "Refactoring"

Obscure Test

What is this test doing!?

Try: Object Mother


  class UserMother {
    public function getInactiveUser() {
      $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 inactive() {
      $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
  • One assertion per test (Arrange-Act-Assert)
  • 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

IV. Motivations

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.

When does unit testing have HIGH value?

  • Rule of 3 (at least 3 classes will use this)
  • Writing a library, e.g. composer dependency
  • Before refactoring legacy code
  • High amount of necessary complexity
  • Exploratory work or to assist development, e.g. TDD
  • There is high cost to even minor bugs
  • You're writing code in a framework, e.g. Symfony

When does unit testing have LOW value?

  • If you're writing "glue" code (e.g. most Drupal projects)
  • Maintenance cost is significantly higher than the cost of a bug
  • You're writing code in a platform, e.g. Drupal
  • There is a more valuable form of testing with lower effort

Solitary or sociable?

See also unit vs kernel tests.

Sociable unit testing...

  • hard to debug
  • requires lots of set up and tear down
  • requires lots of test doubles

 

Have you had to write a KernelTest?

Solitary unit testing...

  • allows higher code coverage
  • promotes decoupling
  • executes faster
  • enables better software design
"It should not be underestimated how much easier these functions are to test than their state-filled counterparts."

— Simon Honeywell, "Functional Programming in PHP"

Solitary unit testing forces
you to write good code.

Unless someone like you
cares a whole awful lot,
nothing is going to
get better, it's not

Ok, but where do I start?

Drupal 8.5 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.

Remember the Principles of Unit Testing:

 

test doubles
solitary vs sociable

Thank you!


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

Rate this session:
events.drupal.org/nashville2018

Let's talk! @josephdpurcell

References

Further Reading