├── .gitignore ├── .travis.yml ├── Dockerfile ├── README.md ├── behat.yml ├── composer.json ├── composer.lock ├── exercises ├── 1-object-calisthenics │ └── original.php ├── 2-leveraging-types │ └── original.php ├── 3-static-analysis │ └── a.php ├── 4-simple-domain-implementation │ └── .gitkeep ├── 5-testing-legacy-code │ ├── src │ │ └── Shell.php │ └── test │ │ └── ShellTest.php ├── 6-branch-coverage │ ├── .gitignore │ ├── branch-coverage-to-dot.php │ ├── branch-coverage.php │ ├── line-coverage.php │ └── src.php ├── 8-infection │ ├── phpunit.xml.dist │ ├── src │ │ └── A.php │ └── test │ │ └── ATest.php └── 9-heroku │ └── Procfile ├── features ├── book-hotel-bad.feature ├── book-hotel.feature └── bootstrap │ └── .gitkeep ├── handbook ├── 0-behat │ ├── BookHotelDomainContext.php │ ├── BookHotelUiContext.php │ └── book-hotel.feature ├── 1-object-calisthenics │ └── notes.md ├── 2-leveraging-types │ └── notes.md ├── 3-static-analysis │ └── notes.md ├── 4-simple-domain-implementation │ ├── Domain │ │ ├── Booking │ │ │ ├── Booking.php │ │ │ ├── BookingId.php │ │ │ └── DateSelection.php │ │ ├── Payment │ │ │ ├── CompletedPayment.php │ │ │ ├── PaymentGateway.php │ │ │ ├── PaymentResult.php │ │ │ └── PendingPayment.php │ │ └── Reservation │ │ │ ├── ConfirmedReservationId.php │ │ │ └── Reservations.php │ └── notes.md ├── 5-testing-legacy-code │ ├── ShellTest.php │ └── notes.md ├── 6-branch-coverage │ └── notes.md ├── 7-coverage-leaking │ └── notes.md ├── 8-infection │ └── notes.md ├── 9-heroku │ └── notes.md └── pr-sources.md ├── infection.json.dist ├── phpcs.xml.dist ├── phpunit.xml.dist ├── psalm-report.html ├── psalm.xml.dist ├── public └── index.php ├── src └── .gitkeep ├── test.sh └── test └── unit └── Domain └── Booking └── BookingTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | infection-log.txt 3 | infection.json 4 | /**/.phpunit.result.cache 5 | src/Domain 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | install: 4 | - ln -s ../handbook/3-simple-domain-implementation/Domain src/Domain 5 | - composer install --prefer-dist 6 | 7 | jobs: 8 | include: 9 | - stage: Run unit tests 10 | php: 7.3 11 | script: 12 | - vendor/bin/phpunit 13 | 14 | - stage: Static analysis 15 | php: 7.3 16 | script: 17 | - vendor/bin/phpstan analyse --level 7 src 18 | 19 | - stage: Check CS 20 | php: 7.3 21 | script: 22 | - vendor/bin/phpcs 23 | 24 | - stage: Upload Coverage 25 | php: 7.3 26 | before_script: 27 | - wget https://scrutinizer-ci.com/ocular.phar 28 | script: 29 | - vendor/bin/phpunit --coverage-text --coverage-clover=clover.xml 30 | - php ocular.phar code-coverage:upload --format=php-clover clover.xml 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.3-fpm-alpine 2 | 3 | RUN mkdir -p /usr/share/php-fpm \ 4 | && apk add --update iproute2 wget gnupg netcat-openbsd git bash unzip autoconf build-base \ 5 | icu-dev \ 6 | && docker-php-ext-install opcache intl \ 7 | && yes | pecl install xdebug \ 8 | && docker-php-ext-enable xdebug \ 9 | && echo "xdebug.remote_enable=1" >> /usr/local/etc/php/conf.d/xdebug.ini \ 10 | && echo "xdebug.remote_connect_back=0" >> /usr/local/etc/php/conf.d/xdebug.ini \ 11 | && echo "xdebug.remote_host=172.17.0.1" >> /usr/local/etc/php/conf.d/xdebug.ini 12 | 13 | RUN yes | pecl install uopz 14 | 15 | ENV PHP_IDE_CONFIG serverName=quality 16 | ENV XDEBUG_CONFIG idekey=PHPSTORM 17 | 18 | COPY --from=composer:latest /usr/bin/composer /usr/bin/composer 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quality PHP Workshop 2 | 3 | Sample code and exercises for my quality PHP workshop. 4 | 5 | Requirements: 6 | 7 | * PHP 7.2+ 8 | * Composer 9 | 10 | ## Installation (online) 11 | 12 | * Clone this repository 13 | * Run `composer install` 14 | * The `handbook` folder is just for me, please don't cheat :) 15 | 16 | ## Installation (copy from USB) 17 | 18 | * Copy the folder, hopefully everything will work for you! :) 19 | 20 | ## Using the Dockerfile 21 | 22 | If you don't have a suitable PHP environment set up, you can try the Dockerfile: 23 | 24 | **NOTE** I have not tested this thoroughly, so YMMV... proceed with caution AT YOUR OWN RISK!! 25 | 26 | * `docker build -t quality .` 27 | * `docker run --rm -v $(pwd):/app -it quality php -v` - test it works 28 | * `docker run --rm -v $(pwd):/app -it quality bash` - open a shell "inside" the container 29 | * inside container: `rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini` (removes xdebug) 30 | * inside container: `docker-php-ext-enable xdebug` (adds xdebug) 31 | 32 | # Bulgaria PHP Training (3h) 33 | 34 | - 14:00 : Intro - 15m 35 | - Planning 36 | - 14:15 : Event storming practical - 30m 37 | - 14:45 : Feature description practical - 15m 38 | - Development 39 | - 15:00 : Object Calisthenics + Types (ex 1 + 2 combined) - 30m 40 | - 15:30 Break 41 | - Development 42 | - 15:45 : Static Analysis (ex 3)- 10m 43 | - Testing 44 | - 15:55 : Testing Legacy (ex 5) - 10m 45 | - 16:05 : Branch coverage (ex 6) - 10m 46 | - 16:15 : Coverage leaking (ex 7) - 10m 47 | - 16:25 : Mutation testing (ex 8) - 10m 48 | - 16:35 : Code Reviews - 10m 49 | - 16:45 : Deployments / Outro - 15m 50 | -------------------------------------------------------------------------------- /behat.yml: -------------------------------------------------------------------------------- 1 | default: 2 | suites: 3 | domain: 4 | contexts: 5 | - AsgrimBehaviourTest\BookHotelDomainContext 6 | filters: 7 | tags: '@domain' 8 | ui: 9 | contexts: 10 | - AsgrimBehaviourTest\BookHotelUiContext 11 | filters: 12 | tags: '@ui' 13 | 14 | extensions: 15 | Behat\MinkExtension: 16 | base_url: 'https://hotel-booking-system-url/' 17 | sessions: 18 | default: 19 | selenium2: ~ 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "asgrim/quality-tutorial", 3 | "description": "Sample code and exercises for my quality PHP workshop", 4 | "type": "project", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "James Titcumb", 9 | "email": "james@asgrim.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.2", 14 | "ext-json": "*", 15 | "beberlei/assert": "^3.2", 16 | "ramsey/uuid": "^3.8", 17 | "symfony/process": "^4.3" 18 | }, 19 | "require-dev": { 20 | "ext-xdebug": "*", 21 | "behat/behat": "^3.5", 22 | "behat/mink": "^1.7", 23 | "behat/mink-extension": "^2.3", 24 | "behat/mink-selenium2-driver": "^1.3", 25 | "infection/infection": "^0.14.1", 26 | "php-mock/php-mock": "^2.1", 27 | "php-mock/php-mock-phpunit": "^2.5", 28 | "phpstan/phpstan": "^0.11.16", 29 | "phpunit/phpunit": "^8.4", 30 | "roave/security-advisories": "dev-master", 31 | "squizlabs/php_codesniffer": "^3.5", 32 | "vimeo/psalm": "^3.6" 33 | }, 34 | "suggest": { 35 | "ext-uopz": "If you want to run the 5-testing-legacy-code exercise, you need uopz extension" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "Asgrim\\": "src/", 40 | "Exercises\\One\\": "exercises/1-object-calisthenics", 41 | "Exercises\\Two\\": "exercises/2-leveraging-types", 42 | "Exercises\\Four\\": "exercises/4-simple-domain-implementation", 43 | "Exercises\\Three\\": "exercises/3-static-analysis", 44 | "Exercises\\Five\\": "exercises/5-testing-legacy-code/src", 45 | "Exercises\\Six\\": "exercises/6-branch-coverage", 46 | "Exercises\\Eight\\": "exercises/8-infection/src" 47 | } 48 | }, 49 | "autoload-dev": { 50 | "psr-4": { 51 | "AsgrimUnitTest\\": "test/unit", 52 | "AsgrimBehaviourTest\\": "features/bootstrap", 53 | "ExercisesTest\\Five\\": "exercises/5-testing-legacy-code/test", 54 | "ExercisesTest\\Eight\\": "exercises/8-infection/test" 55 | } 56 | }, 57 | "config": { 58 | "preferred-install": "dist", 59 | "sort-packages": true 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /exercises/1-object-calisthenics/original.php: -------------------------------------------------------------------------------- 1 | tid; 22 | } 23 | public function setTid($tid) 24 | { 25 | $this->tid = $tid; 26 | } 27 | public function getAmt() 28 | { 29 | return $this->amt; 30 | } 31 | public function setAmt($amt) 32 | { 33 | $this->amt = $amt; 34 | } 35 | public function getisPaid() 36 | { 37 | return $this->isPaid; 38 | } 39 | public function setIsPaid($isPaid) 40 | { 41 | $this->isPaid = $isPaid; 42 | } 43 | } 44 | 45 | class MyFunPaymentGateway 46 | { 47 | /** @var HttpClient */ 48 | private $http; 49 | 50 | /** @var string[] */ 51 | private $responses; 52 | 53 | public function __construct(HttpClient $http) 54 | { 55 | $this->http = $http; 56 | } 57 | 58 | /** 59 | * {@inheritDoc} 60 | * @throws \Exception 61 | */ 62 | public function preauthorise(PaymentDetails $paymentDetails) 63 | { 64 | $setupResult = $this->http->request('/setup-transaction', 'POST', json_encode([ 65 | 'tid' => $paymentDetails->getTid(), 66 | 'amt' => (float)$paymentDetails->getAmt(), 67 | ])); 68 | $this->responses[] = json_decode($this->http->getLastResponse(), true); 69 | if ($setupResult === 200) { 70 | $preauthResult = $this->http->request('/preauthorise', 'POST', $paymentDetails->getTid()); 71 | $responses[] = json_decode($this->http->getLastResponse(), true); 72 | if ($preauthResult === 200) { 73 | $paymentDetails->setIsPaid(true); 74 | return true; 75 | } else { 76 | throw new \Exception('Unable to do things'); 77 | } 78 | } else { 79 | return false; 80 | } 81 | } 82 | 83 | public function getBookingId() 84 | { 85 | foreach ($this->responses as $response) { 86 | if (isset($response['booking_id'])) { 87 | return $response['booking_id']; 88 | } 89 | } 90 | } 91 | 92 | public function completePayment(string $pmt) 93 | { 94 | // TODO: Implement completePayment() method. 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /exercises/2-leveraging-types/original.php: -------------------------------------------------------------------------------- 1 | tid; 22 | } 23 | public function setTid($tid) 24 | { 25 | $this->tid = $tid; 26 | } 27 | public function getAmt() 28 | { 29 | return $this->amt; 30 | } 31 | public function setAmt($amt) 32 | { 33 | $this->amt = $amt; 34 | } 35 | public function getisPaid() 36 | { 37 | return $this->isPaid; 38 | } 39 | public function setIsPaid($isPaid) 40 | { 41 | $this->isPaid = $isPaid; 42 | } 43 | } 44 | 45 | class MyFunPaymentGateway 46 | { 47 | /** @var HttpClient */ 48 | private $http; 49 | 50 | /** @var string[] */ 51 | private $responses; 52 | 53 | public function __construct(HttpClient $http) 54 | { 55 | $this->http = $http; 56 | } 57 | 58 | /** 59 | * {@inheritDoc} 60 | * @throws \Exception 61 | */ 62 | public function preauthorise(PaymentDetails $paymentDetails) 63 | { 64 | $setupResult = $this->http->request('/setup-transaction', 'POST', json_encode([ 65 | 'tid' => $paymentDetails->getTid(), 66 | 'amt' => (float)$paymentDetails->getAmt(), 67 | ])); 68 | $this->responses[] = json_decode($this->http->getLastResponse(), true); 69 | if ($setupResult === 200) { 70 | $preauthResult = $this->http->request('/preauthorise', 'POST', $paymentDetails->getTid()); 71 | $responses[] = json_decode($this->http->getLastResponse(), true); 72 | if ($preauthResult === 200) { 73 | $paymentDetails->setIsPaid(true); 74 | return true; 75 | } else { 76 | throw new \Exception('Unable to do things'); 77 | } 78 | } else { 79 | return false; 80 | } 81 | } 82 | 83 | public function getBookingId() 84 | { 85 | foreach ($this->responses as $response) { 86 | if (isset($response['booking_id'])) { 87 | return $response['booking_id']; 88 | } 89 | } 90 | } 91 | 92 | public function completePayment(string $pmt) 93 | { 94 | // TODO: Implement completePayment() method. 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /exercises/3-static-analysis/a.php: -------------------------------------------------------------------------------- 1 | 'a', 14 | 'a' => 'b', 15 | ]; 16 | 17 | public function foo(string $a) { 18 | $this->callable->foo($a); 19 | } 20 | } 21 | 22 | class b 23 | { 24 | public function foo(int $a) {} 25 | } 26 | 27 | $a = new a(); 28 | $a->callable = new b; 29 | $a->foo(123, 'foo'); 30 | -------------------------------------------------------------------------------- /exercises/4-simple-domain-implementation/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asgrim/quality-tutorial/ee17ba2e5aa9919a2f3b91e8374e32083af7b339/exercises/4-simple-domain-implementation/.gitkeep -------------------------------------------------------------------------------- /exercises/5-testing-legacy-code/src/Shell.php: -------------------------------------------------------------------------------- 1 | _last_errno; 13 | } 14 | 15 | public function getLastOutput() 16 | { 17 | return $this->_last_output; 18 | } 19 | 20 | public function Exec($cmd, $noisy = false) 21 | { 22 | if($noisy) echo "Command: " . $cmd . "

