Advanced PHPUnit Shenanigans

Beware shenanigans

They have shenanned once, they will shenan-again.  So says Lura Thok

Larry Garfield

@Crell@phpc.social

Cover of Exploring PHP 8.0 Cover of Thinking Functionally in PHP

Who are you?

  • Already use PHPUnit
  • Already support automated testing
  • Want to get better at it

If this is not you, one of us is in the wrong room

This talk is

  • Focused on PHPUnit 12/13
  • About core PHPUnit only, no extensions
  • Intended to teach you at least one new thing

Odds and ends

Custom cache directory


          
            
              
                  
                      tests
                  
              

              
                  
                      src
                  
              
          
        

Custom cache directory


          
            
              
                  
                      tests
                  
              

              
                  
                      src
                  
              
          
        

Example code


          class Rectangle implements TwoDShape {
              public float $area { get => $this->height * $this->width; }

              public function __construct(public int $height, public int $width) {}
          }
        

          class RectangleTest extends TestCase {
              public function testAreaOf2x2is4(): void {
                  $r = new Rectangle(2, 2);
                  self::assertSame(4.0, $r->area);
              }
          }
        

#[Test] attribute


          class RectangleTest extends TestCase {
              #[Test]
              public function areaOf2x2is4(): void {
                  $r = new Rectangle(2, 2);
                  self::assertSame(4.0, $r->area);
              }
          }
        

Test sizing


          #[Small]
          class RectangleTest extends TestCase {
              #[Test]
              public function areaOf2x2is4(): void {
                  $r = new Rectangle(2, 2);
                  self::assertSame(4.0, $r->area);
              }
          }
        
  • Also #[Medium] and #[Large]
  • Small ~ Unit, Large ~ Integration
  • You define the line for your project

Multiplicative tests

Redundant tests :-(


        #[Small]
        class RectangleTest extends TestCase {
          #[Test]
          public function areaOf2x2is4(): void {
            $r = new Rectangle(2, 2);
            self::assertSame(4.0, $r->area);
          }

          #[Test]
          public function areaOf3x2is6(): void {
            $r = new Rectangle(3, 2);
            self::assertSame(6.0, $r->area);
          }

          #[Test]
          public function areaOf2x3is6(): void {
            $r = new Rectangle(2, 3);
            self::assertSame(6.0, $r->area);
          }
        }
        

#[TestWith]


        #[Small]
        class RectangleTest extends TestCase {
          #[Test]
          #[TestWith([2, 2, 4], '2x2=4')]
          #[TestWith([3, 2, 6], '3x2=6')]
          #[TestWith([2, 3, 6], '2x3=6')]
          public function area(int $height, int $width, float $expectedArea): void {
            $r = new Rectangle($height, $width);
            self::assertSame($expectedArea, $r->area);
          }
        }
        

Data providers


        #[Small]
        class RectangleTest extends TestCase {
          public static function areaProvider(): array {
            return [
              [2, 2, 4],
              [3, 2, 6],
              [2, 3, 6],
            ];
          }

          #[Test]
          #[DataProvider('areaProvider')]
          public function areaWithProvider(int $height, int $width, float $expectedArea): void {
              $r = new Rectangle($height, $width);
              self::assertSame($expectedArea, $r->area);
          }
        }
        

Data generators


        #[Small]
        class RectangleTest extends TestCase {
          public static function areaProvider(): \Generator {
            yield [2, 2, 4];
            yield [3, 2, 6];
            yield [2, 3, 6];
          }

          #[Test]
          #[DataProvider('areaProvider')]
          public function areaWithProvider(int $height, int $width, float $expectedArea): void {
              $r = new Rectangle($height, $width);
              self::assertSame($expectedArea, $r->area);
          }
        }
        

Data generators


        #[Small]
        class RectangleTest extends TestCase {
          public static function areaProvider(): \Generator {
            yield '2x2=4' => [2, 2, 4];
            yield '3x2=6' => [3, 2, 6];
            yield '2x3=6' => [2, 3, 6];
          }

          #[Test]
          #[DataProvider('areaProvider')]
          public function areaWithProvider(int $height, int $width, float $expectedArea): void {
              $r = new Rectangle($height, $width);
              self::assertSame($expectedArea, $r->area);
          }
        }
        

Data generators error output


          $ vendor/bin/phpunit --filter areaWithProvider

          ..F                                                              6 / 6 (100%)

          Time: 00:00.003, Memory: 14.00 MB

          There was 1 failure:

          1) RectangleTest::areaWithProvider@2x2=4 with data (2, 2, 5)
          Failed asserting that 4.0 is identical to 5.0.

          FAILURES!
          Tests: 3, Assertions: 3, Failures: 1.
        

