"[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...
t()
)function build()
)update.php
)...one thing
function increment($a) {
return $a + 1;
}
A 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 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 Test
public function testIncrementProductFavoriteAmountAddsOne() {
$product = new Product();
$product->favorite_count = 1;
$result = increment($product);
$this->assertEquals(2, $result->favorite_count);
}
Exception: no database to write to!
Test doubles are the tools we use
to test code that has 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 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);
}
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();
}
Drupal\Core\EventSubscriber\MaintenanceModeSubscriber
class MaintenanceModePageOverrideSubscriber {
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 MaintenanceModePageOverrideSubscriber {
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->maintenanceModePage->applies($route_match)) {
if (!$this->maintenanceModePage->exempt($this->account)) {
$response = $this->maintenanceModePage->getResponse();
$event->setResponse($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 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();
}
What is this test doing!?
class UserMother {
public function getDisabledUser() {
$user = User::create([
'name' => 'John Doe',
'status' => FALSE,
]);
return $user;
}
}
class UserBuilder {
protected $user;
public function __construct() {
$this->user = new User();
}
public function disabled() {
$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!!!
See also kernel tests.
"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"
means fewer merge conflicts
means higher collaboration
means faster development
means lower development cost
means quicker innovation
means higher accuracy in deliverables
This is especially true for solitary unit testable code!
Slides are available:
github.com/josephdpurcell/principles-of-unit-testing
Let's talk! @josephdpurcell