"; 23 | 24 | $this->_last_errno = 0; 25 | $this->_last_output = array(); 26 | 27 | exec($cmd . " 2>&1", $this->_last_output, $this->_last_errno); 28 | 29 | if($noisy) 30 | { 31 | echo "Output:
";
32 |             var_dump($this->_last_output);
33 |             echo "Exit code: " . $this->_last_errno;
34 |             echo "
"; 35 | echo "
"; 36 | exit($this->_last_errno); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /exercises/5-testing-legacy-code/test/ShellTest.php: -------------------------------------------------------------------------------- 1 | | 17 | +----------------------------------------------------------------------+ 18 | */ 19 | function branch_coverage_to_dot( $info, $pathInsteadOfBranch = true ) 20 | { 21 | $output = ''; 22 | 23 | $c = 0; 24 | 25 | $output .= "digraph {\n"; 26 | 27 | ksort($info); 28 | foreach ( $info as $fname => $file ) 29 | { 30 | if ( preg_match( '/dump-branch-coverage.inc$/', $fname ) ) 31 | { 32 | continue; 33 | } 34 | if ( preg_match( '/branch-coverage-to-dot.php$/', $fname ) ) 35 | { 36 | continue; 37 | } 38 | 39 | if ( !isset( $file['functions'] ) ) 40 | { 41 | continue; 42 | } 43 | 44 | $output .= sprintf("subgraph cluster_file_%s {\nlabel=\"%s\";\n", md5($fname), $fname); 45 | 46 | ksort( $file['functions'] ); 47 | foreach ( $file['functions'] as $fname => $function ) 48 | { 49 | $output .= sprintf("subgraph cluster_%s {\n\tlabel=\"%s\";\n\tgraph [rankdir=\"LR\"];\n\tnode [shape = record];\n", md5($fname), $fname); 50 | 51 | foreach ( $function['branches'] as $bnr => $branch ) 52 | { 53 | $output .= sprintf( "\t\"__%s_%d\" [ label = \"{ op #%d-%d | line %d-%d }\" ];\n", 54 | $fname, $bnr, 55 | $branch['op_start'], $branch['op_end'], 56 | $branch['line_start'], $branch['line_end'] 57 | ); 58 | 59 | if ( ! $pathInsteadOfBranch ) 60 | { 61 | if ( isset( $branch['out'][0] ) ) 62 | { 63 | $output .= sprintf( "\t\"__%s_%d\" -> \"__%s_%d\" %s;\n", 64 | $fname, $bnr, $fname, $branch['out'][0], 65 | $branch['out_hit'][0] ? '' : '[style=dashed]' 66 | ); 67 | } 68 | if ( isset( $branch['out'][1] ) ) 69 | { 70 | $output .= sprintf( "\t\"__%s_%d\" -> \"__%s_%d\" %s;\n", 71 | $fname, $bnr, $fname, $branch['out'][1], 72 | $branch['out_hit'][1] ? '' : '[style=dashed]' 73 | ); 74 | } 75 | } 76 | } 77 | 78 | if ( $pathInsteadOfBranch ) 79 | { 80 | $output .= sprintf( "\t\"__%s_ENTRY\" [label=\"ENTRY\"];", $fname ); 81 | $output .= sprintf( "\t\"__%s_EXIT\" [label=\"EXIT\"];", $fname ); 82 | foreach( $function['paths'] as $path ) 83 | { 84 | $output .= sprintf( "\t\"__%s_ENTRY\" -> \"__%s_%d\"", 85 | $fname, $fname, $path['path'][0] 86 | ); 87 | for ( $i = 1; $i < sizeof( $path['path'] ); $i++ ) 88 | { 89 | $output .= sprintf( " -> \"__%s_%d\"", 90 | $fname, $path['path'][$i] 91 | ); 92 | } 93 | $lastOp = $path['path'][sizeof($path['path']) - 1]; 94 | 95 | if ( isset( $function['branches'][$lastOp]['out'][0] ) && $function['branches'][$lastOp]['out'][0] == 2147483645 ) 96 | { 97 | $output .= sprintf( " -> \"__%s_EXIT\"", $fname ); 98 | } 99 | if ( isset( $function['branches'][$lastOp]['out'][1] ) && $function['branches'][$lastOp]['out'][1] == 2147483645 ) 100 | { 101 | $output .= sprintf( " -> \"__%s_EXIT\"", $fname ); 102 | } 103 | $output .= sprintf( " [color=\"/set19/%d\" penwidth=3 %s];\n", 104 | ($c % 9) + 1, 105 | $path['hit'] ? '' : ' style=dashed' 106 | ); 107 | $c++; 108 | } 109 | } 110 | 111 | $output .= "}\n"; 112 | } 113 | 114 | $output .= "}\n"; 115 | } 116 | 117 | $output .= "}\n"; 118 | 119 | return $output; 120 | } 121 | -------------------------------------------------------------------------------- /exercises/6-branch-coverage/branch-coverage.php: -------------------------------------------------------------------------------- 1 | coverage.png'); 16 | unlink($dotFile); 17 | -------------------------------------------------------------------------------- /exercises/6-branch-coverage/line-coverage.php: -------------------------------------------------------------------------------- 1 | filter()->addFileToWhitelist('src.php'); 9 | $coverage->start('truefalse'); 10 | foo(true, false); 11 | $coverage->stop(); 12 | $coverage->start('falsetrue'); 13 | foo(false, true); 14 | $coverage->stop(); 15 | 16 | $writer = new \SebastianBergmann\CodeCoverage\Report\Html\Facade(); 17 | $writer->process($coverage, 'coverage'); 18 | -------------------------------------------------------------------------------- /exercises/6-branch-coverage/src.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./test 6 | 7 | 8 | 9 | 10 | ./src 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /exercises/8-infection/src/A.php: -------------------------------------------------------------------------------- 1 | add(2, 3); 14 | 15 | // self::assertSame(5, $result); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /exercises/9-heroku/Procfile: -------------------------------------------------------------------------------- 1 | web: vendor/bin/heroku-php-apache2 public/ 2 | -------------------------------------------------------------------------------- /features/book-hotel-bad.feature: -------------------------------------------------------------------------------- 1 | Feature: Booking a room for a hotel 2 | 3 | Scenario: Making a successful booking 4 | Given I am on "/select-dates" 5 | When I fill in ".check-in-date" with "3/12/18" 6 | And I fill in ".check-out-date" with "6/12/18" 7 | And I press ".next" 8 | Then I should see "£300" in the ".price-total" element 9 | When I fill in "Credit Card Number" with "4242424242424242" 10 | And I fill in "Expiry Date" with "01/19" 11 | And I fill in "CCV (three digits from the back)" with "123" 12 | And I press ".make-payment" 13 | Then I should see "your booking has been confirmed" 14 | And I should see an ".booking-ref" element 15 | And I should see "£300" in the ".receipt-total" element 16 | -------------------------------------------------------------------------------- /features/book-hotel.feature: -------------------------------------------------------------------------------- 1 | Feature: Booking a room for a hotel 2 | -------------------------------------------------------------------------------- /features/bootstrap/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asgrim/quality-tutorial/ee17ba2e5aa9919a2f3b91e8374e32083af7b339/features/bootstrap/.gitkeep -------------------------------------------------------------------------------- /handbook/0-behat/BookHotelDomainContext.php: -------------------------------------------------------------------------------- 1 | selectedDates = DateSelection::between( 46 | new DateTimeImmutable('now +1 day', new DateTimeZone('UTC')), 47 | new DateTimeImmutable(sprintf('now +%d days', $nightsCount + 1), new DateTimeZone('UTC')) 48 | ); 49 | $this->booking = Booking::fromSelectedDates($this->selectedDates); 50 | } 51 | 52 | /** 53 | * @When /^I provide my payment details$/ 54 | */ 55 | public function iProvideMyPaymentDetails() : void 56 | { 57 | $this->booking->preauthorisePayment($this->paymentGateway); 58 | $this->booking->reserveRoom($this->reservations); 59 | $this->booking->completePayment($this->paymentGateway); 60 | } 61 | 62 | /** 63 | * @Then /^I should see my room is booked$/ 64 | * @throws \Behat\Behat\Tester\Exception\PendingException 65 | */ 66 | public function iShouldSeeMyRoomIsBooked() : void 67 | { 68 | // @todo check with the external Reservations system that the booking has been made 69 | throw new PendingException(); 70 | } 71 | 72 | /** 73 | * @Then /^my card has been charged £(\d+)$/ 74 | * @throws \Behat\Behat\Tester\Exception\PendingException 75 | */ 76 | public function myCardHasBeenCharged(int $amount) : void 77 | { 78 | // @todo check with Stripe that the payment went through 79 | throw new PendingException(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /handbook/0-behat/BookHotelUiContext.php: -------------------------------------------------------------------------------- 1 | getSession(); 32 | $page = $session->getPage(); 33 | 34 | $session->visit('/select-dates'); 35 | $page->fillField( 36 | '.check-in-date', 37 | (new DateTimeImmutable('now +1 day', new DateTimeZone('UTC')))->format('d/m/Y') 38 | ); 39 | $page->fillField( 40 | '.check-out-date', 41 | (new DateTimeImmutable(sprintf('now +%d days', $nightsCount + 1), new DateTimeZone('UTC')))->format('d/m/Y') 42 | ); 43 | $page->pressButton('.next'); 44 | } 45 | 46 | /** 47 | * @Given /^I provide my payment details$/ 48 | * @throws \Behat\Mink\Exception\ElementNotFoundException 49 | */ 50 | public function iProvideMyPaymentDetails() : void 51 | { 52 | $session = $this->getSession(); 53 | $page = $session->getPage(); 54 | 55 | Assert::that($session->getCurrentUrl())->endsWith('/credit-card-payment'); 56 | 57 | $page->fillField('Credit Card Number', '4242424242424242'); 58 | $page->fillField('Expiry Date', '01/19'); 59 | $page->fillField('CCV (three digits from the back)', '123'); 60 | $page->pressButton('.next'); 61 | } 62 | 63 | /** 64 | * @Then /^I should see my room is booked$/ 65 | * @throws \Behat\Behat\Tester\Exception\PendingException 66 | */ 67 | public function iShouldSeeMyRoomIsBooked() : void 68 | { 69 | $page = $this->getSession()->getPage(); 70 | 71 | $page->hasContent('your booking has been confirmed'); 72 | $page->has('.booking-ref', 'css'); 73 | 74 | // @todo check with the external Reservations system that the booking has been made 75 | } 76 | 77 | /** 78 | * @Then /^my card has been charged £(\d+)$/ 79 | * @throws \Behat\Behat\Tester\Exception\PendingException 80 | */ 81 | public function myCardHasBeenCharged(int $amount) : void 82 | { 83 | $page = $this->getSession()->getPage(); 84 | 85 | Assert::that($page->find('.receipt-total', 'css')->getText())->contains('300'); 86 | 87 | // @todo check with Stripe that the payment went through 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /handbook/0-behat/book-hotel.feature: -------------------------------------------------------------------------------- 1 | Feature: Booking a room for a hotel 2 | 3 | Glossary: 4 | - Payment/Card: payment card, e.g. debit card, credit card 5 | - Charge/Charged: where money has been taken from the payment card to the business 6 | - Refund/Refunded: where money has been sent back to the payment card from the business 7 | - Room: the subject of the booking 8 | - Booking/Booked/Reservation: a confirmed reservation of a room that has been paid for 9 | 10 | Policies: 11 | - All rooms must be paid in advance 12 | - All rooms and dates cost the same, so monetary amounts are unimportant 13 | - Rooms must be available to be booked 14 | - Room Availability is determined by an external system (e.g. via an API) 15 | 16 | @ui @domain 17 | Scenario: Making a successful booking 18 | Given there is a room available for £100 per night 19 | When I select an available room for 3 nights 20 | And I provide my payment details 21 | Then I should see my room is booked 22 | And my card has been charged £300 23 | 24 | Scenario: Cancelling a booking 25 | Given I already have an upcoming booking for 3 nights at £100 per night 26 | When I choose to cancel the booking 27 | Then I should see that the booking has been cancelled 28 | And my card has been refunded £300 29 | 30 | Scenario: Failing to make a booking 31 | Given there is a room available for £100 per night 32 | When I select an available room for 2 nights 33 | And I provide my payment details 34 | But the room fails to be booked 35 | Then I should see my room has not been reserved 36 | And that my card has not been charged 37 | -------------------------------------------------------------------------------- /handbook/1-object-calisthenics/notes.md: -------------------------------------------------------------------------------- 1 | # Background knowledge 2 | 3 | - `HttpClient` is just a placeholder for guzzle or something 4 | - https://williamdurand.fr/2013/06/03/object-calisthenics/ 5 | - PHP context: https://www.youtube.com/watch?v=I0y5jU61pS4 6 | 7 | # The Exercises 8 | 9 | - One level of indentation per method 10 | - Readability 11 | - Allows better naming! 12 | - Avoid ELSE 13 | - Simply cut out else (return early) 14 | - Be defensive (invalid scenarios are the return early) 15 | - Wrap primitives / strings 16 | - Use value objects, basically 17 | - First class collections 18 | - No arrays: use an object with types 19 | - One arrow per line 20 | - Law of demeter - principle of least knowledge 21 | - Don't abbreviate 22 | - Goes back to naming 23 | - Keep entities small 24 | - No classes with more than two instance variables 25 | - Customer > Name, CustomerId 26 | - Name > First, Last 27 | - CustomerId > int 28 | - First > string 29 | - Last > string 30 | - No getters/setters 31 | - Tell, don't ask 32 | 33 | # Things to improve 34 | 35 | - Return-early for error conditions 36 | - `float` for money - BAD! - definitely wrap that up 37 | - Naming: what is `tid`? - don't abbreviate 38 | - `setIsPaid` - hidden away inside: ASK don't tell 39 | - getters / setters: TELL don't ASK 40 | - Consider inverting call stack; `PaymentDetails` calls `MyFunPaymentGateway` 41 | -------------------------------------------------------------------------------- /handbook/2-leveraging-types/notes.md: -------------------------------------------------------------------------------- 1 | # Leveraging types 2 | 3 | - First: list what tests we need to write here 4 | * PaymentDetails 5 | - get/setTid - types: array, string, float, \stdClass, null, true/false, int 6 | * MyFunPaymentGateway 7 | - Successful payment 8 | - Failed to pre-auth payment (expect exception) 9 | - Failed setup transaction call 10 | - We can ELIMIATE a load of tests on PaymentDeteails 11 | - Refactor to add param & return types: what benefits do we have? 12 | - No mixed expectations: return true/false/throw Exception 13 | - Add an interface 14 | -------------------------------------------------------------------------------- /handbook/3-static-analysis/notes.md: -------------------------------------------------------------------------------- 1 | # phpstan helps find bugs 2 | 3 | - `git checkout -- exercises/3-static-analysis` 4 | - `vendor/bin/phpstan analyse --level 0 exercises/3-static-analysis` 5 | - ... 6 | - `vendor/bin/phpstan analyse --level 7 exercises/3-static-analysis` 7 | 8 | # Psalm also, my preferred tool 9 | 10 | - `git checkout -- exercises/3-static-analysis` 11 | - vendor/bin/psalm 12 | * fix all the issues 13 | * recommendation: start with strictest, but with a baseline 14 | - `errorBaseline="psalm-baseline.xml"` to XML 15 | - `vendor/bin/psalm --set-baseline=psalm-baseline.xml` 16 | - `vendor/bin/psalm --update-baseline` each time you improve the codebase 17 | * HTML output - 18 | - `vendor/bin/psalm --output-format=xml | docker run --rm -i psalm-html-output:latest > psalm-report.html` 19 | - 20 | 21 | # Recommendations 22 | 23 | - PHP Inspections (EA Extended) 24 | - Add `phpstan` or Psalm to CI (after fixing the issues!) 25 | - Baselining (e.g. with Psalm) 26 | -------------------------------------------------------------------------------- /handbook/4-simple-domain-implementation/Domain/Booking/Booking.php: -------------------------------------------------------------------------------- 1 | id = BookingId::new(); 38 | $instance->dates = $dates; 39 | return $instance; 40 | } 41 | 42 | public function preauthorisePayment(PaymentGateway $paymentGateway) : void 43 | { 44 | $this->pendingPayment = $paymentGateway->preauthorise($this); 45 | } 46 | 47 | public function reserveRoom(Reservations $reservations) : void 48 | { 49 | Assert::that($this->pendingPayment)->isInstanceOf(PendingPayment::class); 50 | $this->confirmedReservationId = $reservations->requestReservation($this->dates); 51 | } 52 | 53 | public function notifyCustomerAboutReservation() : void 54 | { 55 | } 56 | 57 | public function completePayment(PaymentGateway $paymentGateway) : void 58 | { 59 | Assert::that($this->pendingPayment)->isInstanceOf(PendingPayment::class); 60 | Assert::that($this->confirmedReservationId)->isInstanceOf(ConfirmedReservationId::class); 61 | $this->completedPayment = $paymentGateway->completePayment($this->pendingPayment); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /handbook/4-simple-domain-implementation/Domain/Booking/BookingId.php: -------------------------------------------------------------------------------- 1 | id = Uuid::fromString($bookingId); 22 | return $instance; 23 | } 24 | 25 | public static function new() 26 | { 27 | $instance = new self(); 28 | $instance->id = Uuid::uuid4(); 29 | return $instance; 30 | } 31 | 32 | public function __toString() : string 33 | { 34 | return $this->id->toString(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /handbook/4-simple-domain-implementation/Domain/Booking/DateSelection.php: -------------------------------------------------------------------------------- 1 | checkIn = $checkIn; 27 | $instance->checkOut = $checkOut; 28 | return $instance; 29 | } 30 | 31 | /** 32 | * @param string[] $data 33 | * @return DateSelection 34 | * @throws \Exception 35 | */ 36 | public static function fromArray(array $data) : self 37 | { 38 | Assert::that($data)->keyExists('checkIn'); 39 | 40 | $instance = new self(); 41 | $instance->checkIn = new DateTimeImmutable($data['checkIn'], new DateTimeZone('UTC')); 42 | $instance->checkOut = new DateTimeImmutable($data['checkIn'], new DateTimeZone('UTC')); 43 | return $instance; 44 | } 45 | 46 | public function checkIn() : DateTimeImmutable 47 | { 48 | return $this->checkIn; 49 | } 50 | 51 | public function checkOut() : DateTimeImmutable 52 | { 53 | return $this->checkIn; 54 | } 55 | 56 | public function toArray() : array 57 | { 58 | return [ 59 | 'checkIn' => $this->checkIn->format('r'), 60 | 'checkOut' => $this->checkIn->format('r'), 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /handbook/4-simple-domain-implementation/Domain/Payment/CompletedPayment.php: -------------------------------------------------------------------------------- 1 | paymentId = $someResult->paymentId(); 20 | return $instance; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /handbook/4-simple-domain-implementation/Domain/Payment/PaymentGateway.php: -------------------------------------------------------------------------------- 1 | id = Uuid::fromString($roomId); 22 | return $instance; 23 | } 24 | 25 | public function __toString() : string 26 | { 27 | return $this->id->toString(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /handbook/4-simple-domain-implementation/Domain/Reservation/Reservations.php: -------------------------------------------------------------------------------- 1 | getFunctionMock('Exercises\Five', 'exec'); 24 | $execCallMock->expects(self::once()) 25 | ->willReturnCallback( 26 | function ( 27 | string $command, 28 | &$output, 29 | &$returnVar 30 | ) use ( 31 | $expectedCommand, 32 | $expectedOutput, 33 | $expectedReturn 34 | ) { 35 | self::assertSame($expectedCommand . ' 2>&1', $command); 36 | $output = $expectedOutput; 37 | $returnVar = $expectedReturn; 38 | }); 39 | 40 | $shell = new Shell(); 41 | $shell->Exec($expectedCommand, false); 42 | self::assertSame($expectedOutput, $shell->getLastOutput()); 43 | self::assertSame($expectedReturn, $shell->getLastError()); 44 | } 45 | 46 | public function testExecWithNoisyEnabled() : void 47 | { 48 | if (!\extension_loaded('uopz')) { 49 | self::markTestSkipped('uopz extension is not enabled'); 50 | return; 51 | } 52 | 53 | uopz_allow_exit(false); // NOTE: not strictly required as `false` is default when enabled 54 | 55 | $expectedCommand = uniqid('command', true); 56 | $expectedOutput = [uniqid('lastLineOfOutput', true)]; 57 | $expectedReturn = random_int(1, 254); 58 | 59 | $execCallMock = $this->getFunctionMock('Exercises\Five', 'exec'); 60 | $execCallMock->expects(self::once()) 61 | ->willReturnCallback( 62 | function ( 63 | string $command, 64 | &$output, 65 | &$returnVar 66 | ) use ( 67 | $expectedCommand, 68 | $expectedOutput, 69 | $expectedReturn 70 | ) { 71 | self::assertSame($expectedCommand . ' 2>&1', $command); 72 | $output = $expectedOutput; 73 | $returnVar = $expectedReturn; 74 | }); 75 | 76 | $shell = new Shell(); 77 | $shell->Exec($expectedCommand, true); 78 | self::assertSame($expectedOutput, $shell->getLastOutput()); 79 | self::assertSame($expectedReturn, $shell->getLastError()); 80 | 81 | $expectStdoutFormat = <<Command: %s

