"[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.
There are two challenges with testing a unit:
... 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);
}
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!
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.
$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
?
$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.
$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.
$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.
$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).
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;
}
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) {
$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);
}
}
}
}
}
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);
}
}
}
}
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
"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.
See also 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"
Slides are available:
github.com/josephdpurcell/principles-of-unit-testing
Rate this session:
events.drupal.org/nashville2018
Let's talk! @josephdpurcell