...sometimes
...I'll help you understand when
"[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
)__construct
)update.php
)...one thing
Point of confusion: these can all be written in PHPUnit.
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:
... anything that makes it not a pure function.
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.
$dummy = $this->getMockBuilder('SomeClass')->getMock();
$cut = new ClassUnderTest($dummy);
$response = $cut->methodDoesntNeedDummy();
// Make an assertion.
... or just use null
?
$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 = $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.
$user = $this->prophet->prophesize('User');
$cut = new ClassUnderTest($user->reveal());
$cut->getFullName();
$user->getFirstName()->shouldHaveBeenCalled();
$user->getLastName()->shouldHaveBeenCalled();
Like a stub, but captures calls.
$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.
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 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);
}
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();
}
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);
}
}
}
}
}
\Drupal::service
drupal_maintenance_theme()
Xss::filterAdmin
SafeMarkup::format
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);
}
}
}
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();
}
What is this test doing!?
class UserMother {
public function getJohnDoe() {
var $user = new User();
$user->setFirstName('John');
$user->setLastName('Doe');
return $user;
}
}
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;
}
}
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);
}
}
Bad:
$this->assertEquals('' . $something . '', $value);
Good:
$this->assertEquals('A string literal', $value);
Source: Cunningham Ward, c2.com
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);
}
}
Which assertion failed? I have no idea.
Bad:
$this->assertEquals('John', $user->getFirstName());
$this->assertEquals('Doe', $user->getLastName());
Good:
$this->assertEquals('John', $user->getFirstName());
Good:
$this->assertName('John', 'Doe', $user);
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!!!
"If it stinks, change it."— Grandma Beck, discussing child-rearing philosophy, "Refactoring"
Solitary unit testing makes bad OOP stinky.
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 quicker innovation
means lower development cost
means higher accuracy in deliverables
This is especially true for solitary unit testable code!
Feedback is welcome:
legacy.joind.in/20320
Slides are available:
github.com/josephdpurcell/principles-of-unit-testing
Let's talk! @josephdpurcell