Output:
array(1) {
83 |   [0]=>
84 |   string(%d) "%s"
85 | }
86 | Exit code: %d

87 | EOF; 88 | 89 | $this->expectOutputString(sprintf( 90 | $expectStdoutFormat, 91 | $expectedCommand, 92 | \strlen($expectedOutput[0]), 93 | $expectedOutput[0], 94 | $expectedReturn 95 | )); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /handbook/5-testing-legacy-code/notes.md: -------------------------------------------------------------------------------- 1 | # Legacy testing exercise 2 | 3 | ## Mocking functions 4 | 5 | * https://github.com/php-mock/php-mock-phpunit 6 | 7 | ## Mocking die/exit 8 | 9 | ```bash 10 | $ sudo phpenmod uopz 11 | # or ... 12 | $ docker-php-ext-enable uopz 13 | ``` 14 | 15 | ``` 16 | vendor/bin/phpunit exercises/5-testing-legacy-code/test/ShellTest.php 17 | ``` 18 | 19 | * Needs extension `uopz`: https://github.com/krakjoe/uopz 20 | * `uopz_allow_exit(false);` is key 21 | * Note: PECL in deb.sury world: https://github.com/oerdnj/deb.sury.org/wiki/PECL-Installation 22 | 23 | ``` 24 | sudo phpdismod uopz 25 | ``` 26 | -------------------------------------------------------------------------------- /handbook/6-branch-coverage/notes.md: -------------------------------------------------------------------------------- 1 | # Branch coverage demo 2 | 3 | * `cd exercises/6-branch-coverage` 4 | * Examine `src.php` 5 | * Run `php line-coverage.php` - examine `coverage` folder HTML report 6 | * 7 | * Run `php branch-coverage.php` - see only 2 of 4 branch covered in `coverage.png` 8 | * Add additional test cases... 9 | 10 | 11 | REF: https://derickrethans.nl/path-branch-coverage.html 12 | -------------------------------------------------------------------------------- /handbook/7-coverage-leaking/notes.md: -------------------------------------------------------------------------------- 1 | # Coverage leaking 2 | 3 | * `BookingTest` 4 | - `vendor/bin/phpunit --coverage-html coverage` 5 | * Run it, note the coverage leaking `BookingId` and `DateSelection` 6 | - file:///home/james/workspace/quality-tutorial/coverage/index.html 7 | * Enable `@covers` annotation 8 | * Note, phpunit settings: 9 | - `forceCoversAnnotation="true"` - does not generate coverage unless @covers specified 10 | -------------------------------------------------------------------------------- /handbook/8-infection/notes.md: -------------------------------------------------------------------------------- 1 | # Infection PHP demo 2 | 3 | * show `A` and `ATest` 4 | * Run `vendor/bin/infection --log-verbosity=all` (need verbosity!!) 5 | * Show `infection-log.txt` to show what is run & failed 6 | * Add assertion back in ;) 7 | -------------------------------------------------------------------------------- /handbook/9-heroku/notes.md: -------------------------------------------------------------------------------- 1 | # Heroku demo 2 | Procfile: 3 | ``` 4 | web: vendor/bin/heroku-php-apache2 public/ 5 | ``` 6 | 7 | - Register for Heroku (if not already) 8 | - Make a new branch `git checkout heroku" 9 | - Copy `Procfile` to root, commit 10 | - What is the Procfile? 11 | - `worker: php worker.php` 12 | - `heroku login` then `heroku create` 13 | - `git push heroku heroku:master` = deploy 14 | 15 | ## Other bits 16 | - PHP extensions as deps in `composer.json` 17 | - Logs: `heroku logs --tail` 18 | - Addons - https://elements.heroku.com/addons (DB, MQ, Redis etc.) 19 | - Migrations? https://devcenter.heroku.com/articles/release-phase 20 | - `release: ./test.sh` 21 | -------------------------------------------------------------------------------- /handbook/pr-sources.md: -------------------------------------------------------------------------------- 1 | Some suggestions: 2 | 3 | - https://github.com/joindin/joindin-api/pulls 4 | - https://github.com/doctrine/doctrine2/pulls 5 | - https://github.com/Roave/BackwardCompatibilityCheck/pulls 6 | - https://github.com/zendframework/zend-expressive/pulls 7 | - https://github.com/laravel/framework/pulls 8 | - https://github.com/doctrine/migrations/pull/715 - DOCS! :) 9 | - https://github.com/nikolaposa/version/pull/18 10 | - https://github.com/DashboardHub/PipelineDashboard/pulls 11 | - https://github.com/jakzal/phpqa/pulls 12 | - https://github.com/joomla/joomla-cms/pulls 13 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 10, 3 | "source": { 4 | "directories": [ 5 | "exercises/8-infection/src" 6 | ] 7 | }, 8 | "logs": { 9 | "text": "infection-log.txt" 10 | }, 11 | "phpUnit": { 12 | "configDir": "exercises/8-infection" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | ./src 12 | ./test 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | ./test/unit 16 | 17 | 18 | 19 | 20 | ./src 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /psalm-report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Psalm Report 5 | 6 | 7 | 48 | 49 | 50 |
51 |
    52 |
  • 53 |

    exercises/3-static-analysis/a.php:12

    54 |
      55 |
    • error
    • 56 |
    • MissingPropertyType
    • 57 |
    58 |

    Property Exercises\Four\a::$a does not have a declared type - consider array{a: string}

    59 |
        private /**##(##**/$a/**##)##**/ = [
    60 |
  • 61 |
  • 62 |

    exercises/3-static-analysis/a.php:14

    63 |
      64 |
    • error
    • 65 |
    • DuplicateArrayKey
    • 66 |
    67 |

    Key 'a' already exists on array

    68 |
            /**##(##**/'a' => 'b'/**##)##**/,
    69 |
  • 70 |
  • 71 |

    exercises/3-static-analysis/a.php:17

    72 |
      73 |
    • error
    • 74 |
    • MissingReturnType
    • 75 |
    76 |

    Method Exercises\Four\a::foo does not have a return type, expecting void

    77 |
        public function /**##(##**/foo/**##)##**/(string $a) {
    78 |
  • 79 |
  • 80 |

    exercises/3-static-analysis/a.php:18

    81 |
      82 |
    • error
    • 83 |
    • PossiblyNullReference
    • 84 |
    85 |

    Cannot call method foo on possibly null value

    86 |
            $this->callable->/**##(##**/foo/**##)##**/($a);
    87 |
  • 88 |
  • 89 |

    exercises/3-static-analysis/a.php:18

    90 |
      91 |
    • error
    • 92 |
    • InvalidScalarArgument
    • 93 |
    94 |

    Argument 1 of Exercises\Four\b::foo expects int, string provided

    95 |
            $this->callable->foo(/**##(##**/$a/**##)##**/);
    96 |
  • 97 |
  • 98 |

    exercises/3-static-analysis/a.php:24

    99 |
      100 |
    • error
    • 101 |
    • MissingReturnType
    • 102 |
    103 |

    Method Exercises\Four\b::foo does not have a return type, expecting void

    104 |
        public function /**##(##**/foo/**##)##**/(int $a) {}
    105 |
  • 106 |
  • 107 |

    exercises/3-static-analysis/a.php:29

    108 |
      109 |
    • error
    • 110 |
    • TooManyArguments
    • 111 |
    112 |

    Too many arguments for method Exercises\Four\a::foo - expecting 1 but saw 2

    113 |
    $a->/**##(##**/foo/**##)##**/(123, 'foo');
    114 |
  • 115 |
  • 116 |

    exercises/3-static-analysis/a.php:29

    117 |
      118 |
    • error
    • 119 |
    • InvalidScalarArgument
    • 120 |
    121 |

    Argument 1 of Exercises\Four\a::foo expects string, int(123) provided

    122 |
    $a->foo(/**##(##**/123/**##)##**/, 'foo');
    123 |
  • 124 |
125 | 188 | 189 | 190 | -------------------------------------------------------------------------------- /psalm.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | createMock(PaymentGateway::class); 29 | $reservationsSystem = $this->createMock(Reservations::class); 30 | 31 | $dateSelection = DateSelection::between( 32 | new DateTimeImmutable('now +2 days', new DateTimeZone('UTC')), 33 | new DateTimeImmutable('now +4 days', new DateTimeZone('UTC')) 34 | ); 35 | $booking = Booking::fromSelectedDates($dateSelection); 36 | 37 | $paymentsSystem->expects(self::once()) 38 | ->method('preauthorise') 39 | ->with($booking) 40 | ->willReturn(PendingPayment::new()); 41 | 42 | $reservationsSystem->expects(self::once()) 43 | ->method('requestReservation') 44 | ->with($dateSelection) 45 | ->willReturn(ConfirmedReservationId::fromString(Uuid::uuid4()->toString())); 46 | 47 | $booking->preauthorisePayment($paymentsSystem); 48 | $booking->reserveRoom($reservationsSystem); 49 | } 50 | } 51 | --------------------------------------------------------------------------------