Slide 1

Slide 1 text

@mheap #phpworld for characterization tests

Slide 2

Slide 2 text

@mheap #phpworld Imagine this…

Slide 3

Slide 3 text

@mheap #phpworld $$$

Slide 4

Slide 4 text

@mheap #phpworld $$$ $$$ $$$

Slide 5

Slide 5 text

@mheap #phpworld $$$ Billing Rating Auth Report Masking Encoding $$$ $$$

Slide 6

Slide 6 text

@mheap #phpworld $$$ Billing Rating Auth Report Masking Encoding $$$ $$$

Slide 7

Slide 7 text

@mheap #phpworld $$$ $$$ $$$ Billing Rating Auth Report Masking Encoding public function provides() { return [Client ::class]; }

Slide 8

Slide 8 text

@mheap #phpworld

Slide 9

Slide 9 text

@mheap #phpworld $ ls -l test total 0

Slide 10

Slide 10 text

@mheap https://twitter.com/brianwisti/status/503987766032494592 YOU ARE IN A LEGACY CODEBASE > RUN TESTS YOU HAVE NO TESTS > READ SPEC YOU HAVE NO SPEC > WRITE FIX YOU ARE EATEN BY AN ELDER CODE HACK.

Slide 11

Slide 11 text

@mheap #phpworld Imagine

Slide 12

Slide 12 text

@mheap #phpworld Hello, I’m Michael

Slide 13

Slide 13 text

@mheap #phpworld @mheap

Slide 14

Slide 14 text

@mheap #phpworld

Slide 15

Slide 15 text

No content

Slide 16

Slide 16 text

@mheap #phpworld Today

Slide 17

Slide 17 text

@mheap #phpworld What is legacy code?

Slide 18

Slide 18 text

@mheap Michael Feathers - Working Effectively with Legacy Code “code without tests” Legacy Code is

Slide 19

Slide 19 text

@mheap Legacy Code is “valuable code that we feel afraid to change” JB Rainsberger - Surviving Legacy Code with Golden Master and Sampling

Slide 20

Slide 20 text

@mheap #phpworld What if I make a change and break something?

Slide 21

Slide 21 text

@mheap #phpworld What if I make a change and change something?

Slide 22

Slide 22 text

@mheap #phpworld Intended or Unintended

Slide 23

Slide 23 text

@mheap #phpworld Intended or Unintended

Slide 24

Slide 24 text

@mheap #phpworld Intended or Unintended

Slide 25

Slide 25 text

@mheap #phpworld But there aren’t any tests!

Slide 26

Slide 26 text

@mheap #phpworld class SalesUtil { const BQ = 1000.0; const BCR = 0.20; const OQM1 = 1.5; const OQM2 = self ::OQM1 * 2; static public function calculate($tSales) { if ($tSales <= static ::BQ) { return $tSales * static ::BCR; } else if ($tSales <= static ::BQ*2) { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1; } else { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1 + ($tSales - static ::BQ * 2) * static ::BCR * static ::OQM2; } } }

Slide 27

Slide 27 text

@mheap #phpworld class SalesUtil { const BQ = 1000.0; const BCR = 0.20; const OQM1 = 1.5; const OQM2 = self ::OQM1 * 2; static public function calculate($tSales) { if ($tSales <= static ::BQ) { return $tSales * static ::BCR; } else if ($tSales <= static ::BQ*2) { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1; } else { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1 + ($tSales - static ::BQ * 2) * static ::BCR * static ::OQM2; } } }

Slide 28

Slide 28 text

@mheap #phpworld class SalesUtil { const BQ = 1000.0; const BCR = 0.20; const OQM1 = 1.5; const OQM2 = self ::OQM1 * 2; static public function calculate($tSales) { if ($tSales <= static ::BQ) { return $tSales * static ::BCR; } else if ($tSales <= static ::BQ*2) { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1; } else { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1 + ($tSales - static ::BQ * 2) * static ::BCR * static ::OQM2; } } }

Slide 29

Slide 29 text

