Test-driven Development (TDD) is still a subject all developers agree is a great thing, but never get around to actually doing it for many reasons. In this workshop, I use real-world business requirements on legacy code for which we need to fix bugs and add features, but we’re doing it in a TDD way.
1. Let your tests drive your
development
An in2it workshop
in it2PROFESSIONAL PHP SERVICES
2. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Agenda
2
Introduction
TDD from scratch
TDD with legacy app
Additional tips
Recap
3. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Agenda
3
Introduction
TDD from scratch
TDD with legacy app
Additional tips
Recap
4. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
What is test-driven development (TDD)?
Write unit tests first
They will fail
Write functional code in accordance of the tests
Your tests will structure the way you write your code
Re-run your tests again
They should pass
4
5. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
System’s Check
5
6. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Some conventions
PHPUnit was installed using composer
All vendor packages were installed with the code base
Running PHPUnit with ./vendor/bin/phpunit
If you use a different approach, make sure it works for you
GIT is used for the exercises, make sure you know about
checking out branches
reading git logs
In the slides I left out comments to save space
6
7. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Functional requirement
Write a small PHP class with a method that will return the string “Hello
World!”
7
8. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
PHPUnit test
<?php
namespace AppTest;
use PHPUnitFrameworkTestCase;
use AppHelloWorld;
class HelloWorldTest extends TestCase
{
public function testAppOutputsHelloWorld()
{
$helloWorld = new HelloWorld();
$expectedAnswer = $helloWorld->sayHello();
$this->assertSame('Hello World!', $expectedAnswer);
}
}
8
9. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Running unit tests
9
10. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Writing the code
<?php
namespace App;
class HelloWorld
{
public function sayHello(): string
{
return 'Hello World!';
}
}
10
11. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Re-run unit tests
11
12.
13. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Change requests
13
14. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Change request
Update our small PHP class method that will allow an argument and will
return the string “Hello <arg>!” where <arg> is the argument provided.
14
15. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Options
15
16. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Why change the existing test?
Code change so test has to change
New requirements change the test goal
16
17. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Why not change the existing test?
Code requirement change so new test is required
We don’t want to change existing requirements
Prevent BC breaks
New test will cover changing requirements
17
18. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
New test case
public function testAppOutputsHelloArgument()
{
$helloWorld = new HelloWorld();
$expectedAnswer = $helloWorld->sayHello('unit testers');
$this->assertSame('Hello unit testers!', $expectedAnswer);
}
18
19. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Running unit tests
19
20. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Changing the code
<?php
namespace App;
class HelloWorld
{
public function sayHello(string $arg): string
{
return 'Hello ' . $arg . '!';
}
}
20
21. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Re-run the tests
21
22. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
We introduced an error now!
22
23. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Finding the bug
<?php
namespace App;
class HelloWorld
{
public function sayHello(string $arg): string
{
return 'Hello ' . $arg . '!';
}
}
23
No default value
24. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Fixing failure
<?php
namespace App;
class HelloWorld
{
public function sayHello(string $arg = 'World'): string
{
return 'Hello ' . $arg . '!';
}
}
24
25. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Re-run the tests
25
26.
27. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Recap
Write test based on functionality (and run test)
Write code based on functionality (and re-run test)
Write new test based on changed functionality (and re-run tests)
Change code based on functionality (and re-run tests)
Update code until all tests are passing
27
28. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Simple example
Get the source code
Go to the project folder and use the following commands
git checkout simple-example
./vendor/bin/phpunit
All files will be there, so review them closely
28
29. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development29
Exercise
New change requirements
Security is important, so we need to validate the
given argument so it only accepts string type
values. If something other than a string is
provided, an exception should be raised.
10 minutes
30. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Agenda
30
Introduction
TDD from scratch
TDD with legacy app
Additional tips
Recap
31. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Project “TodoToDone”
Project “TodoToDone” is a simple todo tool, tracking the tasks that you need
to do. It should provide the following features:
List open tasks sorted newest to oldest
Create a new task (label and description)
Update an existing task
Mark task as done in the overview list
Remove task marked as done
31
32. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Requirements as skeleton
<?php
namespace AppTestService;
use PHPUnitFrameworkTestCase;
class TaskServiceTest extends TestCase
{
public function testServiceReturnsListOfTasks()
{
// List open tasks sorted newest to oldest
}
public function testServiceCanAddNewTask()
{
// Create a new task (label and description)
}
public function testServiceCanUpdateExistingTask()
{
// Update an existing task
}
public function testServiceCanMarkTaskAsDone()
{
// Mark task as done in the overview list
}
public function testServiceCanRemoveTaskMarkedAsDone()
{
// Remove task marked as done
}
}
32
33. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Don’t start writing test yet!
Unit testing is about looking at a specific task from every angle
Define use and edge cases and add them as additional tests
33
34. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Example edge cases
public function testServiceWillThrowRuntimeExceptionWhenStorageFailsToFetchTaskList()
{
// Throw a runtime exception when connection to storage fails for fetching task list
}
public function testServiceWillThrowInvalidArgumentExceptionWhenInvalidTaskIsAdded()
{
// Throw an invalid argument exception for invalid task when adding
}
public function testServiceWillThrowRuntimeExceptionWhenStorageFails()
{
// Throw a runtime exception when storage of task fails
}
public function testServiceWillThrowDomainExceptionWhenTaskWasMarkedAsDoneWhenMarkingTaskAsDone()
{
// Throw a domain exception when a task was already marked as done
}
34
35. Question
Why am I using very long and explicit method names for
my test methods?
36. Answer
To have human readable documentation about the features we’re developing and testing.
./vendor/bin/phpunit --testdox
37. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
TestDox output
PHPUnit 6.1.3 by Sebastian Bergmann and contributors.
AppTestServiceTaskService
[ ] Service returns list of tasks
[ ] Service can add new task
[ ] Service throws exception if task was not found
[ ] Service can find task
[ ] Service can remove task
[ ] Service can update existing task
[ ] Service can mark task as done
[ ] Service can remove task marked as done
[ ] Service will throw type error when invalid task is added
[ ] Service will throw domain exception when done task gets marked done
37
38. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development38
Exercise
Complete the test cases
git checkout tdd-ex1
Go and check out branch tdd-ex1 where you will
find the code as we’ve seen thus far.
Pro tip: complete 1 test and commit, this way
you also learn to commit small and commit
often.
20 minutes
39. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
How we’re approaching this
We need to “prepare” our test class
Create a “setUp” method to create a fixture
Create a “tearDown” method to unset the fixture
Implement first test “testServiceReturnsListOfTasks”
Making use of fixture to mimic actual behaviour
Create class interfaces for structure
Implement concrete class
39
40. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Preparing our 1st test
40
41. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
class TaskServiceTest extends TestCase
{
protected $taskGateway;
protected function setUp()
{
parent::setUp();
$taskEntity = $this->getMockBuilder(TaskEntityInterface::class)
->setMethods(['getId', 'getLabel', 'getDescription', 'isDone', 'getCreated', 'getModified'])->getMock();
$taskEntry1 = clone $taskEntity;
$taskEntry1->method('getId')->willReturn('123');
$taskEntry1->method('getLabel')->willReturn('Task #123');
$taskEntry1->method('getDescription')->willReturn('#123: This is task 123');
$taskEntry1->method('isDone')->willReturn(false);
$taskEntry1->method('getCreated')->willReturn(new DateTime('2017-03-21 07:53:24'));
$taskEntry1->method('getModified')->willReturn(new DateTime('2017-03-21 08:16:53'));
$taskEntry2 = clone $taskEntity;
$taskEntry3 = clone $taskEntity;
$taskCollection = new SplObjectStorage();
$taskCollection->attach($taskEntry3);
$taskCollection->attach($taskEntry2);
$taskCollection->attach($taskEntry1);
$taskGateway = $this->getMockBuilder(TaskGatewayInterface::class)->setMethods(['fetchAll'])->getMock();
$taskGateway->expects($this->any())->method('fetchAll')->willReturn($taskCollection);
$this->taskGateway = $taskGateway;
}
/* ... */
}
41
42. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
class TaskServiceTest extends TestCase
{
/* ... */
protected function tearDown()
{
unset ($this->taskGateway);
}
/* ... */
}
42
43. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
class TaskServiceTest extends TestCase
{
/* ... */
/**
* List open tasks sorted newest to oldest
*
* @covers TaskService::fetchAll
*/
public function testServiceReturnsListOfTasks()
{
$taskService = new TaskService($this->taskGateway);
$taskList = $taskService->getAllTasks();
$this->assertInstanceOf(Iterator::class, $taskList);
$this->assertGreaterThan(0, count($taskList));
$taskList->rewind();
$previous = null;
while ($taskList->valid()) {
if (null !== $previous) {
$current = $taskList->current();
$this->assertTrue($previous->getCreated() > $current->getCreated());
}
$previous = $taskList->current();
$taskList->next();
}
}
/* ... */
}
43
44. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Creating gateway interface
<?php
namespace AppModel;
interface TaskGatewayInterface
{
/**
* Fetch all tasks from the back-end storage
* @return Iterator
*/
public function fetchAll(): Iterator;
}
44
45. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Creating entity interface
<?php
namespace AppModel;
interface TaskEntityInterface
{
public function getId(): string;
public function getLabel(): string;
public function getDescription(): string;
public function isDone(): bool;
public function getCreated(): DateTime;
public function getModified(): DateTime;
}
45
46. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development46
47. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development47
Exercise
Check out the finished test cases
git checkout tdd-ex2
Go and check out branch tdd-ex2 where you will
find the completed test cases.
Make sure you also check out the GIT logs as I
used 27 commits to explain what was happening
and why!
10 minutes
48. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
protected function setUp()
{
parent::setUp();
// We create a mock object
$taskEntity = $this->getMockBuilder(TaskEntityInterface::class)
->setMethods(['getId', 'getLabel', 'getDescription', 'isDone', 'getCreated', 'getModified', 'setLabel', 'setDone'])
->getMock();
$taskEntry1 = clone $taskEntity;
$taskEntry1->method('getId')->willReturn('123');
$taskEntry1->method('getLabel')->willReturn('Task #123');
$taskEntry1->method('getDescription')->willReturn('#123: This is task 123');
$taskEntry1->method('isDone')->willReturn(false);
$taskEntry1->method('getCreated')->willReturn(new DateTime('2017-03-21 07:53:24'));
$taskEntry1->method('getModified')->willReturn(new DateTime('2017-03-21 08:16:53'));
$taskEntryUpdate = clone $taskEntity;
$taskEntryUpdate->method('getId')->willReturn('123');
$taskEntryUpdate->method('getLabel')->willReturn('Task #123: Update from service');
$taskEntryUpdate->method('getDescription')->willReturn('#123: This is task 123');
$taskEntryUpdate->method('isDone')->willReturn(false);
$taskEntryUpdate->method('getCreated')->willReturn(new DateTime('2017-03-21 07:53:24'));
$taskEntryUpdate->method('getModified')->willReturn(new DateTime('now'));
$taskEntry2 = clone $taskEntity;
$taskEntry2->method('getId')->willReturn('456');
$taskEntry2->method('getLabel')->willReturn('Task #456');
$taskEntry2->method('getDescription')->willReturn('#456: This is task 456');
$taskEntry2->method('isDone')->willReturn(true);
$taskEntry2->method('getCreated')->willReturn(new DateTime('2017-03-22 07:53:24'));
$taskEntry2->method('getModified')->willReturn(new DateTime('2017-03-22 08:16:53'));
$taskEntry3 = clone $taskEntity;
$taskEntry3->method('getId')->willReturn('789');
$taskEntry3->method('getLabel')->willReturn('Task #789');
$taskEntry3->method('getDescription')->willReturn('#789: This is task 789');
$taskEntry3->method('isDone')->willReturn(false);
$taskEntry3->method('getCreated')->willReturn(new DateTime('2017-04-23 07:53:24'));
$taskEntry3->method('getModified')->willReturn(new DateTime('2017-04-23 08:16:53'));
$taskEntryDone = clone $taskEntity;
$taskEntryDone->method('getId')->willReturn('789');
$taskEntryDone->method('getLabel')->willReturn('#789');
$taskEntryDone->method('getDescription')->willReturn('#789: This is task 789');
$taskEntryDone->method('isDone')->willReturn(true);
$taskEntryDone->method('getCreated')->willReturn(new DateTime('2017-04-23 07:53:24'));
$taskEntryDone->method('getModified')->willReturn(new DateTime('now'));
48
49. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Magic happening in setUp
Ideal place to set things up (using fixtures)
Stub is shared among different test methods
Now all is ready to be implemented as we secured the code-base
49
50. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Project status
AppTestServiceTaskService
[x] Service returns list of tasks
[x] Service can add new task
[x] Service throws exception if task was not found
[x] Service can find task
[x] Service can remove task
[x] Service can update existing task
[x] Service can mark task as done
[x] Service can remove task marked as done
[x] Service will throw type error when invalid task is added
[x] Service will throw domain exception when done task gets marked done
50
51. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development51
Exercise
Check out the finished test cases
git checkout tdd-ex3
Go and check out branch tdd-ex3 where you will
find the other completed test cases.
15 minutes
52. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development52
53. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
TextDox output
PHPUnit 6.1.3 by Sebastian Bergmann and contributors.
AppTestModelTaskEntity
[x] Task entity is empty at construction
[x] Task entity throws error when constructed with wrong type
of arguments
[x] Task entity throws exception when constructed with wrong
arguments
[x] Task entity accepts correct arguments
AppTestModelTaskGateway
[x] Fetch all returns iterator object
[x] Gateway can add task entity
[x] Find returns null when nothing found
[x] Find returns task entity when result is found
[x] Gateway can remove task entity
[x] Gateway can update task entity
AppTestServiceTaskService
[x] Service returns list of tasks
[x] Service can add new task
[x] Service throws exception if task was not found
[x] Service can find task
[x] Service can remove task
[x] Service can update existing task
[x] Service can mark task as done
[x] Service can remove task marked as done
[x] Service will throw type error when invalid task is added
[x] Service will throw domain exception when done task gets
marked done
53
54. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development54
Exercise
Check out the web app
git checkout tdd-ex4
php -S localhost:8000 -t web web/index.php
Go and check out branch tdd-ex4 where you will
find the other completed test cases.
Run the web application using PHP’s build-in
web server to see how the app is behaving.
15 minutes
55. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development55
56. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development56
57. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development57
58. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development58
59. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development59
Conclusion
Let’s recap what has happened here
Writing test-first gives you a clean scope of what
your code should do.
You have a more precise code-base that’s easy
to maintain, upgrade and is independent.
60. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Agenda
60
Introduction
TDD from scratch
TDD with legacy app
Additional tips
Recap
61. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Legacy challenges
Not (always) written with testing in mind
Dependencies make it hard to change code
Refactoring is often required before proper testing can start
For refactoring tests are required to ensure the refactored code
behaves the same!
61
62. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Example project: EPESI
62
63. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development63
64. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Tests?!?
64
65. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Tests?!?
64
66. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
How to get started?
65
67. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Getting ready to test!
<?xml version="1.0" encoding="UTF-8" ?>
<phpunit
bootstrap="vendor/autoload.php"
colors="true"
stopOnErrors="true"
stopOnFailures="true">
<testsuites>
<testsuite name="App unit tests">
<directory suffix="php">tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix="php">src</directory>
</whitelist>
</filter>
<logging>
<log type="coverage-html" target="build/coverage" lowUpperBound="35" highLowerBound="70"/>
</logging>
</phpunit>
66
68. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
ModuleManager::module_install
/**
* Includes file with module installation class.
*
* Do not use directly.
*
* @param string $module_class_name module class name - underscore separated
*/
public static final function include_install($module_class_name) {
if(isset(self::$modules_install[$module_class_name])) return true;
$path = self::get_module_dir_path($module_class_name);
$file = self::get_module_file_name($module_class_name);
$full_path = 'modules/' . $path . '/' . $file . 'Install.php';
if (!file_exists($full_path)) return false;
ob_start();
$ret = require_once($full_path);
ob_end_clean();
$x = $module_class_name.'Install';
if(!(class_exists($x, false)) ||
!array_key_exists('ModuleInstall',class_parents($x)))
trigger_error('Module '.$path.': Invalid install file',E_USER_ERROR);
self::$modules_install[$module_class_name] = new $x($module_class_name);
return true;
}
67
69. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Testing happily…
<?php
use PHPUnitFrameworkTestCase;
require_once __DIR__ . '/../src/ModuleManager.php';
class ModuleManagerTest extends TestCase
{
/**
* @covers ModuleManager::include_install
*/
public function testModuleManagerCanLoadMailModule()
{
$result = ModuleManager::include_install('Mail');
$this->assertTrue($result);
}
}
68
70. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development69
71. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Comment out the test
<?php
use PHPUnitFrameworkTestCase;
require_once __DIR__ . '/../src/ModuleManager.php';
class ModuleManagerTest extends TestCase
{
/**
* @covers ModuleManager::include_install
*/
/*public function testModuleManagerCanLoadMailModule()
{
$result = ModuleManager::include_install('Mail');
$this->assertTrue($result);
}*/
}
70
72. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Let’s look again, more closely
/**
* Includes file with module installation class.
*
* Do not use directly.
*
* @param string $module_class_name module class name - underscore separated
*/
public static final function include_install($module_class_name) {
if(isset(self::$modules_install[$module_class_name])) return true;
$path = self::get_module_dir_path($module_class_name);
$file = self::get_module_file_name($module_class_name);
$full_path = 'modules/' . $path . '/' . $file . 'Install.php';
if (!file_exists($full_path)) return false;
ob_start();
$ret = require_once($full_path);
ob_end_clean();
$x = $module_class_name.'Install';
if(!(class_exists($x, false)) ||
!array_key_exists('ModuleInstall',class_parents($x)))
trigger_error('Module '.$path.': Invalid install file',E_USER_ERROR);
self::$modules_install[$module_class_name] = new $x($module_class_name);
return true;
}
71
73. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Let’s look again, more closely
/**
* Includes file with module installation class.
*
* Do not use directly.
*
* @param string $module_class_name module class name - underscore separated
*/
public static final function include_install($module_class_name) {
if(isset(self::$modules_install[$module_class_name])) return true;
$path = self::get_module_dir_path($module_class_name);
$file = self::get_module_file_name($module_class_name);
$full_path = 'modules/' . $path . '/' . $file . 'Install.php';
if (!file_exists($full_path)) return false;
ob_start();
$ret = require_once($full_path);
ob_end_clean();
$x = $module_class_name.'Install';
if(!(class_exists($x, false)) ||
!array_key_exists('ModuleInstall',class_parents($x)))
trigger_error('Module '.$path.': Invalid install file',E_USER_ERROR);
self::$modules_install[$module_class_name] = new $x($module_class_name);
return true;
}
71
74. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Let’s look again, more closely
/**
* Includes file with module installation class.
*
* Do not use directly.
*
* @param string $module_class_name module class name - underscore separated
*/
public static final function include_install($module_class_name) {
if(isset(self::$modules_install[$module_class_name])) return true;
$path = self::get_module_dir_path($module_class_name);
$file = self::get_module_file_name($module_class_name);
$full_path = 'modules/' . $path . '/' . $file . 'Install.php';
if (!file_exists($full_path)) return false;
ob_start();
$ret = require_once($full_path);
ob_end_clean();
$x = $module_class_name.'Install';
if(!(class_exists($x, false)) ||
!array_key_exists('ModuleInstall',class_parents($x)))
trigger_error('Module '.$path.': Invalid install file',E_USER_ERROR);
self::$modules_install[$module_class_name] = new $x($module_class_name);
return true;
}
71
75. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Let’s look again, more closely
/**
* Includes file with module installation class.
*
* Do not use directly.
*
* @param string $module_class_name module class name - underscore separated
*/
public static final function include_install($module_class_name) {
if(isset(self::$modules_install[$module_class_name])) return true;
$path = self::get_module_dir_path($module_class_name);
$file = self::get_module_file_name($module_class_name);
$full_path = 'modules/' . $path . '/' . $file . 'Install.php';
if (!file_exists($full_path)) return false;
ob_start();
$ret = require_once($full_path);
ob_end_clean();
$x = $module_class_name.'Install';
if(!(class_exists($x, false)) ||
!array_key_exists('ModuleInstall',class_parents($x)))
trigger_error('Module '.$path.': Invalid install file',E_USER_ERROR);
self::$modules_install[$module_class_name] = new $x($module_class_name);
return true;
}
71
76. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Let’s look again, more closely
/**
* Includes file with module installation class.
*
* Do not use directly.
*
* @param string $module_class_name module class name - underscore separated
*/
public static final function include_install($module_class_name) {
if(isset(self::$modules_install[$module_class_name])) return true;
$path = self::get_module_dir_path($module_class_name);
$file = self::get_module_file_name($module_class_name);
$full_path = 'modules/' . $path . '/' . $file . 'Install.php';
if (!file_exists($full_path)) return false;
ob_start();
$ret = require_once($full_path);
ob_end_clean();
$x = $module_class_name.'Install';
if(!(class_exists($x, false)) ||
!array_key_exists('ModuleInstall',class_parents($x)))
trigger_error('Module '.$path.': Invalid install file',E_USER_ERROR);
self::$modules_install[$module_class_name] = new $x($module_class_name);
return true;
}
71
78. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Test first condition
/**
* @covers ModuleManager::include_install
*/
public function testReturnImmediatelyWhenModuleAlreadyLoaded()
{
$module = 'Foo_Bar';
ModuleManager::$modules_install[$module] = 1;
$result = ModuleManager::include_install($module);
$this->assertTrue($result);
$this->assertCount(1, ModuleManager::$modules_install);
}
73
79. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development74
80. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development75
81. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development76
https://www.flickr.com/photos/christian_johannesen/2248244786
82. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Test second condition
/**
* @covers ModuleManager::include_install
*/
public function testReturnWhenModuleIsNotFound()
{
$module = 'Foo_Bar';
$result = ModuleManager::include_install($module);
$this->assertFalse($result);
$this->assertEmpty(ModuleManager::$modules_install);
}
77
83. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development78
84. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
/**
* Includes file with module installation class.
*
* Do not use directly.
*
* @param string $module_class_name module class name - underscore separated
*/
public static final function include_install($module_class_name) {
if(isset(self::$modules_install[$module_class_name])) return true;
$path = self::get_module_dir_path($module_class_name);
$file = self::get_module_file_name($module_class_name);
$full_path = 'modules/' . $path . '/' . $file . 'Install.php';
if (!file_exists($full_path)) return false;
ob_start();
$ret = require_once($full_path);
ob_end_clean();
$x = $module_class_name.'Install';
if(!(class_exists($x, false)) ||
!array_key_exists('ModuleInstall',class_parents($x)))
trigger_error('Module '.$path.': Invalid install file',E_USER_ERROR);
self::$modules_install[$module_class_name] = new $x($module_class_name);
return true;
}
79
85. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
/**
* Includes file with module installation class.
*
* Do not use directly.
*
* @param string $module_class_name module class name - underscore separated
*/
public static final function include_install($module_class_name) {
if(isset(self::$modules_install[$module_class_name])) return true;
$path = self::get_module_dir_path($module_class_name);
$file = self::get_module_file_name($module_class_name);
$full_path = 'modules/' . $path . '/' . $file . 'Install.php';
if (!file_exists($full_path)) return false;
ob_start();
$ret = require_once($full_path);
ob_end_clean();
$x = $module_class_name.'Install';
if(!(class_exists($x, false)) ||
!array_key_exists('ModuleInstall',class_parents($x)))
trigger_error('Module '.$path.': Invalid install file',E_USER_ERROR);
self::$modules_install[$module_class_name] = new $x($module_class_name);
return true;
}
79
self::$modules_install[$module_class_name]
86. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Add a “setUp” method
class ModuleManagerTest extends TestCase
{
protected function setUp()
{
ModuleManager::$modules_install = [];
}
/* ... */
}
80
87. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development81
88. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development82
https://www.flickr.com/photos/evaekeblad/14780090550
89. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Testing third condition
/**
* @covers ModuleManager::include_install
* @expectedException Error
*/
public function testTriggerErrorWhenInstallClassDoesNotExists()
{
$module = 'EssClient';
$result = ModuleManager::include_install($module);
$this->fail('Expecting loading module ' . $module . ' would trigger an error');
}
83
90. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development84
91. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
/**
* Includes file with module installation class.
*
* Do not use directly.
*
* @param string $module_class_name module class name - underscore separated
*/
public static final function include_install($module_class_name) {
if(isset(self::$modules_install[$module_class_name])) return true;
$path = self::get_module_dir_path($module_class_name);
$file = self::get_module_file_name($module_class_name);
$full_path = 'modules/' . $path . '/' . $file . 'Install.php';
if (!file_exists($full_path)) return false;
ob_start();
$ret = require_once($full_path);
ob_end_clean();
$x = $module_class_name.'Install';
if(!(class_exists($x, false)) ||
!array_key_exists('ModuleInstall',class_parents($x)))
trigger_error('Module '.$path.': Invalid install file',E_USER_ERROR);
self::$modules_install[$module_class_name] = new $x($module_class_name);
return true;
}
85
92. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
/**
* Includes file with module installation class.
*
* Do not use directly.
*
* @param string $module_class_name module class name - underscore separated
*/
public static final function include_install($module_class_name) {
if(isset(self::$modules_install[$module_class_name])) return true;
$path = self::get_module_dir_path($module_class_name);
$file = self::get_module_file_name($module_class_name);
$full_path = 'modules/' . $path . '/' . $file . 'Install.php';
if (!file_exists($full_path)) return false;
ob_start();
$ret = require_once($full_path);
ob_end_clean();
$x = $module_class_name.'Install';
if(!(class_exists($x, false)) ||
!array_key_exists('ModuleInstall',class_parents($x)))
trigger_error('Module '.$path.': Invalid install file',E_USER_ERROR);
self::$modules_install[$module_class_name] = new $x($module_class_name);
return true;
}
85
if (!file_exists($full_path)) return false;
93. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Our current file structure
|-- ModuleManager.php
`-- modules
|-- EssClient
| `-- EssClient.php
|-- IClient
| `-- IClientInstall.php
`-- Mail
`-- MailInstall.php
86
94. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development87
https://www.flickr.com/photos/sis/2497912343
Dead Code
95. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
So this test…
/**
* @covers ModuleManager::include_install
* @expectedException Error
*/
public function testTriggerErrorWhenInstallClassDoesNotExists()
{
$module = 'EssClient';
$result = ModuleManager::include_install($module);
$this->fail('Expecting loading module ' . $module . ' would trigger an error');
}
88
96. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
… changes into this test
/**
* @covers ModuleManager::include_install
*/
public function testTriggerErrorWhenInstallClassDoesNotExists()
{
$module = 'EssClient';
$result = ModuleManager::include_install($module);
$this->assertFalse($result);
}
89
97. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development90
https://www.flickr.com/photos/fragiletender/5332586299
98. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Our current file structure
|-- ModuleManager.php
`-- modules
|-- EssClient
| `-- EssClient.php
|-- IClient
| `-- IClientInstall.php
`-- Mail
`-- MailInstall.php
91
99. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Testing fourth condition
/**
* @covers ModuleManager::include_install
* @expectedException Error
*/
public function testTriggerErrorWhenInstallClassIsNotRegistered()
{
$module = 'IClient';
$result = ModuleManager::include_install($module);
$this->fail('Expecting loading module ' . $module . ' would trigger an error');
}
92
100. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development93
101. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development94
102. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Completing all tests
95
103. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Remove comment tags
/**
* @covers ModuleManager::include_install
*/
public function testModuleManagerCanLoadMailModule()
{
$result = ModuleManager::include_install('Mail');
$this->assertTrue($result);
}
96
104. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development97
105. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development98
106. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Recap
Testing legacy code is not easy, but still possible
Approach the source-code with a bail-first approach
Make sure you can “bail” the method as fast as possible
Start with the most important part of your code
Ask yourself “What costs us money if it breaks” ➡ test that first!
99
107. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development100
Exercise
Check out these tests
git checkout legacy-0.1
Go and check out branch legacy-0.1 and
analyse the tests.
If you have XDebug installed, you can run
PHPUnit with code coverage.
15 minutes
109. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
/**
* Process Bank Payment files
*/
public function processBankPayments()
{
$this->getLogger()->log('Starting bank payment process', Zend_Log::INFO);
foreach ($this->_getBankFiles() as $bankFile) {
$bankData = $this->_processBankFile($bankFile);
$this->getLogger()->log('Processing ' . $bankData->transactionId, Zend_Log::DEBUG);
/** @var Contact_Model_Contact $contact */
$contact = $this->getMapper('Contact_Model_Mapper_Contact')
->findContactByBankAccount($bankData->transactionAccount);
if (null !== $contact) {
$this->getLogger()->log(sprintf('Found contact "%s" for bank account %s',
$contact->getName(),$bankData->transactionAccount
), Zend_Log::DEBUG);
$data = array (
'amount' => $bankData->transactionAmount,
'payment_date' => $bankData->transactionDate
);
$this->getMapper('Invoice_Model_Mapper_Payments')
->updatePayment($data, array ('contact_id = ?' => $contact->getContactId()));
$this->_moveBankFile($bankFile,
$this->getPath() . DIRECTORY_SEPARATOR . self::PROCESS_SUCCEEDED
);
} else {
$this->getLogger()->log(sprintf(
'Could not match bankaccount "%s" with a contact',
$bankData->transactionAccount
), Zend_Log::WARN);
$this->_moveBankFile($bankFile,
$this->getPath() . DIRECTORY_SEPARATOR . self::PROCESS_FAILED
);
}
}
}
102
110. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development103
111. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
class Payments_Service_As400Test extends PHPUnitFrameworkTestCase
{
public function testProcessingBankPayments()
{
$contact = $this->getMockBuilder(Contact_Model_Contact::class)
->setMethods(['getContactId', 'getName'])
->getMock();
$contact->expects($this->any())
->method('getContactId')
->will($this->returnValue(1));
$contact->expects($this->any())
->method('getName')
->will($this->returnValue('Foo Bar'));
$contactMapper = $this->getMockBuilder(Contact_Model_Mapper_Contact::class)
->setMethods(['findContactByBankAccount'])
->getMock();
$contactMapper->expects($this->any())
->method('findContactByBankAccount')
->will($this->returnValue($contact));
$paymentsMapper = $this->getMockBuilder(Invoice_Model_Mapper_Payments::class)
->setMethods(['updatePayment'])
->getMock();
$logMock = new Zend_Log_Writer_Mock();
$logger = new Zend_Log();
$logger->setWriter($logMock);
$logger->setPriority(Zend_Log::DEBUG);
$as400 = new Payments_Service_As400();
$as400->addMapper($contactMapper, Contact_Model_Mapper_Contact::class)
->addMapper($paymentsMapper, Invoice_Model_Mapper_Payments::class)
->setPath(__DIR__ . DIRECTORY_SEPARATOR . '_files')
->setLogger($logger);
$as400->processBankPayments();
$this->assertCount(3, $logMock->events);
$this->assertEquals('Processing 401341345', $logMock->events[1]);
$this->assertEquals(
'Found contact "Foo Bar" for bank account BE93522511513933',
$logMock->events[2]
);
}
}
104
112. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Let’s focus on mocks first
$contact = $this->getMockBuilder(Contact_Model_Contact::class)
->setMethods(['getContactId', 'getName'])
->getMock();
$contact->expects($this->any())
->method('getContactId')
->will($this->returnValue(1));
$contact->expects($this->any())
->method('getName')
->will($this->returnValue('Foo Bar'));
$contactMapper = $this->getMockBuilder(Contact_Model_Mapper_Contact::class)
->setMethods(['findContactByBankAccount'])
->getMock();
$contactMapper->expects($this->any())
->method('findContactByBankAccount')
->will($this->returnValue($contact));
$paymentsMapper = $this->getMockBuilder(Invoice_Model_Mapper_Payments::class)
->setMethods(['updatePayment'])
->getMock();
105
113. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Write the log mock
$logMock = new Zend_Log_Writer_Mock();
$logger = new Zend_Log();
$logger->setWriter($logMock);
$logger->setPriority(Zend_Log::DEBUG);
$as400 = new Payments_Service_As400();
$as400->addMapper($contactMapper, Contact_Model_Mapper_Contact::class)
->addMapper($paymentsMapper, Invoice_Model_Mapper_Payments::class)
->setPath(__DIR__ . DIRECTORY_SEPARATOR . '_files')
->setLogger($logger);
$as400->processBankPayments();
$this->assertCount(3, $logMock->events);
$this->assertEquals('Processing 401341345', $logMock->events[1]);
$this->assertEquals(
'Found contact "Foo Bar" for bank account BE93522511513933',
$logMock->events[2]
);
106
114. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development107
115. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development108
Exercise
Check out the web app
git checkout legacy-0.2
Go and check out branch legacy-0.2 where you
will find the example test case.
10 minutes
116. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Privates Exposed
109
http://www.slashgear.com/former-tsa-agent-admits-we-knew-full-body-scanners-didnt-work-31315288/
117. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Direct access forbidden?!?
<?php
defined("_VALID_ACCESS") || die('Direct access forbidden');
/**
* This class provides dependency requirements
* @package epesi-base
* @subpackage module
*/
class Dependency {
private $module_name;
private $version_min;
private $version_max;
private $compare_max;
private function __construct(
$module_name, $version_min, $version_max, $version_max_is_ok = true) {
$this->module_name = $module_name;
$this->version_min = $version_min;
$this->version_max = $version_max;
$this->compare_max = $version_max_is_ok ? '<=' : '<';
}
/** ... */
}
110
118. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Don’t touch my junk!
111
https://www.flickr.com/photos/caseymultimedia/5412293730
119. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
House of Reflection
https://www.flickr.com/photos/tabor-roeder/8250770115
120. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Let’s do this…
<?php
require_once 'include.php';
class DependencyTest extends PHPUnit_Framework_TestCase
{
public function testConstructorSetsProperSettings()
{
require_once 'include/module_dependency.php';
// We have a problem, the constructor is private!
}
}
113
121. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Using static method works too
$params = array (
'moduleName' => 'Foo_Bar',
'minVersion' => 0,
'maxVersion' => 1,
'maxOk' => true,
);
// We use a static method for this test
$dependency = Dependency::requires_range(
$params['moduleName'],
$params['minVersion'],
$params['maxVersion'],
$params['maxOk']
);
// We use reflection to see if properties are set correctly
$reflectionClass = new ReflectionClass('Dependency');
114
122. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Asserting private properties
// Let's retrieve the private properties
$moduleName = $reflectionClass->getProperty('module_name');
$moduleName->setAccessible(true);
$minVersion = $reflectionClass->getProperty('version_min');
$minVersion->setAccessible(true);
$maxVersion = $reflectionClass->getProperty('version_max');
$maxVersion->setAccessible(true);
$maxOk = $reflectionClass->getProperty('compare_max');
$maxOk->setAccessible(true);
// Let's assert
$this->assertEquals($params['moduleName'], $moduleName->getValue($dependency),
'Expected value does not match the value set’);
$this->assertEquals($params['minVersion'], $minVersion->getValue($dependency),
'Expected value does not match the value set’);
$this->assertEquals($params['maxVersion'], $maxVersion->getValue($dependency),
'Expected value does not match the value set’);
$this->assertEquals('<=', $maxOk->getValue($dependency),
'Expected value does not match the value set');
115
123. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development116
124. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Agenda
117
Introduction
TDD from scratch
TDD with legacy app
Additional tips
Recap
125. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
PHPUnit requires basic PHP!
Sometimes the challenge lies within PHP instead of direct PHPUnit
Testing is simple, coding is hard!
Testing is all about asserting that an actual process matches an expected result,
so make sure you cover your expectations and test against those expectations
PHP functionality you need to know:
Reflection
Streams
System and PHP executions (e.g. “eval”, “passthru”, …)
118
126. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
If you don’t know the destination…
Start testing with what you know
Work your way up
119
127. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
“But my code is too crappy…”
120
128. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
For “untestable” code
Write out the functionality in tests
Create a class providing this functionality (service, model, …)
Slowly move your existing code over to use the “cleaner” code
Bonus: you’ve got it already tested
121
129. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Agenda
122
Introduction
TDD from scratch
TDD with legacy app
Additional tips
Recap
136. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development
Thank you
129
https://www.flickr.com/photos/drewm/3191872515
137. in2it training - www.in2it.be - @in2itvof - #in2tdd Let your tests drive your development130
in it2PROFESSIONAL PHP SERVICES
Michelangelo van Dam
Zend Certified Engineer
contact@in2it.be - www.in2it.be - T in2itvof - F in2itvof
Quality
Assurance
Zend Framework 3
Consulting
Disaster
Recovery
Development
Workflow
Enterprise
PHP
Training
Mentoring
Our expertise services