Data generators error output


          $ vendor/bin/phpunit --filter areaWithProvider@2x2=4

          F                                                              6 / 6 (100%)

          Time: 00:00.003, Memory: 14.00 MB

          There was 1 failure:

          1) RectangleTest::areaWithProvider@2x2=4 with data (2, 2, 5)
          Failed asserting that 4.0 is identical to 5.0.

          FAILURES!
          Tests: 1, Assertions: 1, Failures: 1.
        

Named arguments


        #[Small]
        class RectangleTest extends TestCase {
          public static function areaProvider(): \Generator {
            yield '2x2=4' => [
                'height' => 2,
                'width' => 2,
                'expectedArea' => 4,
            ];
            yield '3x2=6' => [
                'height' => 3,
                'width' => 2,
                'expectedArea' => 6,
            ];
            yield '2x3=6' => [
                'expectedArea' => 6, // Out of order is fine.
                'height' => 2,
                'width' => 3,
            ];
          }

          #[Test]
          #[DataProvider('areaProvider')]
          public function areaWithProvider(int $height, int $width, float $expectedArea): void {
              $r = new Rectangle($height, $width);
              self::assertSame($expectedArea, $r->area);
          }
        }
        

Multiple providers


        #[Small]
        class RectangleTest extends TestCase {
          public static function squareProvider(): \Generator {
            // ...
          }

         public static function rectProvider(): \Generator {
            // ...
          }

          #[Test]
          #[DataProvider('squareProvider')]
          #[DataProvider('rectProvider')]
          public function areaWithProvider(int $height, int $width, float $expectedArea): void {
              $r = new Rectangle($height, $width);
              self::assertSame($expectedArea, $r->area);
          }
        }
        

Complex providers


        #[Large]
        class RequestHandlerTest extends TestCase {
          #[Test, DataProvider('handlersProvider')]
          public function handlers(
            string $method, string|UriInterface $uri, $headers = [], ?string $body = null,
            int $expectedCode = 200, array $expectedHeaders = [], ?string $expectedBody = null,
            ): void {
            $request = new ServerRequest($method, $uri, $headers, $body);

            $handler = new RequestHandler();
            $res = $handler->handle($request);

            self::assertSame($expectedCode, $res->getStatusCode());
            if ($expectedBody) {
              self::assertSame($expectedBody, (string) $res->getBody());
            }

            foreach ($expectedHeaders as $header => $value) {
              $v = $res->getHeaderLine($header);
              self::assertNotNull($v);
              self::assertSame($value, $v);
            }
          }
        }
        

Complex providers


        #[Large]
        class RequestHandlerTest extends TestCase {
          public static function handlersProvider(): \Generator {
            yield 'just confirm a 200' => [
              'method' => 'GET',
              'uri' => '/beep',
              'expectedBody' => 'boop',   // Optional
            ];

            yield 'confirm a not-found URL' => [
              'method' => 'GET',
              'uri' => '/missing',
              'expectedCode' => 404,     // Optional, and don't check the body
            ];

            yield 'create an object' => [
              'method' => 'POST',
              'uri' => '/create',
              'expectedCode' => 201,    // Override default
              'expectedHeaders' => ['Location' => '/beep/1'], // Triggers the foreach
            ];
          }
        }
        