@mheap #phpworld class SalesUtil { const BQ = 1000.0; const BCR = 0.20; const OQM1 = 1.5; const OQM2 = self ::OQM1 * 2; static public function calculate($tSales) { if ($tSales <= static ::BQ) { return $tSales * static ::BCR; } else if ($tSales <= static ::BQ*2) { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1; } else { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1 + ($tSales - static ::BQ * 2) * static ::BCR * static ::OQM2; } } }

Slide 30

Slide 30 text

@mheap #phpworld use PHPUnit\Framework\TestCase; class SalesUtilTest extends TestCase { public function testX() { $this ->assertEquals(null, SalesUtil ::calculate(1000)); } }

Slide 31

Slide 31 text

@mheap #phpworld use PHPUnit\Framework\TestCase; class SalesUtilTest extends TestCase { public function testX() { $this ->assertEquals(null, SalesUtil ::calculate(999)); } }

Slide 32

Slide 32 text

@mheap #phpworld use PHPUnit\Framework\TestCase; class SalesUtilTest extends TestCase { public function testX() { $this ->assertEquals(null, SalesUtil ::calculate(1000)); } }

Slide 33

Slide 33 text

@mheap #phpworld There was 1 failure: 1) SalesUtilTest ::testX Failed asserting that 200 matches expected null. /Users/michael/development/oss/characterisation-tests-examples/sales-util/test/SalesUtilTest.php:8 FAILURES! Tests: 1, Assertions: 1, Failures: 1.

Slide 34

Slide 34 text

@mheap #phpworld use PHPUnit\Framework\TestCase; class SalesUtilTest extends TestCase { public function testSalesLTE1000Pay20PercentCommission() { $this ->assertEquals(200, SalesUtil ::calculate(1000)); } }

Slide 35

Slide 35 text

@mheap #phpworld class SalesUtil { const BQ = 1000.0; const BCR = 0.20; const OQM1 = 1.5; const OQM2 = self ::OQM1 * 2; static public function calculate($tSales) { if ($tSales <= static ::BQ) { return $tSales * static ::BCR; } else if ($tSales <= static ::BQ*2) { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1; } else { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1 + ($tSales - static ::BQ * 2) * static ::BCR * static ::OQM2; } } }

Slide 36

Slide 36 text

@mheap #phpworld There was 1 failure: 1) SalesUtilTest ::testX Failed asserting that 500.0 matches expected null. /Users/michael/development/oss/characterisation-tests-examples/sales-util/test/SalesUtilTest.php:13 FAILURES! Tests: 2, Assertions: 2, Failures: 1.

Slide 37

Slide 37 text

@mheap #phpworld public function test2000Gets500Commission() { $this ->assertEquals(500, SalesUtil ::calculate(2000)); } public function testX() { $this ->assertEquals(null, SalesUtil ::calculate(1600)); }

Slide 38

Slide 38 text

@mheap #phpworld There was 1 failure: 1) SalesUtilTest ::testX Failed asserting that 380.0 matches expected null. /Users/michael/development/oss/characterisation-tests-examples/sales-util/test/SalesUtilTest.php:19 FAILURES! Tests: 3, Assertions: 3, Failures: 1.

Slide 39

Slide 39 text

@mheap #phpworld class SalesUtil { const BQ = 1000.0; const BCR = 0.20; const OQM1 = 1.5; const OQM2 = self ::OQM1 * 2; static public function calculate($tSales) { if ($tSales <= static ::BQ) { return $tSales * static ::BCR; } else if ($tSales <= static ::BQ*2) { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1; } else { return static ::BQ * static ::BCR + ($tSales - static ::BQ) * static ::BCR * static ::OQM1 + ($tSales - static ::BQ * 2) * static ::BCR * static ::OQM2; } } }

Slide 40

Slide 40 text

@mheap #phpworld Input Expected 0 0 10 2 500 100 1000 200 1001 200.3 1500 350 1982 494.6 1999.9 499.97 2000 500 2001 500.9 5000 3200 7890 5801 10000 7700

Slide 41

Slide 41 text

@mheap #phpworld public function testX() { $this ->assertEquals(null, SalesUtil ::calculate(-123)); }

Slide 42

Slide 42 text

@mheap #phpworld public function testNegativeSalesGivesCommissionIs() { $this ->assertEquals(-24.6, SalesUtil ::calculate(-123)); }

