Share
Embed
Learn To Test Like A Grumpy Programmer
Slides from the workshop I gave at Symfony Live Berlin on October 26, 2017
Chris Hartjes
October 26, 2017
Transcript
117 slides
-
Today — TDD as a design tool — The role
of tests in your application — Testing building blocks — Code katas to build testing skills — Difficult language constructs to test — Code coverage — Mutation testing -
The Grumpy Way Have an idea Write a test like
everything works Write code until the test passes Repeat until you are internet- famous -
TDD As Design Tool We use the tests to help
us build out a collection of loosely-coupled modules that accomplish well-defined tasks -
The role of tests 5 factors courtesy of Sarah Mei
https://www.devmynd.com/blog/five-factor-testing/ -
Testing Building Blocks Assertions Created by the folks who invented
NUnit style testing in the Java world -
Testing Building Blocks Assertions Revolves around a very simple concept:
your test is designed to prove that the result is as you expected it to be -
Testing Building Blocks Assertions These cover 99% of everything I've
ever needed to assert -
TDD As Design Tool Code Kata #1 — take a
collection of positive integers — iterate over the collection — replace all integers % 3 with 'Fizz' — replace all integers % 5 with 'Buzz' — replace all integers % 5 and 3 with 'FizzBuzz' -
TDD As Design Tool Code Kata #2 Build a Magic
The Gathering deck list analyzer -
Code Kata #2 Parse a deck list submi!ed as an
array Sort the items by number of cards and then alphabetical -
Code Kata #2 Each line consists of [Number of cards][optional
'x' or 'X'] [Name Of Card] — [Name of Card] is alphabetical characters, commas and apostophres -
Code Kata #2 Example: 2X Taiga 1 Forest 3 Grove
of the Burnwillows 4 Wasteland 1x Verdant Catacombs Becomes 4x Wasteland 3x Grove of the Burnwillows -
Testing Building Blocks They are used when you need a
dependency for a test and don't want to use the real thing -
Testing Building Blocks Fake A Fake is when you create
your own version of a dependency that has the same functionality class FakeMailer { function validateEmail($emailAddress) { // Code using regex to verify addresses } function sendMail($emailAddress, $msg) { return true; } } -
Testing Building Blocks Dummy A Dummy is when you create
a version of a dependency that has the same class signature but has no functionality -
Testing Building Blocks Stub A Stub is when you create
a version of a dependency that has the same class signature and the methods your test needs defined -
Testing Building Blocks Mock A Mock is when you create
a version of a depedency that has the same class signature, the methods your test needs defined, and methods return values -
Testing Building Blocks Mock $double = $this->prophesize(SpamCheckerService::class); $response = '{"success":true,"score":"7.9"}';
$double->process(\Prophecy\Argument::type('string')) ->willReturn($response); $spamCheckerService = $double->reveal; -
Testing Building Blocks Some guidelines Always use the real thing
if you can Work hard to keep your doubles up to date -
Testing Building Blocks Always use the real thing if you
can Doubles increase the size of your Arrange step -
Testing Building Blocks Work hard to keep your doubles up
to date Use doubling tools that help you! -
Testing Building Blocks Work hard to keep your doubles up
to date Prophecy is currently the best choice -
Testing Building Blocks Work hard to keep your doubles up
to date Prophecy won't let you create doubles with method calls that don't exist on the dependency -
Code Kata #3 Create an object that submits a message
to h!p:// spamcheck.postmarkapp.com Processes the response on whether it's spam or not -
Code Kata #3 Implement code for handling: — '{"success":true,"score":"3.1"}' —
'{"success":false,"message":""}' -
Code Coverage — Tool for looking at what code is
being run by your tests -
Mutation Testing — Randomly changes your code ("mutating") — Looking
for tests that pass even when code is mutated -
Mutation Testing — Sometimes the mutations are false-positives! — Requires
very-specific testing scenarios to have a "high score" -
Mutation Testing 861 mutations were generated: 383 mutants were killed
414 mutants were not covered by tests 64 covered mutants were not detected 0 time outs were encountered Metrics: Mutation Score Indicator (MSI): 44% Mutation Code Coverage: 52% Covered Code MSI: 86% -
Difficult Language Constructs Global objects They lurk everywhere and anywhere
Adds extra work to the Arrange step -
Difficult Language Constructs Global objects Good practice - refactor to
configuration objects Allows over-riding them in your arrange steps -
Difficult Langage Constructs Service Locators They are like global variables
with methods a!ached to them! -
Difficult Language Constructs Service Locators class DashboardController extends BaseController {
public function showSpeakerProfile() { /** * Local reference to speakers application service. * * This should be injected instead of using service location but there's currently a * "conflict" between Controller as Services and our custom ControllerResolver that injects the Application * container. * * @var Speakers $speakers */ $speakers = $this->service('application.speakers'); try { $profile = $speakers->findProfile(); /** @var CallForProposal $cfp */ $cfp = $this->service('callforproposal'); return $this->render('dashboard.twig', [ 'profile' => $profile, 'cfp_open' => $cfp->isOpen(), ]); } catch (NotAuthenticatedException $e) { return $this->redirectTo('login'); } } -
Difficult Language Constructs Service Locators class DashboardControllerTest extends \PHPUnit\Framework\TestCase {
use GeneratorTrait; /** * Test that the index page returns a list of talks associated * with a specific user and information about that user as well * * @test */ public function indexDisplaysUserAndTalks() { $app = new Application(BASE_PATH, Environment::testing()); $app['session.test'] = true; // Set things up so Sentry believes we're logged in $user = m::mock('StdClass'); $user->shouldReceive('id')->andReturn(1); $user->shouldReceive('getId')->andReturn(1); $user->shouldReceive('hasAccess')->with('admin')->andReturn(true); // Create a test double for Sentry $sentry = m::mock(Sentry::class); $sentry->shouldReceive('check')->times(3)->andReturn(true); $sentry->shouldReceive('getUser')->andReturn($user); $app['sentry'] = $sentry; $app['callforproposal'] = m::mock(CallForProposal::class); $app['callforproposal']->shouldReceive('isOpen')->andReturn(true); // Create a test double for a talk in profile $talk = m::mock('StdClass'); $talk->shouldReceive('title')->andReturn('Test Title'); $talk->shouldReceive('id')->andReturn(1); $talk->shouldReceive('type', 'category', 'created_at'); // Create a test double for profile $profile = m::mock('StdClass'); $profile->shouldReceive('name')->andReturn('Test User'); $profile->shouldReceive('photo', 'company', 'twitter', 'airport', 'bio', 'info', 'transportation', 'hotel'); $profile->shouldReceive('talks')->andReturn([$talk]); $speakerService = m::mock('StdClass'); $speakerService->shouldReceive('findProfile')->andReturn($profile); $app['application.speakers'] = $speakerService; ob_start(); $app->run(); // Fire before handlers... boot... ob_end_clean(); // Instantiate the controller and run the indexAction $controller = new DashboardController(); $controller->setApplication($app); $response = $controller->showSpeakerProfile(); $this->assertContains('Test Title', (string) $response); $this->assertContains('Test User', (string) $response); } } -
Difficult Language Constructs Service Locators class DashboardControllerTest extends \PHPUnit\Framework\TestCase {
use GeneratorTrait; /** * Test that the index page returns a list of talks associated * with a specific user and information about that user as well * * @test */ public function indexDisplaysUserAndTalks() { $app = new Application(BASE_PATH, Environment::testing()); /** Other arrange work goes here **/ // Create a test double for a talk in profile $talk = m::mock('StdClass'); $talk->shouldReceive('title')->andReturn('Test Title'); $talk->shouldReceive('id')->andReturn(1); $talk->shouldReceive('type', 'category', 'created_at'); // Create a test double for profile $profile = m::mock('StdClass'); $profile->shouldReceive('name')->andReturn('Test User'); $profile->shouldReceive('photo', 'company', 'twitter', 'airport', 'bio', 'info', 'transportation', 'hotel'); $profile->shouldReceive('talks')->andReturn([$talk]); $speakerService = m::mock('StdClass'); $speakerService->shouldReceive('findProfile')->andReturn($profile); $app['application.speakers'] = $speakerService; /** Act and assert steps to follow *// } } -
Diffuclt Language Constructs Service Locators Good practice seems to be
think hard about what you're doing and minimize their use -
Difficult Languge Constructs Tightly coupled-dependencies class SimpleExample { function doSomething($data)
{ $foo = new Foo; $bar = new Bar; $result = $foo->mangle($bar->transform($data)); return $result; } } $example = new SimpleExample; $data = [1, 2, 3, 4, 5]; echo $exmple->doSomething($data); -
Difficult Languge Constructs Tightly coupled-dependencies class SimpleExample { public $foo;
public $bar; public function __constuct($foo, $bar) { $this->foo = $foo; $this->bar = $bar; } public function doSomething($data) { $result = $this->foo->mangle($this->bar->transform($data)); return $result; } } $foo = new Foo; $bar = new Bar; $example = new SimpleExample($foo, $bar); $data = [1, 2, 3, 4, 5]; echo $exmple->doSomething($data); -
Difficult Language Constructs Tightly Coupled Dependencies Good practice is to
refactor towards dependency injection -
Difficult Language Constructs Tightly Coupled Dependencies — Service locators —
Dependency injection containers -
Current Grumpy Practices public function youCannotEditSomeoneElsesProfile() { $this->app['sentinel'] = $this->createSentinelWithLoggedInUser();
ob_start(); $this->app->run(); ob_end_clean(); $req = m::mock(Request::class); $req->shouldReceive('get')->with('id')->andReturn(2); $controller = new ProfileController(); $controller->setApp($this->app); $response = $controller->editAction($req); $this->assertContains( 'Redirecting to /dashboard', $response->getContent(), "User was not redirected to dashboard after trying to edit someone else's profile" ); } -
Current Grumpy Practices public function createSentinelWithLoggedInUser() { $sentinel = m::mock(SentinelWrapper::class);
$sentinel->shouldReceive('check')->andReturn(true); $sentinel->shouldReceive('getUser')->andReturn($this->createUser()); return $sentinel; } -
Current Grumpy Practices public function createImageFile() { // Create a
virtual file system and image file vfsStreamWrapper::register(); $root = vfsStream::newDirectory('images'); vfsStreamWrapper::setRoot($root); $root->addChild(vfsStream::newFile('image.jpg')); return new UploadedFile(vfsStream::url('images/image.jpg'), 'image.jpg'); } -
Current Grumpy Practices // Override things so that Spot2 is
using in-memory tables $cfg = new \Spot\Config; $cfg->addConnection('sqlite', [ 'dbname' => 'sqlite::memory', 'driver' => 'pdo_sqlite', ]); $spot = new \Spot\Locator($cfg); unset($this->app['spot']); $this->app['spot'] = $spot; -
Current Grumpy Practices Like a VFS, clean up happens a!er
the tests are run -
Current Grumpy Practices Data Providers /** * @test * @dataProvider
invalidTalkTitles * @expectedException \OpenCFP\Domain\Talk\InvalidTalkSubmissionException * @expectedExceptionMessage title */ public function it_guards_that_title_is_appropriate_length($title) { TalkSubmission::fromNative(['title' => $title]); } -
Current Grumpy Practices Data Providers public function invalidTalkTitles() { return
[ [''], ['String over one-hundred characters long: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc vel placerat nulla. Nunc orci aliquam.'], ]; } -
Current Grumpy Practices Data Providers /** * @test * @dataProvider
specializedResponseExamples */ public function it_has_helpers_to_send_specialized_responses($type, $expectedStatus, $expectedDefaultMessage) { $methodName = "respond{$type}"; $response = $this->sut->$methodName(); $this->assertEquals($expectedStatus, $response->getStatusCode()); $this->assertContains($expectedDefaultMessage, $response->getContent()); } -
Current Grumpy Practices Data Providers public function specializedResponseExamples() { return
[ ['BadRequest', 400, 'Bad request'], ['Unauthorized', 401, 'Unauthorized'], ['Forbidden', 403, 'Forbidden'], ['NotFound', 404, 'Resource not found'], ['InternalError', 500, 'Internal server error'], ]; } -
Current Grumpy Practices Data Providers Extend the provider to include
more data instead of slightly-different tests -
Test-A!er Skills /** * Controller action for viewing a specific
talk * * @param Request $req * @return mixed */ public function viewAction(Request $req) { if (!$this->service('sentinel')->check()) { return $this->redirectTo('login'); } try { $speakers = $this->service('application.speakers'); $id = filter_var($req->get('id'), FILTER_VALIDATE_INT); $talk = $speakers->getTalk($id); } catch (NotAuthorizedException $e) { return $this->redirectTo('dashboard'); } return $this->render('talk/view.twig', compact('id', 'talk')); } -
Test-A!er Skills Testing a non-authorized user trying to view a
talk? if (!$this->service('sentinel')->check()) { return $this->redirectTo('login'); } -
Test-A!er Skills public function viewKicksOutUsersWhoAreNotLoggedIn() { // We need a
Sentinel user who is not logged in unset($this->app['sentinel']); $this->app['sentinel'] = $this->createNotLoggedInUser(); $controller = new TalkController(); $controller->setApplication($this->app); $this->assertContains( 'Redirecting to /login', $controller->viewAction($this->req)->getContent(), 'Non-logged in user can view a talk' ); } -
Test-A!er Skills try { $speakers = $this->service('application.speakers'); $id = filter_var($req->get('id'),
FILTER_VALIDATE_INT); $talk = $speakers->getTalk($id); } catch (NotAuthorizedException $e) { return $this->redirectTo('dashboard'); } return $this->render('talk/view.twig', compact('id', 'talk')); -
Test-A!er Skills public function viewRendersTalkForLoggedInUser() { list($talk, $talk_id) = $this->createTalk();
// Create a double for our speaker object $application_speakers = m::mock('\stdClass'); $application_speakers->shouldReceive('getTalk')->with($talk_id)->andReturn($talk); $this->app['application.speakers'] = $application_speakers; // Tell our request object what the ID of the talk is $this->req->shouldReceive('get')->with('id')->andReturn($talk_id); $controller = new TalkController(); $controller->setApplication($this->app); $this->assertContains( '<!-- id: talk/view -->', $controller->viewAction($this->req)->getContent(), 'TalkController::viewAction did not correctly render view' ); } -
Test-A!er Skills HTML comments make searching for specific output easy
$this->assertContains( '<!-- id: talk/view -->', $controller->viewAction($this->req)->getContent(), 'TalkController::viewAction did not correctly render view' ); -
Test-A!er Skills Sometimes the code needs to change to be
more testable Consider making your application "environment-aware"