Better complex providers


        #[Large]
        class RequestHandlerTest extends TestCase {
          #[Test, DataProvider('handlersProvider')]
          public function handlers(
            string $method, string|UriInterface $uri, $headers = [], ?string $body = null,
            int $expectedCode = 200, array $expectedHeaders = [], ?string $expectedBody = null,
            ): void {
            $request = new ServerRequest($method, $uri, $headers, $body);

            $handler = new RequestHandler();
            $res = $handler->handle($request);

            self::assertSame($expectedCode, $res->getStatusCode());
            if ($expectedBody) {
              self::assertSame($expectedBody, (string) $res->getBody());
            }

            foreach ($expectedHeaders as $header => $value) {
              $v = $res->getHeaderLine($header);
              self::assertNotNull($v);
              self::assertSame($value, $v);
            }
          }
        }
        

Objects FTW


        #[Large]
        class RequestHandlerTest extends TestCase {
          #[Test, DataProvider('handlersProvider')]
          public function handlers(
            ServerRequest $request,
            int $expectedCode = 200, array $expectedHeaders = [], ?string $expectedBody = null,
            ): void {
            $handler = new RequestHandler();
            $res = $handler->handle($request);

            self::assertSame($expectedCode, $res->getStatusCode());
            if ($expectedBody) {
              self::assertSame($expectedBody, (string) $res->getBody());
            }

            foreach ($expectedHeaders as $header => $value) {
              $v = $res->getHeaderLine($header);
              self::assertNotNull($v);
              self::assertSame($value, $v);
            }
          }
        }
        

Simpler data provider, too


        #[Large]
        class RequestHandlerTest extends TestCase {
          #[Test, DataProvider('handlersProvider')]
          public function handlers(
            ServerRequest $request,
            int $expectedCode = 200, array $expectedHeaders = [], ?string $expectedBody = null,
            ): void {
            $handler = new RequestHandler();
            $res = $handler->handle($request);

            // Assertions here
          }

          public static function handlersProvider(): \Generator {
            yield 'just confirm a 200' => [
                'request' => new ServerRequest('GET', '/beep'),
            ];

            yield 'confirm a not-found URL' => [
              'request' => new ServerRequest('GET', '/missing'),
              'expectedCode' => 404,
            ];

            yield 'create an object' => [
              'request' => new ServerRequest('POST', '/create'),
              'expectedCode' => 201,
              'expectedHeaders' => ['Location' => '/beep/1'],
            ];
          }
        }
        

Can we do the same for assertions?


        #[Large]
        class RequestHandlerTest extends TestCase {
          #[Test, DataProvider('handlersProvider')]
          public function handlers(
            ServerRequest $request,
            int $expectedCode = 200, array $expectedHeaders = [], ?string $expectedBody = null,
            ): void {
            $handler = new RequestHandler();
            $res = $handler->handle($request);

            self::assertSame($expectedCode, $res->getStatusCode());
            if ($expectedBody) {
              self::assertSame($expectedBody, (string) $res->getBody());
            }

            foreach ($expectedHeaders as $header => $value) {
              $v = $res->getHeaderLine($header);
              self::assertNotNull($v);
              self::assertSame($value, $v);
            }
          }

          public static function handlersProvider(): \Generator {
            // ...
          }
        }
        

1. Self-validating complex providers


        class RequestHandlerTest extends TestCase {
          #[Test, DataProvider('handlersProvider')]
          public function handlers(ServerRequest $request, \Closure $checks): void {
            $handler = new RequestHandler();
            $res = $handler->handle($request);
            $checks($res);
          }

          public static function handlersProvider(): \Generator {
            yield 'just confirm a 200' => [
              'request' => new ServerRequest('GET', '/beep', []),
              'checks' => static function(ResponseInterface $response) {
                self::assertSame(200, $response->getStatusCode());
              },
            ];

            yield 'confirm a not-found URL' => [
              'request' => new ServerRequest('GET', '/missing'),
              'checks' => static function (ResponseInterface $response) {
                self::assertSame(404, $response->getStatusCode());
              },
            ];

            yield 'create an object' => [
              'request' => new ServerRequest('POST', '/create'),
              'checks' => static function (ResponseInterface $response) {
                self::assertSame(201, $response->getStatusCode());
                self::assertSame('/beep/1', $response->getHeaderLine('Location'));
              },
            ];
          }
        }
        