Slide 43

Slide 43 text

@mheap #phpworld Do not change the code

Slide 44

Slide 44 text

@mheap #phpworld Do not change the code

Slide 45

Slide 45 text

@mheap #phpworld Do NOT change the code

Slide 46

Slide 46 text

@mheap https://en.wikipedia.org/wiki/Equivalence_partitioning What’s the minimum number of tests?

Slide 47

Slide 47 text

@mheap #phpworld We’re half way! (and we’ve barely mentioned Behat)

Slide 48

Slide 48 text

@mheap #phpworld Choosing your pinch points

Slide 49

Slide 49 text

@mheap #phpworld Choose HTTP

Slide 50

Slide 50 text

@mheap #phpworld Choose Behat

Slide 51

Slide 51 text

@mheap #phpworld Given I have 1 "Red Bucket" in my basket And I have 2 "Large Spades" in my basket When I add 1 "Red Bucket" in my basket Then I should see 2 "Red Buckets" in my basket And I should see 2 "Large Spades" in my basket

Slide 52

Slide 52 text

@mheap #phpworld Given I have 1 "Red Bucket" in my basket And I have 2 "Large Spades" in my basket When I add 1 "Red Bucket" in my basket Then I should see 2 "Red Buckets" in my basket And I should see 2 "Large Spades" in my basket

Slide 53

Slide 53 text

@mheap #phpworld Given I have 1 "Red Bucket" in my basket And I have 2 "Large Spades" in my basket When I add 1 "Red Bucket" in my basket Then I should see 2 "Red Buckets" in my basket And I should see 2 "Large Spades" in my basket

Slide 54

Slide 54 text

@mheap #phpworld Given I have 1 "Red Bucket" in my basket And I have 2 "Large Spades" in my basket When I add 1 "Red Bucket" in my basket Then I should see 2 "Red Buckets" in my basket And I should see 2 "Large Spades" in my basket

Slide 55

Slide 55 text

@mheap #phpworld Given I have 1 "Red Bucket" in my basket And I have 2 "Large Spades" in my basket When I add 1 "Red Bucket" in my basket Then I should see 2 "Red Buckets" in my basket And I should see 2 "Large Spades" in my basket

Slide 56

Slide 56 text

@mheap #phpworld Be pragmatic

Slide 57

Slide 57 text

@mheap #phpworld composer require behat/behat --dev vendor/bin/behat --init

Slide 58

Slide 58 text

@mheap #phpworld Testing a HTTP API

Slide 59

Slide 59 text

@mheap #phpworld $username = $_GET['username'] ?? error('username is required'); $key = $_GET['key'] ?? error('key is required'); $validUsers = [ "michael" => "kangar00s", "oscar" => "phparch17" ]; $validKey = $validUsers[strtolower($username)] ?? error('could not find user', 404); if ($key !== $validKey){ error('invalid apikey'); } output([ 'success' => 'true', 'user' => ['name' => $username] ]);

Slide 60

Slide 60 text

@mheap #phpworld $username = $_GET['username'] ?? error('username is required'); $key = $_GET['key'] ?? error('key is required'); $validUsers = [ "michael" => "kangar00s", "oscar" => "phparch17" ]; $validKey = $validUsers[strtolower($username)] ?? error('could not find user', 404); if ($key !== $validKey){ error('invalid apikey'); } output([ 'success' => 'true', 'user' => ['name' => $username] ]);

Slide 61

Slide 61 text

@mheap #phpworld $username = $_GET['username'] ?? error('username is required'); $key = $_GET['key'] ?? error('key is required'); $validUsers = [ "michael" => "kangar00s", "oscar" => "phparch17" ]; $validKey = $validUsers[strtolower($username)] ?? error('could not find user', 404); if ($key !== $validKey){ error('invalid apikey'); } output([ 'success' => 'true', 'user' => ['name' => $username] ]);

Slide 62

Slide 62 text

@mheap #phpworld error('username is required'); error('key is required'); error('could not find user', 404); error('invalid apikey'); output([ 'success' => 'true', 'user' => ['name' => $username] ]); 1. Missing username 2. Missing key 3. Invalid username 4. Invalid key 5. Successful authentication

