├── .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 |
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 |
67 | Key 'a' already exists on array
68 | /**##(##**/'a' => 'b'/**##)##**/,
69 |
70 | -
71 |
exercises/3-static-analysis/a.php:17
72 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------