We're hiring: bounteous.com/careers
"[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...
t()
)function build()
)update.php
)...one thing
Think of it as a single frame in a call stack...
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);
}
}
Point of confusion: these can all be written in PHPUnit.
"Man of Faith", photo from Popular Science
Test doubles are used to help test units that have:
... anything that makes it not a pure function.
function get_product($id) {
$product_storage = \Drupal::service('entity.manager')->getStorage('product');
return $product_storage->load($id);
}
public function testGetProductRetrievesId() {
$product_id = 1;
$result = get_product($product_id);
$this->assertEquals($product_id, $result->id());
}
function increment_product_favorite_amount(Product $product) {
$product->favorite_count = $product->favorite_amount + 1;
$product->save();
return $product;
}
public function testIncrementProductFavoriteAmountAddsOne() {
$product = new Product();
$product->favorite_count = 1;
$result = increment_product_favorite_amount($product);
$this->assertEquals(2, $result->favorite_count);
}
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']));
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'));
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'));
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();
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();
It depends if you're doing
Sociable or Solitary unit testing.
Source: Jay Fields, "Working Effectively With Unit Tests"
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
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;
}
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
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
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);
}
}
}
}
}
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);
}
}
}
}
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
"If it stinks, change it."
— Grandma Beck, discussing child-rearing philosophy, "Refactoring"
What is this test doing!?
class UserMother {
public function getInactiveUser() {
$user = User::create([
'name' => 'John Doe',
'status' => FALSE,
]);
return $user;
}
}
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;
}
}
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...
}
}
Bad:
$this->assertEquals('text', $output);
Good:
$this->assertEquals('text', $output);
Source: Cunningham Ward, c2.com
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);
}
}
Which assertion failed? I have no idea.
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);
}
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);
}
I didn't change this class!? How is it now erroring?
Why am I repeating myself so much?
Writing these tests takes way too long.
I spend more time changing tests than code!!!
Photo of two-level bascule bridge in Chicago, IL, photo courteousy of Lori Babcock.
Think unit vs kernel tests...
Have you had to write a KernelTest?
"It should not be underestimated how much easier these functions are to test than their state-filled counterparts."— Simon Honeywell, "Functional Programming in PHP"
Please provide feedback:
mid.camp/301
Slides are available:
github.com/josephdpurcell/principles-of-unit-testing
Let's talk! @josephdpurcell
You don't have to know code to give back!
New Contributor training 10am to Noon
with AmyJune Hineline of Kanopi Studios