Slide 63

Slide 63 text

@mheap #phpworld Scenario: No Username When I make a "GET" request to "/" Then the response status code should be "400" And the "error" property equals "username is required"

Slide 64

Slide 64 text

@mheap #phpworld Scenario: No API Key When I make a "GET" request to "/?username=foo" Then the response status code should be "400" And the "error" property equals "key is required”

Slide 65

Slide 65 text

@mheap #phpworld Scenario: Invalid Username When I make a "GET" request to "/?username=bananas&key=foo" Then the response status code should be "404" And the "error" property equals "could not find user” Scenario: Invalid API Key When I make a "GET" request to "/?username=michael&key=foo" Then the response status code should be "400" And the "error" property equals "invalid apikey"

Slide 66

Slide 66 text

@mheap #phpworld Scenario: Successful login When I make a "GET" request to "/?username=michael&key=kangar00s" Then the response status code should be "200" And the "success" property equals "true" And the "user.name" property equals "michael”

Slide 67

Slide 67 text

@mheap #phpworld Scenario Outline: Successful login When I make a "GET" request to "/?username=&key=" Then the response status code should be "200" And the "success" property equals "true" And the "user.name" property equals "" Examples: | name | key | | michael | kangar00s | | oscar | phparch16 |

Slide 68

Slide 68 text

@mheap #phpworld More complex tests

Slide 69

Slide 69 text

@mheap #phpworld Given that header property "X-CustomAuthKey" is "Secret123" And that header property "Accept" is "application/json+vnd.v2" And that the request body is valid JSON ''' { "alpha":"beta", "count":3, "collection":["a","b","c"] } ''' When I make a "POST" request to "/account/balance" Then the response status code should be "200" And the "X-Remaining" header property equals "18" And the "balance.usd" property equals"1832.54" And the "balance.gbp" property equals "13.99" And the "balance.eur" property equals "19422.18"

Slide 70

Slide 70 text

@mheap #phpworld When I make a "POST" request to "/account/balance" Then the response status code should be “200" And the "X-Remaining" header property equals "18" And the response body contains the JSON data ''' { "in_credit": true, "balance": { "usd": 1832.54, "gbp": 13.99, "eur": 19422.18 } } '''

Slide 71

Slide 71 text

@mheap #phpworld When I make a "POST" request to "/account/balance" Then the response status code should be "200" And the "X-Remaining" header property equals "18" And the value of the "balance.usd" property matches the pattern “/^\d{1,3}\. \d{2}$/" And the value of the “last_updated” property matches the pattern "/^[0-9]{4} [\-][0-9]{2}[\-][0-9]{2} [0-9]{2}[:][0-9]{2}[:][0-9]{2}$/"

Slide 72

Slide 72 text

@mheap #phpworld composer require datasift/testrest-extension

Slide 73

Slide 73 text

@mheap #phpworld # behat.yml default: extensions: DataSift\BehatExtension: base_url: "http: //localhost:8080/" suites: default: contexts: - 'DataSift\BehatExtension\Context\RestContext'

Slide 74

Slide 74 text

@mheap #phpworld HTTP Mocks

Slide 75

Slide 75 text

@mheap #phpworld function get_balance($username) { return $client ->get( “http: //localhost:88/billing/check_balance/".$username ) ->getBody(); } $balance = get_balance($username); if ($username != 'michael') { error('wrong username'); } if (!$balance['has_credit']) { error('no credit remaining'); } success(['data' => 'valid account'])

Slide 76

Slide 76 text

@mheap #phpworld Mountebank

Slide 77

Slide 77 text

@mheap #phpworld npm install -g mountebank

Slide 78

Slide 78 text

@mheap #phpworld Given Mountebank is running And a mock exists at "/billing/check_balance" it should return "200" with the body: ''' { "has_credit": false, "credit_limit": 0 } ''' And the mocks are created When I make a "GET" request to "/auth?username=michael" Then the response status code should be "403" And the response is JSON And the response body JSON equals ''' { "error": "no credit remaining" } '''

Slide 79