2. Custom macro-assertions


        class RequestHandlerTest extends TestCase {
          #[Test, DataProvider('handlersProvider')]
          public function handlers(
            ServerRequest $request,
            int $expectedCode = 200, array $expectedHeaders = [], ?string $expectedBody = null,
            ): void {
            $handler = new RequestHandler();
            $res = $handler->handle($request);

            self::assertSame($expectedCode, $res->getStatusCode());
            self::assertBody($expectedBody, $res);
            self::assertHeaders($expectedHeaders, $res);
          }

          protected static function assertBody(?string $expectedBody, ResponseInterface $res): void {
            if ($expectedBody) {
              self::assertSame($expectedBody, (string) $res->getBody());
            }
          }

          protected static function assertHeaders(array $expectedHeaders, ResponseInterface $res): void {
            foreach ($expectedHeaders as $header => $value) {
              $v = $res->getHeaderLine($header);
              self::assertNotNull($v);
              self::assertSame($value, $v);
            }
          }
        }
        

Summary

  • Simple cases: #[TestWith]
  • Most cases: Data providers
  • Always use a generator provider
  • Name your tests and your arguments
  • Assertion callbacks in tests are neat
  • Use custom assertion wrappers liberally

Test setup

The traditional way


          class IntegrationTest extends TestCase {
              private Connection $conn;

              public function setUp(): void {
                $connectionParams = new DsnParser()->parse('pdo-sqlite:///:memory:');
                $this->conn = DriverManager::getConnection($connectionParams);

                $this->conn->executeQuery('CREATE TABLE users ...');
              }

              #[Test]
              public function stuff(): void {
                $this->conn->insert('users', ['id' => 1, 'name' => 'Larry']);
                // Do stuff ...
                $result = $this->conn->executeQuery('SELECT ...')->fetchOne();
                self::assertSame('Larry', $result);
              }
          }
        

The flexible way


          class IntegrationTest extends TestCase {
              private Connection $conn;

              #[Before(20)]
              public function setupDoctrine(): void {
                $connectionParams = new DsnParser()->parse('pdo-sqlite:///:memory:');
                $this->conn = DriverManager::getConnection($connectionParams);
              }

              #[Before(10)]
              public function setupTables(): void {
                $this->conn->executeQuery('CREATE TABLE users ...');
              }

              #[Test]
              public function stuff(): void {
                $this->conn->insert('users', ['id' => 1, 'name' => 'Larry']);
                // Do stuff ...
                $result = $this->conn->executeQuery('SELECT ...')->fetchOne();
                self::assertSame('Larry', $result);
              }
          }
        

Also class-level options


          class IntegrationTest extends TestCase {
              private static Connection $conn;

              #[BeforeClass(20)]
              public static function setupDoctrine(): void {
                $connectionParams = new DsnParser()->parse('pdo-sqlite:///:memory:');
                self::$conn = DriverManager::getConnection($connectionParams);
              }

              #[BeforeClass(10)]
              public static function setupTables(): void {
                self::$conn->executeQuery('CREATE TABLE users ...');
              }

              #[Test]
              public function stuff(): void {
                self::$conn->insert('users', ['id' => 1, 'name' => 'Larry']);
                // Do stuff ...
                $result = $this->conn->executeQuery('SELECT ...')->fetchOne();
                self::assertSame('Larry', $result);
              }
          }
        

#[After] and #[AfterClass]


          class IntegrationTest extends TestCase {
              private static Connection $conn;

              #[BeforeClass(20)]
              public static function setupDoctrine(): void {
                $connectionParams = new DsnParser()->parse('pdo-sqlite:///:memory:');
                self::$conn = DriverManager::getConnection($connectionParams);
              }

              #[BeforeClass(10)]
              public static function setupTables(): void {
                self::$conn->executeQuery('CREATE TABLE users ...');
              }

              #[After]
              public function cleanupTables(): void {
                self::$conn->executeQuery('DELETE FROM users');
              }

              #[Test]
              public function stuff(): void {
                self::$conn->insert('users', ['id' => 1, 'name' => 'Larry']);
                // Do stuff ...
                $result = $this->conn->executeQuery('SELECT ...')->fetchOne();
                self::assertSame('Larry', $result);
              }
          }
        
But what if I want to reuse my setup methods? That means a utility base class, right?

Wrong!

Traits

