Principles of Unit Testing

by Joseph D. Purcell

at MidCamp

March 21, 2019

Who am I?

  • PHP developer since 2003
  • Lead Architect at Bounteous

We're hiring: bounteous.com/careers

My mentors...

@zendoodles
@craychee
@lukewertz
@stevector

@yesct
@crell
@becw

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)

Test doubles are used to help test units that have:

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


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

Indirect Input

Unit

  function get_product($id) {
    $product_storage = \Drupal::service('entity.manager')->getStorage('product');
    return $product_storage->load($id);
  }
            
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!

Indirect Output

Unit

  function increment_product_favorite_amount(Product $product) {
    $product->favorite_count = $product->favorite_amount + 1;
    $product->save();
    return $product;
  }
            
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

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

Test that CREATE is granted by default


  $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']));
            

Fake

Test that the X-Accel-Redirect header is the file path


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

  $response->prepare();

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

Stub

Test that an authorization checker allows access


  $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'));
            

Spy

Test that full name calls first and last name


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

  $profile->getFullName();

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

Mock

Test that full name calls first and last name


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

What should you double?

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

2. Solitary vs Sociable

"Man of Faith", photo from Popular Science

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

Test that loading a batch starts a session


  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) {
    $request = $event->getRequest();
    $route_match = RouteMatch::createFromRequest($request);
    if ($this->maintenanceMode->applies($route_match)) {
      \Drupal::service('page_cache_kill_switch')->trigger();
      if (!$this->maintenanceMode->exempt($this->account)) {
        if ($request->getRequestFormat() !== 'html') {
          $response = new Response($this->getSiteMaintenanceMessage(), 503, ['Content-Type' => 'text/plain']);
          $event->setResponse($response);
          return;
        }
        drupal_maintenance_theme();
        $response = $this->bareHtmlPageRenderer->renderBarePage(['#markup' => $this->getSiteMaintenanceMessage()], $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->messenger->addMessage($this->t('Operating in maintenance mode. Go online.', [':url' => $this->urlGenerator->generate('system.site_maintenance_mode')]), 'status', FALSE);
          } else {
            $this->messenger->addMessage($this->t('Operating in maintenance mode.'), 'status', FALSE);
          }
        }
      }
    }
  }
            

We can't solitary test this.

Let's...

  1. Write a higher level test to make sure we're not breaking anything.
  2. Delete the untestable code and re-implement later.
  3. Break the class into two responsibilities:
    • Generation of the maintenance mode page
    • Determining if maintenance mode applies
  4. Write our unit test.
  5. Eventually, delete our higher level test.

Responsibility 1: Generation of the maintenance mode page


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

}
          

Responsibility 2: Determining if maintenance mode applies


  class MaintenanceModeSubscriber {

    public function __construct(MaintenanceModePageInterface $maintenance_mode_page, MaintenanceModeInterface $maintenance_mode, AccountInterface $account) {
      $this->maintenanceModePage = $maintenance_mode_page;
      $this->maintenanceMode = $maintenance_mode;
      $this->account = $account;
    }

    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!

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 response

Stub the request

Execute the unit under test

Verify the unit meets our specification

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 7
  • It's more readable
  • It's easier to test
  • It's abstracted so non-HTML responses can be returned

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?

Think 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.6 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!

Please provide feedback:
mid.camp/301


The top rated sessions will be captioned,
courtesy of Clarity Partners!


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

Let's talk! @josephdpurcell

Contribution Day

Saturday 10am to 4pm


You don't have to know code to give back!


New Contributor training 10am to Noon

with AmyJune Hineline of Kanopi Studios

References

Further Reading