Principles of Unit Testing

by Joseph D. Purcell

at Midwest PHP

March 17, 2017

Who am I?

  • PHP developer since 2003
  • Sr. Developer at Digital Bridge Solutions
  • Making a difference for B2B companies in the midwest

Who are you?

  • You can code
  • You want to get better at testing your code

Unit testing is a tool that can help you write excellent code.

...sometimes

...I'll help you understand when

Overview

  1. Definition
  2. Principles
    • Test Doubles
    • 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. __construct)
  • a script (.e.g update.php)

...one thing

Consider three levels of testing:

  1. Solitary (e.g. UnitTestBase)
  2. Sociable (e.g. KernelTestBase)
  3. Functional (e.g. BrowserTestBase)


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

II. Principles

Test Doubles

Example

A Unit

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

  class IncrementTest extends \PHPUnit_Framework_TestSuite {
    public function testIncrement() {
      $result = increment(1);

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

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

A Unit With A Side Effect

  function increment(Product $product) {
    $product->favorite_count = $product->favorite_amount + 1;
    // Write value to database.
    $product->save();
    return $product;
  }
            
A Test

  class IncrementTest extends \PHPUnit_Framework_TestSuite {
    public function testIncrement() {
      $product = new \Product();
      $product->favorite_count = 1;

      $result = increment($product);

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

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


  $dummy = $this->getMockBuilder('SomeClass')->getMock();
  $cut = new ClassUnderTest($dummy);

  $response = $cut->methodDoesntNeedDummy();

  // Make an assertion.
            

... or just use null?

Fake


  $fs = new FakeFileSystem();
  $cut = new ClassUnderTest($fs);

  $response = $cut->write('A string going to a log.');

  // Make an assertion.
            

Example: write a fake for a logger.

Stub


  $stub = $this->getMockBuilder('User')->getMock();
  $stub->method('getFirstName')->willReturn('First');
  $stub->method('getLastName')->willReturn('Last');
  $cut = new ClassUnderTest($stub);

  $full_name = $cut->getFullName();

  $this->assertEquals('First Last', $full_name);
            

Like a dummy, but is called and returns values.

Spy


  $user = $this->prophet->prophesize('User');
  $cut = new ClassUnderTest($user->reveal());

  $cut->getFullName();

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

Like a stub, but captures calls.

Mock


  $stub = $this->getMockBuilder('User')->getMock();
  $stub->expects($this->once())->method('getFirstName')->willReturn('First');
  $stub->expects($this->once())->method('getLastName')->willReturn('Last');
  $cut = new ClassUnderTest($stub);

  $fullName = $cut->getFullName();
            

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

Recap of 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

What should you double?

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

Solitary vs Sociable

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.

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 test_load_starts_session() {
    $statement = $this->getMock(StatementInterface::class);
    $connection = $this->getMockBuilder(Connection::class)
        ->disableOriginalConstructor()
        ->getMock();
    $connection->method('query')->willReturn($statement);
    $session = $this->getMock(SessionInterface::class);
    $session->expects($this->once())->method('start');
    $csrf_token = $this->getMockBuilder(CsrfTokenGenerator::class)
        ->disableOriginalConstructor()
        ->getMock();
    $batch_storage = new BatchStorage($connection, $session, $csrf_token);

    $batch_storage->load(123);
  }
            

Can we make the assertion more apparent?

Let's test using a spy


  public function test_load_starts_session() {
    $prophet = new Prophet();
    $statement = $this->getMock(StatementInterface::class);
    $connection = $this->getMockBuilder(Connection::class)
        ->disableOriginalConstructor()
        ->getMock();
    $connection->method('query')->willReturn($statement);
    $session = $prophet->prophesize(SessionInterface::class);
    $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

  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.

  1. \Drupal::service
  2. drupal_maintenance_theme()
  3. Xss::filterAdmin
  4. SafeMarkup::format

Let's...

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

Example 2: Revision 1, Part 1

Drupal\Core\EventSubscriber\MaintenanceModePageOverrideSubscriber

Create an interface to implement later
Drupal\Core\Site\MaintenanceModePageInterface


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

}
          

Create a subscriber to override page rendering

Drupal\Core\EventSubscriber\MaintenanceModePageOverrideSubscriber

  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->maintenancePage->getResponse();
        $event->setResponse($response);
      }
    }
  }
            

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

  public function test_applicable_page_sets_maintenance_page_response() {
    $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();
  }
            

IV. Test Smells

Obscure Test

What is this test doing!?

Try: Object Mother


  class UserMother {
    public function getJohnDoe() {
      var $user = new User();
      $user->setFirstName('John');
      $user->setLastName('Doe');
      return $user;
    }
  }
            

Try: Data Builder


  class UserBuilder {
    protected $user;

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

    public function withNoPosts() {
      foreach ($this->user->getPosts() as $post) {
        $this->user->removePost($post);
      }
      return $this;
    }

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

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

Try: Custom Assertion


  class UserTest extends \PHPUnit_Framework_TestCase {

    use UserAssertions;

    public function test_something_complicated() {
      // Testing logic...

      $this->assertHasNoPosts($user);
    }

    protected function assertHasNoPosts(User $user) {
      // Complicated logic...

      $this->assertEmpty($posts);
    }
  }
            

Try: Expect Literals

Bad:


  $this->assertEquals('
' . $something . '
', $value);

Good:


  $this->assertEquals('
A string literal
', $value);

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 \PHPUnit_Framework_TestSuite {
    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:


  $this->assertEquals('John', $user->getFirstName());
  $this->assertEquals('Doe', $user->getLastName());
            

Good:


  $this->assertEquals('John', $user->getFirstName());
            

Good:


  $this->assertName('John', 'Doe', $user);
            

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
"If it stinks, change it."

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

Solitary unit testing makes bad OOP stinky.

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.

Recap

  • 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

Unit testable code means lower coupling

  means fewer merge conflicts

  means higher collaboration

  means faster development

  means quicker innovation

  means lower development cost

  means higher accuracy in deliverables


This is especially true for solitary unit testable code!

Thank you!

Feedback is welcome:
legacy.joind.in/20320


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

Let's talk! @josephdpurcell

References

Further Reading