<aside>

  • Traits are not evil, no matter what some fuddy duddy says
  • Traits that have no external dependences are fine
  • Using traits to bypass proper DI/API is evil
  • Yes, this means Laravel's trait usage is evil. (So is Laravel)

</aside>

Back to our code


          class IntegrationTest extends TestCase {
              private static Connection $conn;

              #[BeforeClass(20)]
              public static function setupDoctrine(): void {
                $connectionParams = new DsnParser()->parse('pdo-sqlite:///:memory:');
                self::$conn = DriverManager::getConnection($connectionParams);
              }

              #[BeforeClass(10)]
              public static function setupTables(): void {
                self::$conn->executeQuery('CREATE TABLE users ...');
              }

              #[After]
              public function cleanupTables(): void {
                self::$conn->executeQuery('DELETE FROM users');
              }

              #[Test]
              public function stuff(): void {
                self::$conn->insert('users', ['id' => 1, 'name' => 'Larry']);
                // Do stuff ...
                $result = $this->conn->executeQuery('SELECT ...')->fetchOne();
                self::assertSame('Larry', $result);
              }
          }
        

Move utilities to traits


          trait UseDoctrine {
            private static Connection $conn;

            #[BeforeClass(20)]
            public static function setupDoctrine(): void {
              $connectionParams = new DsnParser()->parse('pdo-sqlite:///:memory:');
              self::$conn = DriverManager::getConnection($connectionParams);
            }
          }
          trait WithUserTable {
            use UseDoctrine;

           #[BeforeClass(10)]
            public static function setupTables(): void {
              self::$conn->executeQuery('CREATE TABLE users ...');
            }

            #[After]
            public function cleanupTables(): void {
              self::$conn->executeQuery('DELETE FROM users');
            }
          }
          class IntegrationTest extends TestCase {
              use UseDoctrine;
              use WithUserTable;

              // Tests here
          }
        

Assertion utilities


          trait ResponseAssertions {
            protected static function assertBody(?string $expectedBody, ResponseInterface $res): void {
              // Same as before
            }

            protected static function assertHeaders(array $expectedHeaders, ResponseInterface $res): void {
              // Same as before
            }
          }

          class RequestHandlerTest extends TestCase {
            use ResponseAssertions;

            #[Test, DataProvider('handlersProvider')]
            public function handlers(ServerRequest $request,
              int $expectedCode = 200, array $expectedHeaders = [], ?string $expectedBody = null,
              ): void {
              $handler = new RequestHandler();
              $res = $handler->handle($request);

              self::assertSame($expectedCode, $res->getStatusCode());
              self::assertBody($expectedBody, $res);
              self::assertHeaders($expectedHeaders, $res);
            }
          }
        

Transaction-based test isolation


          trait TransactionIsolation {
            use UseDoctrine;

            #[Before]
            public function startTransaction(): void {
                self::$conn->beginTransaction();
            }

            #[After]
            public function endTransaction(): void {
                self::$conn->rollBack();
            }
          }

          class IntegrationTest extends TestCase {
              use UseDoctrine;
              use TransactionIsolation;

              // Tests here
          }
        

I'm sure you can find other uses...

Documentation

Standard output


          $ vendor/bin/phpunit tests/Shapes/RectangleTest.php
          PHPUnit 13.0.5 by Sebastian Bergmann and contributors.

          Runtime:       PHP 8.4.11
          Configuration: /home/crell/Projects/shapes/phpunit.xml

          .........                                                           9 / 9 (100%)

          Time: 00:00.001, Memory: 14.00 MB

          OK (9 tests, 9 assertions)
        

With --testdox


          $ vendor/bin/phpunit tests/Shapes/RectangleTest.php --testdox
          PHPUnit 13.0.5 by Sebastian Bergmann and contributors.

          Runtime:       PHP 8.4.11
          Configuration: /home/crell/Projects/shapes/phpunit.xml

          .........                                                           9 / 9 (100%)

          Time: 00:00.001, Memory: 14.00 MB

          Rectangle (Crell\Shenanigans\Shapes\Rectangle)
            ✔ Area with provider with data set "2x2=4"
            ✔ Area with provider with data set "3x2=6"
            ✔ Area with provider with data set "2x3=6"
            ✔ Area with data set "2x2=4"
            ✔ Area with data set "3x2=6"
            ✔ Area with data set "2x3=6"
            ✔ Area of 2x 2is 4
            ✔ Area of 3x 2is 6
            ✔ Area of 2x 3is 6

          OK (9 tests, 9 assertions)
        