Slide 79 text

@mheap #phpworld Given Mountebank is running And a mock exists at "/billing/check_balance" it should return "200" with the body: ''' { "has_credit": true, "credit_limit": 10000 } ''' And the mocks are created When I make a "GET" request to "/auth?username=michael" Then the response status code should be "200" And the response is JSON And the response body JSON equals ''' { "data": "valid account" } '''

Slide 80

Slide 80 text

@mheap #phpworld Automating a browser

Slide 81

Slide 81 text

@mheap #phpworld $ composer require --dev behat/mink-extension $ composer require --dev behat/mink-selenium2-driver $ java -Dwebdriver.chrome.driver=chromedriver -jar selenium- server-standalone-3.7.1.jar

Slide 82

Slide 82 text

@mheap #phpworld # behat.yml default: extensions: Behat\MinkExtension: base_url: 'https: //wikipedia.org' sessions: default: selenium2: browser: "chrome" suites: my_suite: contexts: - \Behat\MinkExtension\Context\MinkContext

Slide 83

Slide 83 text

@mheap #phpworld Scenario: Searching for a page that does exist Given I am on "/wiki/Main_Page" When I fill in "search" with "Behavior Driven Development" And I press "searchButton" Then I should see "agile software development"

Slide 84

Slide 84 text

@mheap #phpworld Custom Steps

Slide 85

Slide 85 text

@mheap #phpworld Scenario: Searching for a page that does exist Given I am on "/wiki/Main_Page" When I fill in "search" with "Behavior Driven Development" And I press "searchButton" Then I should see "agile software development"

Slide 86

Slide 86 text

@mheap #phpworld Scenario: Searching for a page that does exist Given I am on "/wiki/Main_Page" When I search for "Behavior Driven Development" Then I should see "agile software development"

Slide 87

Slide 87 text

@mheap #phpworld Scenario: Searching for a page that does exist Given I am on "/wiki/Main_Page" When I search for "Behavior Driven Development" Then I should see "agile software development" 1 scenario (1 undefined) 3 steps (1 passed, 1 undefined, 1 skipped) 0m3.63s (10.62Mb) >> my_suite suite has undefined steps. Please choose the context to generate snippets: [0] None [1] FeatureContext [2] Behat\MinkExtension\Context\MinkContext

Slide 88

Slide 88 text

@mheap #phpworld [0] None [1] FeatureContext [2] Behat\MinkExtension\Context\MinkContext > 1 --- FeatureContext has missing steps. Define them with these snippets: /** * @When I search for :arg1 */ public function iSearchFor($arg1) { throw new PendingException(); }

Slide 89

Slide 89 text

@mheap #phpworld ./vendor/bin/behat --append-snippets

Slide 90

Slide 90 text

@mheap #phpworld [0] None [1] FeatureContext [2] Behat\MinkExtension\Context\MinkContext > 1 u features/bootstrap/FeatureContext.php - `I search for "Behavior Driven Development"` definition added

Slide 91

Slide 91 text

@mheap #phpworld Feature: Test Scenario: Searching for a page that does exist Given I am on "/wiki/Main_Page" When I search for "Behavior Driven Development" TODO: write pending definition Then I should see "agile software development" 1 scenario (1 pending) 3 steps (1 passed, 1 pending, 1 skipped) 0m2.23s (10.61Mb)

Slide 92

Slide 92 text

@mheap #phpworld /** @BeforeScenario */ public function gatherContexts( Behat\Behat\Hook\Scope\BeforeScenarioScope $scope ) { $environment = $scope ->getEnvironment(); $this ->minkContext = $environment ->getContext( 'Behat\MinkExtension\Context\MinkContext' ); }

Slide 93

Slide 93 text

@mheap #phpworld /** * Fills in form field with specified id|name|label|value * Example: When I fill in "username" with: "bwayne" * Example: And I fill in "bwayne" for "username" * * @When /^( ?:|I )fill in "(?P( ?:[^"]| \\")*)" with "(?P( ?:[^"]| \\")*)"$/ * @When /^( ?:|I )fill in "(?P( ?:[^"]| \\")*)" with:$/ * @When /^( ?:|I )fill in "(?P( ?:[^"]| \\")*)" for "(?P( ?:[^"]| \\")*)"$/ */ public function fillField($field, $value) { $field = $this ->fixStepArgument($field); $value = $this ->fixStepArgument($value); $this ->getSession() ->getPage() ->fillField($field, $value); }