public function translateFormElementValueTranslateRenderingOptionForConcreteFormAndConcreteSectionElementIfElementRenderingOptionsContainsATranslationFilesAndElementRenderingOptionIsNotEmptyAndRenderingOptionShouldBeTranslatedAndTranslationExists(): void

WAT?

Yes, this is real code from TYPO3

Separate code from English


          #[Test]
          #[TestDox('If translations are enabled and there are translation files, they should be used')]
          public function translationsEnabledFiles(): void {
            // ...
          }
        

One-off test dox


          #[TestDox('Rectangles can compute their area')]
          class RectangleTest extends TestCase {

            #[Test, TestDox('A 2x2 rectangle has an area of 4')]
            public function areaOf2x2is4(): void {
              // ...
            }

            #[Test, TestDox('A 3x2 rectangle has an area of 6')]
            public function areaOf3x2is6(): void {
              // ...
            }

            #[Test, TestDox('A 2x3 rectangle has an area of 6')]
            public function areaOf2x3is6(): void {
              // ...
            }
          }
        

          $ vendor/bin/phpunit tests/Shapes/RectangleTest.php --testdox
          // ...
          Rectangles can compute their area
           ✔ A 2x2 rectangle has an area of 4
           ✔ A 3x2 rectangle has an area of 6
           ✔ A 2x3 rectangle has an area of 6

          OK (3 tests, 3 assertions)
        

Data providers and test dox


          #[TestDox('Rectangles can compute their area')]
          class RectangleTest extends TestCase {
            public static function areaProvider(): \Generator {
              yield '2x2=4' => [ ... ];
              yield '3x2=6' => [ ... ];
              yield '2x3=6' => [ ... ];
            }

            #[Test, DataProvider('areaProvider')]
            #[TestDox('A rectangle with height $height and width $width has an area of $expectedArea')]
            public function areaWithProvider(int $height, int $width, float $expectedArea): void {
              $r = new Rectangle($height, $width);
              self::assertSame($expectedArea, $r->area);
            }
        }
        

          $ vendor/bin/phpunit tests/Shapes/RectangleTest.php --testdox
          // ...
          Rectangles can compute their area
           ✔ A rectangle with height 2 and width 2 has an area of 4
           ✔ A rectangle with height 3 and width 2 has an area of 6
           ✔ A rectangle with height 2 and width 3 has an area of 6

          OK (3 tests, 3 assertions)
        

$_dataName to get the dataset name

Custom dox formatters


          #[Large, TestDox('RequestHandler returns expected response')]
          class RequestHandlerTest extends TestCase {
              #[Test, DataProvider('handlersProvider')]
              #[TestDoxFormatter('handlersFormatter')]
              public function handlers(
                  ServerRequest $request,
                  int $expectedCode = 200, array $expectedHeaders = [], ?string $expectedBody = null,
              ): void {
                  // ...
              }

              public static function handlersFormatter(
                  ServerRequest $request,
                  int $expectedCode = 200, array $expectedHeaders = [], ?string $expectedBody = null,
              ): string {
                  $formattedRequest = sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath());
                  return sprintf('%s responds with %d', $formattedRequest, $expectedCode);
              }

              public static function handlersProvider(): \Generator { ... }
          }
        

          $ vendor/bin/phpunit tests/Shapes/RectangleTest.php --testdox
          // ...
          RequestHandler returns expected response
           ✔ GET /missing responds with 404
           ✔ POST /create responds with 201
           ✔ GET /beep responds with 200

          OK (3 tests, 5 assertions)
        

Test organization

  • Test isolation is good
  • Sometimes you just need to hit the DB
  • Those tests will be slow

Based on a true story


          class Sync {
            public function __construct(
              private Client $client,
              private Connection $conn,
            ) {}

            public function getData(): string {
              $this->client->send('...');
              return $this->client->getResponse()->getBody()->toString();
            }

            public function saveData(string $data): void {
              $this->conn->insert('synced_data', ['data' => $data]);
            }

            public function cleanData(string $data): string {
              return strtolower($data);
            }
          }
        

These tests are rude


          class SyncTest extends TestCase {
              use UseDoctrine;
              private Sync $sync;

              #[Before]
              public function makeSubject(): void {
                  $this->sync = new Sync(new Client(), $this->conn);
              }

              #[Test]
              public function cleaning(): void {
                  self::assertSame('blah', $this->sync->cleanData('Blah'));
              }

              #[Test]
              public function writeToDb(): void {
                  $this->sync->saveData('...');
                  self::assertEquals('...', $this->conn->executeQuery("...")->fetchOne());
              }

              #[Test]
              public function getData(): void {
                  $data = $this->sync->getData();
                  // ...
              }
          }
        

These tests are polite


          #[Group('Sync')]
          class SyncTest extends TestCase {
              use UseDoctrine;
              private Sync $sync;

              #[Before]
              public function makeSubject(): void {
                  $this->sync = new Sync(new Client(), $this->conn);
              }

              #[Test]
              public function cleaning(): void {
                  self::assertSame('blah', $this->sync->cleanData('Blah'));
              }

              #[Test, Group('database')]
              public function writeToDb(): void {
                  $this->sync->saveData('...');
                  self::assertEquals('...', $this->conn->executeQuery("...")->fetchOne());
              }

              #[Test, Group('connected')]
              public function getData(): void {
                  $data = $this->sync->getData();
                  // ...
              }
          }
        

Run just some groups


          $ vendor/bin/phpunit tests/Groups/SyncTest.php
          PHPUnit 13.0.5 by Sebastian Bergmann and contributors.
          // ...
          ...                                                           3 / 3 (100%)

          Time: 00:00.001, Memory: 14.00 MB

          OK (3 tests, 3 assertions)
        

          $ vendor/bin/phpunit tests/Groups/SyncTest.php --group connected
          PHPUnit 13.0.5 by Sebastian Bergmann and contributors.
          // ...
          .                                                             1 / 1 (100%)

          Time: 00:00.001, Memory: 14.00 MB

          OK (1 tests, 1 assertions)
        

          $ vendor/bin/phpunit tests/Groups/SyncTest.php --exclude-group connected
          PHPUnit 13.0.5 by Sebastian Bergmann and contributors.
          // ...
          ..                                                            2 / 2 (100%)

          Time: 00:00.001, Memory: 14.00 MB

          OK (2 tests, 2 assertions)
        

Fully exclude in phpunit.xml


          
          
             
            
              
                connected
                database
              
            
          
        

Override with phpunit --group connected

#[Small], #[Medium], #[Large] are automatically groups as well

Apply liberally

Driver tests

New sample code


          class Runner {
            public function __construct(
              readonly private Backend $backend,
            ) {}

            public function run($stuff): string {
              return $this->backend->doStuff($stuff);
            }
          }

          interface Backend {
            public function doStuff(string $stuff): string;
          }

          class SqlBackend implements Backend {
            public function doStuff(string $stuff): string { /* ... */ }
          }

          class RedisBackend implements Backend {
            public function doStuff(string $stuff): string { /* ... */ }
          }
        

Test base class


          abstract class RunnerTestCases extends TestCase {

            protected Backend $backend;

            abstract function setupBackend(): void;

            public static function runProvider(): \Generator {
              yield ['a'];
              yield ['b'];
            }

            #[DataProvider('runProvider')]
            public function testRun(string $val): void {
              $runner = new Runner($this->backend);
              self::assertSame($val, $runner->run($val));
            }
          }
        

Driver-specific tests


          #[Group('sql'), Group('runner')]
          class SqlRunnerTest extends RunnerTestCases {
            #[Before]
            public function setupBackend(): void {
              $this->backend = new SqlBackend();
            }
          }

          #[Group('redis'), Group('runner')]
          class RedisRunnerTest extends RunnerTestCases {
            #[Before]
            public function setupBackend(): void {
              $this->backend = new RedisBackend();
            }
          }
        