Slide 94

Slide 94 text

@mheap #phpworld /** * Presses button with specified id|name|title|alt|value * Example: When I press "Log In" * Example: And I press "Log In" * @When /^( ?:|I )press "(?P( ?:[^"]| \\")*)"$/ */ public function pressButton($button) { $button = $this ->fixStepArgument($button); $this ->getSession() ->getPage() ->pressButton($button); }

Slide 95

Slide 95 text

@mheap #phpworld /** * @When I search for :term */ public function iSearchFor($term) { $this ->minkContext ->fillField('search', $term); $this ->minkContext ->pressButton('searchButton'); }

Slide 96

Slide 96 text

@mheap #phpworld $ ./vendor/bin/behat Feature: Test Scenario: Searching for a page that does exist Given I am on "/wiki/Main_Page" When I search for "Behavior Driven Development" Then I should see "agile software development" 1 scenario (1 passed) 3 steps (3 passed)

Slide 97

Slide 97 text

@mheap #phpworld Scenario: Successful login When I make a "GET" request to "/?username=michael&key=kangar00s" Then the response status code should be "200" And the "success" property equals "true" And the "user.name" property equals "michael”

Slide 98

Slide 98 text

@mheap #phpworld Scenario: Successful login When I log in as "Michael" with the password "kangar00s" Then the response status code should be "200" And the "success" property equals "true" And the "user.name" property equals "michael”

Slide 99

Slide 99 text

@mheap #phpworld Snapshot testing

Slide 100

Slide 100 text

@mheap #phpworld public function toJson(){ return json_encode(["id" => $this ->id]); } —— public function test_json_formatting_is_correct() { $order = new Order(1); $this ->assertMatchesSnapshot($order ->toJson()); }

Slide 101

Slide 101 text

@mheap #phpworld $ ./vendor/bin/phpunit There was 1 incomplete test: 1) ExampleTest ::test_json_formatting_is_correct Snapshot created for ExampleTest __test_json_formatting_is_correct

Slide 102

Slide 102 text

@mheap #phpworld $ ./vendor/bin/phpunit OK (1 test, 1 assertion)

Slide 103

Slide 103 text

@mheap #phpworld public function toJson(){ return json_encode(["id" => 'Q'.$this ->id]); }

Slide 104

Slide 104 text

@mheap #phpworld $ ./vendor/bin/phpunit 1) ExampleTest ::test_json_formatting_is_correct Failed asserting that two strings are equal. --- Expected +++ Actual @@ @@ -'{"id": 1}' +'{"id": "Q1"}' FAILURES! Tests: 1, Assertions: 1, Failures: 1.

Slide 105

Slide 105 text

@mheap #phpworld https: //github.com/spatie/phpunit-snapshot-assertions

Slide 106

Slide 106 text

@mheap #phpworld Conclusion

Slide 107

Slide 107 text

@mheap #phpworld We don’t have time for this

Slide 108

Slide 108 text

@mheap #phpworld Where should we start?

Slide 109

Slide 109 text

@mheap #phpworld Start refactoring

Slide 110

Slide 110 text

@mheap #phpworld Characterisation tests aren’t perfect

Slide 111

Slide 111 text

@mheap #phpworld The 4 stages of characterisation tests

Slide 112

Slide 112 text

@mheap #phpworld Examine your feature

Slide 113

Slide 113 text

@mheap #phpworld Characterize your feature

Slide 114

Slide 114 text

@mheap #phpworld Refactor your feature

Slide 115

Slide 115 text

@mheap #phpworld Delete your tests

Slide 116

Slide 116 text

@mheap #phpworld for characterization tests

Slide 117

Slide 117 text

@mheap #phpworld I’m Michael

Slide 118

Slide 118 text

@mheap #phpworld @mheap

Slide 119

Slide 119 text

@mheap #phpworld https://joind.in/21790