Need a new driver?


          #[Group('memory'), Group('runner')]
          class MemoryRunnerTest extends RunnerTestCases {
            #[Before]
            public function setupBackend(): void {
              $this->backend = new MemoryBackend();
            }
          }

          class MemoryBackend implements Backend {
            public function doStuff(string $stuff): string {
              return $stuff;
            }
          }
        

A real-world example: Crell/Serde

(A bit simplified)

Core Serde class


          class SerdeCommon extends Serde {
            /** @var Formatter[] */
            protected readonly array $formatters;

            // ...

            public function __construct(
              // ...
              array $formatters = [],
            ) {
              // ...
              $this->formatters = array_filter($formatters typeIs(Formatter::class));
              $this->deformatters = array_filter($formatters typeIs(Deformatter::class));
            }
          }
        

Base tests class


          abstract class SerdeTestCases extends TestCase {
            protected array $formatters;
            protected string $format;

            abstract protected function arrayify(mixed $serialized): array;

            protected function validateSerialized(mixed $serialized, string $name): void {
              $validateMethod = str_replace([' ', ':', ','], '_', $name) . '_validate';
              if (method_exists($this, $validateMethod)) {
                $this->$validateMethod($serialized);
              }
            }

            #[Test, DataProvider('round_trip_examples')]
            #[DataProvider('value_object_flatten_examples')]
            // Lots more data providrs here...
            public function round_trip(object $data): void {
              $s = new SerdeCommon(formatters: $this->formatters);

              $serialized = $s->serialize($data, $this->format);

              // dataName() is marked internal, but seems stable enough.
              $this->validateSerialized($serialized, (string)$this->dataName());

              $result = $s->deserialize($serialized, from: $this->format, to: $data::class);

              self::assertEquals($data, $result);
            }
          }
        

Array-based validation inserts


          abstract class ArrayBasedFormatterTestCases extends SerdeTestCases {

            protected function point_validate(mixed $serialized): void {
              $toTest = $this->arrayify($serialized);

              self::assertEquals(1, $toTest['x']);
              self::assertEquals(2, $toTest['y']);
              self::assertEquals(3, $toTest['z']);
            }

            // ...
          }
        

The real test class


          #[Group('json'), Medium]
          class JsonFormatterTest extends ArrayBasedFormatterTestCases {
            #[Before]
            public function setupFormatter(): void {
              $this->formatters = [new JsonFormatter()];
              $this->format = 'json';
              // ...
            }

            protected function arrayify(mixed $serialized): array {
              return json_decode($serialized, true, 512, JSON_THROW_ON_ERROR);
            }
          }
        

Let's review

  • #[TestWith], #[DataProvider] make writing many tests easier
    • Generators, Test callbacks, Custom assertions
    • Name everything
  • Use #[Before] etc. always, never setUp()
  • Traits are good, actually! (In the right situation)
  • #[TestDox] are your friend
  • #[Group]s are your friend

PHPUnit is your friend!

* Who you are * Use PHPUnit * Believe in testing * Want to do it better * This talk is not * An intro to PHPUnit basics * An intro to the value of testing * Focused on PHPUnit 12/13 * Topics * Custom cache dir * S/M/L tests * Data providers * #[TestWith] * provider methods * use generators for provider methods, always * Name your test cases * Named arguments for provider methods * Pass extra validation as a test case argument * #[Before]\#[After] instead of setUp() * Traits for tests, better than subclasses * Package up Before/After pairs * Transaction start/rollback trait * Ensure schema is up to date trait * TestDox * Use #[Test] attribute, not test prefix. * #[TestDox] * #[TestDoxFormatter] * Base abstract test class with impl-specific subclasses. (cf, Serde) * Groups * Use for excluding tests that require network, but you still want sometimes in dev or CI * Can have many groups, so be liberal with them * * Mocks * Avoid them in favor of anon classes * If must use them, mocks vs stubs in PHPUnit (cf SB's article)

Larry Garfield

@Crell@phpc.social

All about PHP 8!

https://bit.ly/php80

Cover of Exploring PHP 8.0

Buy my book!

https://bit.ly/fn-php

Cover of Thinking Functionally in PHP

https://www.garfieldtech.com/