diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84dde22..0e33b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,24 @@ on: branches: [main] jobs: - tests: + unit-testing: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Test - run: docker compose run --rm tests \ No newline at end of file + - uses: actions/checkout@v3 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + extensions: xdebug + tools: composer:2 + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + - name: Composer + run: composer install + - name: Unit Testing + env: + XDEBUG_MODE: coverage + run: ./vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 046fe40..47d384b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ . .. +/build /vendor .phpunit.cache .phpunit.result.cache diff --git a/README.md b/README.md index f9f70fb..5574741 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,94 @@ -# DatabaseTestCase +# cspray/database-testing -A library to facilitate testing database interactions using PHPUnit 10+. +A low-level, framework-agnostic library for setting up a database suitable +for automated tests. This library provides the following features: -Features this library currently provides: +- The `Cspray\DatabaseTesting\ConnectionAdapter` interface that defines how this library, + and those that extend it, interact with your database. +- Comprehensive, customizable strategies for cleaning up your database to ensure each + test works with a known state. +- Declare fixtures, sets of database records, that are loaded for each test. +- A simple interface to easily introspect the contents of a database + table in your test suite. +- Database and testing-framework agnostic approach -- Handles typical database setup and teardown -- Simple representation of a table's rows -- Mechanism for loading fixture data specific to each test +It cannot be emphasized enough; this library does not provide a turn-key, usable solution +out-of-the-box. If you use this library directly, instead of one of the extensions that +targets a specific database connection and testing framework, you'll need to ensure the +appropriate concrete implementations and framework integration points are created. -Features this library **does not** currently provide, but plans to: +## Complete Libraries -- Semantic assertions on the state of a database -- Representation for the information schema of a given table +Instead of installing this library directly, we recommend that you install one of the +available options from the "Connection Adapter" and "Testing Extension" list. Chances are, +you'll need both. If you don't see your database connection type or testing framework +listed, please submit an issue to this repository! -The rest of this document details how to install this library, make use of its `TestCase`, and what database -connection objects are supported out-of-the-box. +### Connection Adapter -## Installation +- [cspray/database-testing-pdo](https://github.com/cspray/database-testing-pdo) -[Composer](https://getcomposer.org/) is the only supported method for installing this library. +### Testing Extension -``` -composer require --dev cspray/database-test-case -``` +- [cspray/database-testing-phpunit](https://github.com/cspray/database-testing-phpunit) -## Usage Guide - -Using this library starts by creating a PHPUnit test that extends `Cspray\DatabaseTestCase\DatabaseTestCase`. This class -overrides various setup and teardown functions provided by PHPUnit to ensure that a database connection is established -and that database interactions happen against a known state. The `DatabaseTestCase` requires implementations -to provide a `Cspray\DatabaseTestCase\ConnectionAdapter`. This implementation is ultimately responsible for calls to the -database required by the testing framework. The `ConnectionAdapter` also provides access to the underlying connection, -for example a `PDO` instance, that you can use in your code under test. Check out the section titled "Database Connections" -for `ConnectionAdapter` instances supported out-of-the-box and how you could implement your own. - -In our example, going to assume that you have a PostgreSQL database with a table that has -the following DDL: - -```postgresql -CREATE TABLE my_table ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username VARCHAR(255), - email VARCHAR(255), - is_active BOOLEAN -) -``` +## Quick Example -Now, we can write a series of tests that interact with the database. +This example is intended to reflect what should be capable with this library. We're going to +use PHPUnit as our testing framework, it is ubiquitous and likely the framework you'll start off +using with this library. ```php getTable('my_table'); - - // The $table is Countable, the count represents the number of rows in the table - self::assertCount(0, $table); - - // The $table is iterable, each iteration yields a Row, but our database is empty! - self::assertSame([], iterator_to_array($table)); + // you could also use Cspray\DatabaseTesting\DatabaseCleanup\TruncateTables + // or implement your own Cspray\DatabaseTesting\DatabaseCleanup\CleanupStrategy + new TransactionWithRollback() +)] +final class RepositoryTest extends TestCase { + + private PDO $pdo; + private MyRepository $myRepository; + + protected function setUp() : void { + // be sure to use the connection from TestDatabase! depending on CleanupStrategy, + // using a different connection could wind up with a dirty database state + $this->pdo = TestDatabase::connection(); + $this->myRepository = new MyRepository($this->pdo); } - // Pass any number of Fixture to have corresponding FixtureRecords inserted into - // the database before your test starts + // populate with more appropriate data. recommended to implement your own + // Cspray\DatabaseTesting\Fixture\Fixture to reuse datasets across tests #[LoadFixture( - new SingleRecordFixture('my_table', ['username' => 'cspray', 'email' => 'cspray@example.com', 'is_active' => true]), - new SingleRecordFixture('my_table', ['username' => 'dyana', 'email' => 'dyana@example.com', 'is_active' => true]) + new SingleRecordFixture('my_table', [ + 'name' => 'cspray', + 'website' => 'https://cspray.io' + ]) )] - public function testLoadingFixtures() : void { - $table = $this->getTable('my_table'); + public function testTableHasCorrectlyLoadedFixtures() : void { + $table = TestDatabase::table('my_table'); - self::assertCount(2, $table); - self::assertContainsOnlyInstancesOf(Row::class, iterator_to_array($table)); - self::assertSame('cspray', $table->getRow(0)->get('username')); - self::assertSame('dyana@example.com', $table->getRow(1)->get('email')); - self::assertNull($table->getRow(2)); + self::assertCount(1, $table); + + self::assertSame('cspray', $table->row(0)->get('name')) + self::assertSame('website', $table->row(0)->get('website')); } - + } ``` -### TestCase Hooks - -There are several critical things the `DatabaseTestCase` must take care of for database tests to work properly. To do that -we must do something in all the normally used PHPUnit `TestCase` hooks. To be clear those methods are: - -- `TestCase::setUpBeforeClass` -- `TestCase::setUp` -- `TestCase::tearDown` -- `TestCase::tearDownAfterClass` - -To make sure that `DatabaseTestCase` processes these hooks correctly they have been marked as `final`. There are new -methods that have been provided that allow for the same effective hooks. - -| Old Hook | New Hook | -| --- |--------------------------------| -| `TestCase::setUpBeforeClass` | `DatabaseTestCase::beforeAll` | -| `TestCase::setUp` | `DatabaseTestCase::beforeEach` | -| `TestCase::tearDown` | `DatabaseTestCase::afterEach` | -| `TestCase::tearDownAfterClass` | `DatabaseTestCase::afterAll` | - -## Database Connections - -| Connection Adapter | Connection Instance | Library | Database | Implemented | -|--------------------------------------------------------|-----------------------------|-----------------------------------|-----------|------------| -| `Cspray\DatabaseTestCase\PdoConnectionAdapter` | `PDO` | [PHP PDO][pdo] | PostgreSQL | :white_check_mark: | -| `Cspray\DatabaseTestCase\PdoConnectionAdapter` | `PDO` | [PHP PDO][pdo] | MySQL | :white_check_mark: | -| `Cspray\DatabaseTestCase\AmpPostgresConnectionAdapter` | `Amp\Postgres\PostgresLink` | [amphp/postgres@^2][amp-postgres] | PostgreSQL | :white_check_mark: | -| | `Amp\Mysql\MysqlLink` | [amphp/mysql@^3][amp-mysql] | MySQL | :x: | -[amp-mysql]: https://github.com/amphp/mysql -[amp-postgres]: https://github.com/amphp/postgres -[pdo]: https://php.net/pdo \ No newline at end of file diff --git a/composer.json b/composer.json index cb42dcb..7f9b300 100644 --- a/composer.json +++ b/composer.json @@ -1,36 +1,29 @@ { - "name": "cspray/database-test-case", - "description": "A PHPUnit TestCase for asserting expectations on a database", + "name": "cspray/database-testing", + "description": "", "type": "library", "keywords": [ "testing", "database" ], "license": ["MIT"], - "minimum-stability": "beta", "prefer-stable": true, "require": { - "php": "^8.2", - "phpunit/phpunit": "^10.0" + "php": "^8.3" }, "require-dev": { - "ext-pdo": "*", - "ext-pdo_pgsql": "*", - "amphp/postgres": "^v2.0.0-beta.2", + "phake/phake": "^4.5", + "phpunit/phpunit": "^11.0", "roave/security-advisories": "dev-latest" }, "autoload": { "psr-4": { - "Cspray\\DatabaseTestCase\\": "src" + "Cspray\\DatabaseTesting\\": "src" } }, "autoload-dev": { "psr-4": { - "Cspray\\DatabaseTestCase\\Tests\\": "tests" + "Cspray\\DatabaseTesting\\Tests\\": "tests" } - }, - "suggest": { - "ext-pdo": "To enable the PdoConnectionAdapter", - "amphp/postgres": "To enable the AmpPostgresConnectionAdapter" } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index c8a2a59..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -version: '3.8' - -services: - postgres: - build: - context: . - dockerfile: docker/postgres/Dockerfile - volumes: - - pgdata:/var/lib/postgresql/data - restart: unless-stopped - environment: - - POSTGRES_PASSWORD=postgres - networks: - databasetestcase: - - mysql: - build: - context: . - dockerfile: docker/mysql/Dockerfile - volumes: - - mysqldata:/var/lib/mysql - restart: unless-stopped - environment: - - MYSQL_DATABASE=mysql - - MYSQL_USER=mysql - - MYSQL_PASSWORD=mysql - - MYSQL_ROOT_PASSWORD=mysql - networks: - databasetestcase: - - tests: - build: - context: . - dockerfile: docker/php/Dockerfile - target: app - depends_on: - postgres: - condition: service_healthy - mysql: - condition: service_healthy - volumes: - - ./src:/app/src - - ./tests:/app/tests - - ./resources:/app/resources - - ./phpunit.xml:/app/phpunit.xml - - ./composer.json:/app/composer.json - networks: - databasetestcase: - -networks: - databasetestcase: - -volumes: - mysqldata: - pgdata: \ No newline at end of file diff --git a/docker/mysql/Dockerfile b/docker/mysql/Dockerfile deleted file mode 100644 index 37602b9..0000000 --- a/docker/mysql/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM mysql:8-debian - -COPY /resources/schemas/mysql.sql /docker-entrypoint-initdb.d/ - -HEALTHCHECK --interval=5s --start-period=7s --retries=5 --timeout=5s CMD mysqladmin ping -h localhost - -USER mysql diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile deleted file mode 100644 index 01a4c23..0000000 --- a/docker/php/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM php:8.3-zts-bullseye AS php - -RUN apt-get update -y \ - && apt-get upgrade -y - -RUN apt-get install git libsodium-dev libzip-dev libpq-dev -y -RUN docker-php-ext-install sodium zip pdo pdo_pgsql pdo_mysql pgsql - -RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" - -COPY --from=composer:2.1 /usr/bin/composer /usr/bin/composer - -FROM php AS app - -RUN mkdir /app -COPY src /app/src -COPY tests /app/tests -COPY composer.json phpunit.xml /app/ - -WORKDIR /app - -RUN COMPOSER_ALLOW_SUPERUSER=1 composer validate \ - && composer install --no-scripts - -CMD ["/app/vendor/bin/phpunit"] diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile deleted file mode 100644 index 943a1e5..0000000 --- a/docker/postgres/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM postgres:15-bullseye - -COPY /resources/schemas/postgres.sql /docker-entrypoint-initdb.d/ - -HEALTHCHECK --interval=5s --start-period=7s --retries=5 --timeout=5s CMD pg_isready -d postgres - -USER postgres \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index fbd9f20..0fb0a3b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,12 +1,18 @@ - + tests/Unit - - tests/Integration - diff --git a/resources/schemas/mysql.sql b/resources/schemas/mysql.sql deleted file mode 100644 index 3b157fa..0000000 --- a/resources/schemas/mysql.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE my_table ( - id INT PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(255), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL -); \ No newline at end of file diff --git a/resources/schemas/postgres.sql b/resources/schemas/postgres.sql deleted file mode 100644 index 4c2d62f..0000000 --- a/resources/schemas/postgres.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE my_table ( - id SERIAL PRIMARY KEY, - name VARCHAR(255), - created_at TIMESTAMP DEFAULT now() -); \ No newline at end of file diff --git a/resources/schemas/sqlite.sql b/resources/schemas/sqlite.sql deleted file mode 100644 index 3961f95..0000000 --- a/resources/schemas/sqlite.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE my_table ( - id INTEGER PRIMARY KEY, - name VARCHAR(255), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL -); \ No newline at end of file diff --git a/src/AbstractConnectionAdapter.php b/src/AbstractConnectionAdapter.php deleted file mode 100644 index 1d6247a..0000000 --- a/src/AbstractConnectionAdapter.php +++ /dev/null @@ -1,67 +0,0 @@ -getFixtureRecords() as $fixtureRecord) { - $sql = $this->generateInsertSqlForParameters($fixtureRecord); - $parameters = $fixtureRecord->parameters; - $this->executeInsertSql($sql, $parameters); - } - } - } - - final public function getTable(string $name) : Table { - try { - $table = Table::forName($name); - foreach ($this->executeSelectAllSql($name) as $row) { - $r = null; - foreach ($row as $col => $val) { - $r = $r === null ? Row::forValue($col, $val) : $r->withValue($col, $val); - } - $table = $table->withRow($r); - } - return $table; - } catch (Throwable $throwable) { - throw new UnableToGetTable( - message: sprintf('Unable to fetch table "%s", please check previous Exception for more details.', $name), - previous: $throwable - ); - } - } - - protected function generateInsertSqlForParameters(FixtureRecord $fixtureRecord) : string { - $table = $fixtureRecord->table; - $parameters = $fixtureRecord->parameters; - $colsString = implode( - ', ', - array_keys($parameters) - ); - $paramString = implode( - ', ', - array_map(static fn(string $col) => ':' . $col, array_keys($parameters)) - ); - return <<> - * @throws Throwable - */ - abstract protected function executeSelectAllSql(string $table) : array; - -} diff --git a/src/AmpPostgresConnectionAdapter.php b/src/AmpPostgresConnectionAdapter.php deleted file mode 100644 index 6400354..0000000 --- a/src/AmpPostgresConnectionAdapter.php +++ /dev/null @@ -1,73 +0,0 @@ - connect( - PostgresConfig::fromString(sprintf( - 'db=%s host=%s port=%d user=%s pass=%s', - $config->database, - $config->host, - $config->port, - $config->user, - $config->password - )) - )); - } - - public static function fromExistingConnection(PostgresLink $link) : self { - return new self(fn() => $link); - } - - public function establishConnection() : void { - $this->connection = ($this->connectionFactory)(); - } - - public function onTestStart() : void { - $this->connection->query('START TRANSACTION'); - } - - public function onTestStop() : void { - $this->connection->query('ROLLBACK'); - } - - public function closeConnection() : void { - $this->connection->close(); - $this->connection = null; - } - - public function getUnderlyingConnection() : PostgresLink { - return $this->connection; - } - - protected function executeInsertSql(string $sql, array $parameters) : void { - $this->connection->execute($sql, $parameters); - } - - protected function executeSelectAllSql(string $table) : array { - $result = $this->connection->query(sprintf('SELECT * FROM %s', $table)); - $rows = []; - while ($row = $result->fetchRow()) { - $rows[] = $row; - } - return $rows; - } -} diff --git a/src/ConnectionAdapter.php b/src/ConnectionAdapter.php deleted file mode 100644 index 407e12b..0000000 --- a/src/ConnectionAdapter.php +++ /dev/null @@ -1,23 +0,0 @@ - $fixtures + */ + public function insert(array $fixtures) : void; + + public function selectAll(string $name) : Table; + +} \ No newline at end of file diff --git a/src/ConnectionAdapterConfig.php b/src/ConnectionAdapter/ConnectionAdapterConfig.php similarity index 62% rename from src/ConnectionAdapterConfig.php rename to src/ConnectionAdapter/ConnectionAdapterConfig.php index e137e16..84b58d5 100644 --- a/src/ConnectionAdapterConfig.php +++ b/src/ConnectionAdapter/ConnectionAdapterConfig.php @@ -1,7 +1,12 @@ + */ + public function createConnectionAdapter() : ConnectionAdapter; + +} diff --git a/src/DatabaseAwareTest.php b/src/DatabaseAwareTest.php new file mode 100644 index 0000000..bd8d85d --- /dev/null +++ b/src/DatabaseAwareTest.php @@ -0,0 +1,19 @@ + + */ + public function fixtures() : array; + +} \ No newline at end of file diff --git a/src/DatabaseCleanup/CleanupStrategy.php b/src/DatabaseCleanup/CleanupStrategy.php new file mode 100644 index 0000000..ff41766 --- /dev/null +++ b/src/DatabaseCleanup/CleanupStrategy.php @@ -0,0 +1,14 @@ +class(), $test->method()); + + return [ + ...$this->beforeFixtureTables($test, $reflection), + ...$this->fixtureTables($test), + ...$this->afterFixtureTables($test, $reflection) + ]; + } + + private function beforeFixtureTables(DatabaseAwareTest $test, ReflectionMethod $reflection) : array { + $forceTruncateBeforeAttributes = $reflection->getAttributes(ForceTruncateBeforeFixtureTables::class); + + $beforeFixtureTables = []; + if ($forceTruncateBeforeAttributes !== []) { + $beforeFixtureTables = $forceTruncateBeforeAttributes[0]->newInstance()->tablesToTruncate->tables($test); + } + + return $beforeFixtureTables; + } + + private function fixtureTables(DatabaseAwareTest $test) : array { + $fixtureTables = []; + foreach ($test->fixtures() as $fixture) { + foreach ($fixture->records() as $fixtureRecord) { + $fixtureTables[] = $fixtureRecord->table; + } + } + return array_reverse(array_unique($fixtureTables)); + } + + private function afterFixtureTables(DatabaseAwareTest $test, ReflectionMethod $reflection) : array { + $forceTruncateAfterAttributes = $reflection->getAttributes(ForceTruncateAfterFixtureTables::class); + + $afterFixtureTables = []; + if ($forceTruncateAfterAttributes !== []) { + $afterFixtureTables = $forceTruncateAfterAttributes[0]->newInstance()->tablesToTruncate->tables($test); + } + + return $afterFixtureTables; + } + +} diff --git a/src/DatabaseCleanup/ForceTruncateAfterFixtureTables.php b/src/DatabaseCleanup/ForceTruncateAfterFixtureTables.php new file mode 100644 index 0000000..9e422d1 --- /dev/null +++ b/src/DatabaseCleanup/ForceTruncateAfterFixtureTables.php @@ -0,0 +1,12 @@ + */ + private array $tables, + ) {} + + public function tables(DatabaseAwareTest $test) : array { + return $this->tables; + } +} \ No newline at end of file diff --git a/src/DatabaseCleanup/TablesToTruncate.php b/src/DatabaseCleanup/TablesToTruncate.php new file mode 100644 index 0000000..d44d06a --- /dev/null +++ b/src/DatabaseCleanup/TablesToTruncate.php @@ -0,0 +1,14 @@ + + */ + public function tables(DatabaseAwareTest $test) : array; + +} \ No newline at end of file diff --git a/src/DatabaseCleanup/TransactionWithRollback.php b/src/DatabaseCleanup/TransactionWithRollback.php new file mode 100644 index 0000000..8f58dbd --- /dev/null +++ b/src/DatabaseCleanup/TransactionWithRollback.php @@ -0,0 +1,20 @@ +beginTransaction(); + } + + #[Override] + public function teardownAfterTest(DatabaseAwareTest $test, ConnectionAdapter $connectionAdapter) : void { + $connectionAdapter->rollback(); + } +} \ No newline at end of file diff --git a/src/DatabaseCleanup/TruncateTables.php b/src/DatabaseCleanup/TruncateTables.php new file mode 100644 index 0000000..2c9ec68 --- /dev/null +++ b/src/DatabaseCleanup/TruncateTables.php @@ -0,0 +1,25 @@ +tablesToTruncate->tables($test) as $table) { + $connectionAdapter->truncateTable($table); + } + } + + #[Override] + public function teardownAfterTest(DatabaseAwareTest $test, ConnectionAdapter $connectionAdapter) : void { + } +} \ No newline at end of file diff --git a/src/DatabaseRepresentation/Row.php b/src/DatabaseRepresentation/Row.php index 5a506a6..0dc2fa3 100644 --- a/src/DatabaseRepresentation/Row.php +++ b/src/DatabaseRepresentation/Row.php @@ -1,33 +1,18 @@ */ -final class Row implements IteratorAggregate { +interface Row extends IteratorAggregate { - private function __construct( - private readonly array $columnValues - ) {} + public function get(string $name) : mixed; - public static function forValue(string $name, mixed $value) : self { - return new self([$name => $value]); - } - - public function withValue(string $name, mixed $value) : self { - return new self([...$this->columnValues, $name => $value]); - } - - public function get(string $name) : mixed { - return $this->columnValues[$name]; - } - - public function getIterator() : Traversable { - yield from $this->columnValues; - } + public function getIterator() : Traversable; } diff --git a/src/DatabaseRepresentation/Table.php b/src/DatabaseRepresentation/Table.php index 139b105..afd017a 100644 --- a/src/DatabaseRepresentation/Table.php +++ b/src/DatabaseRepresentation/Table.php @@ -1,6 +1,6 @@ */ -final class Table implements Countable, IteratorAggregate { +interface Table extends Countable, IteratorAggregate { - private function __construct( - private readonly string $name, - private readonly array $rows = [] - ) {} + public function name() : string; - public static function forName(string $name) : self { - return new self($name); - } + public function row(int $index) : ?Row; - public function withRow(Row $row) : self { - return new self($this->name, [...$this->rows, $row]); - } + public function reload() : void; - public function getName() : string { - return $this->name; - } - - public function getRow(int $index) : ?Row { - return $this->rows[$index] ?? null; - } - - public function getIterator() : Traversable { - yield from $this->rows; - } - - public function count() : int { - return count($this->rows); - } } diff --git a/src/DatabaseTestCase.php b/src/DatabaseTestCase.php deleted file mode 100644 index ffb4b7b..0000000 --- a/src/DatabaseTestCase.php +++ /dev/null @@ -1,75 +0,0 @@ -getUnderlyingConnection(); - } - - final public static function setUpBeforeClass() : void { - parent::setUpBeforeClass(); - self::$connectionAdapter = static::getConnectionAdapter(); - self::$connectionAdapter->establishConnection(); - static::beforeAll(); - } - - protected static function beforeAll() : void { - - } - - final protected function setUp() : void { - parent::setUp(); - self::$connectionAdapter->onTestStart(); - $reflectionMethod = new ReflectionMethod($this, $this->name()); - $loadFixtureAttr = $reflectionMethod->getAttributes(LoadFixture::class, \ReflectionAttribute::IS_INSTANCEOF); - if ($loadFixtureAttr !== []) { - $loadFixture = $loadFixtureAttr[0]->newInstance(); - assert($loadFixture instanceof LoadFixture); - self::$connectionAdapter->loadFixture(...$loadFixture->fixtures); - } - $this->beforeEach(); - } - - protected function beforeEach() : void { - - } - - final protected function tearDown() : void { - parent::tearDown(); - $this->afterEach(); - self::$connectionAdapter->onTestStop(); - } - - protected function afterEach() : void { - - } - - final public static function tearDownAfterClass() : void { - parent::tearDownAfterClass(); - static::afterAll(); - self::$connectionAdapter->closeConnection(); - self::$connectionAdapter = null; - } - - protected static function afterAll() : void { - } - - final protected function getTable(string $name) : Table { - return self::$connectionAdapter->getTable($name); - } - - abstract protected static function getConnectionAdapter() : ConnectionAdapter; - -} diff --git a/src/Exception/ConnectionAlreadyEstablished.php b/src/Exception/ConnectionAlreadyEstablished.php new file mode 100644 index 0000000..bf36cc2 --- /dev/null +++ b/src/Exception/ConnectionAlreadyEstablished.php @@ -0,0 +1,16 @@ + */ - public function getFixtureRecords() : array; + public function records() : array; } diff --git a/src/FixtureRecord.php b/src/Fixture/FixtureRecord.php similarity index 81% rename from src/FixtureRecord.php rename to src/Fixture/FixtureRecord.php index 3e4a644..96585bc 100644 --- a/src/FixtureRecord.php +++ b/src/Fixture/FixtureRecord.php @@ -1,6 +1,6 @@ */ + /** @var Fixture */ public readonly array $fixtures; public function __construct( diff --git a/src/SingleRecordFixture.php b/src/Fixture/SingleRecordFixture.php similarity index 53% rename from src/SingleRecordFixture.php rename to src/Fixture/SingleRecordFixture.php index 6f6a09d..299f1bc 100644 --- a/src/SingleRecordFixture.php +++ b/src/Fixture/SingleRecordFixture.php @@ -1,8 +1,8 @@ table)) { - throw new InvalidFixture('A valid table name must be provided when using ' . self::class); + throw InvalidFixture::fromEmptyTableName(); } if (empty($this->record)) { - throw new InvalidFixture('A valid, non-empty record must be provided when using ' . self::class); + throw InvalidFixture::fromEmptyRow(); } } - public function getFixtureRecords() : array { + public function records() : array { return [ new FixtureRecord($this->table, $this->record) ]; diff --git a/src/Internal/ClosureDataProviderTable.php b/src/Internal/ClosureDataProviderTable.php new file mode 100644 index 0000000..5cc3e9b --- /dev/null +++ b/src/Internal/ClosureDataProviderTable.php @@ -0,0 +1,70 @@ + + */ + private array $rows = []; + + /** + * @param Closure():list> $dataProvider + */ + public function __construct( + private readonly string $name, + private readonly Closure $dataProvider + ) { + $this->reload(); + } + + private function createRow(array $record) : Row { + return new class($record) implements Row { + + public function __construct( + private readonly array $record, + ) {} + + public function get(string $name) : mixed { + return $this->record[$name]; + } + + public function getIterator() : Traversable { + yield from $this->record; + } + }; + } + + public function getIterator() : Traversable { + yield from $this->rows; + } + + public function count() : int { + return count($this->rows); + } + + public function name() : string { + return $this->name; + } + + public function row(int $index) : ?Row { + return $this->rows[$index] ?? null; + } + + public function reload() : void { + $this->rows = []; + $records = ($this->dataProvider)(); + foreach ($records as $record) { + $this->rows[] = $this->createRow($record); + } + } +} \ No newline at end of file diff --git a/src/Internal/ConnectionAdapterTestCase.php b/src/Internal/ConnectionAdapterTestCase.php new file mode 100644 index 0000000..3773fc4 --- /dev/null +++ b/src/Internal/ConnectionAdapterTestCase.php @@ -0,0 +1,200 @@ + + */ + protected ConnectionAdapter $connectionAdapter; + + /** + * @return ConnectionAdapter + */ + protected abstract function connectionAdapter() : ConnectionAdapter; + + /** + * @return class-string + */ + protected abstract function expectedConnectionType() : string; + + protected abstract function assertConnectionClosed() : void; + + /** + * @param non-empty-string $table + * @return list> + */ + protected abstract function executeSelectSql(string $sql) : array; + + protected abstract function executeDeleteSql(string $sql) : void; + + protected function setUp() : void { + $this->connectionAdapter = $this->connectionAdapter(); + } + + public function testGettingConnectionBeforeEstablishingConnectionThrowsError() : void { + $this->expectException(ConnectionNotEstablished::class); + $this->expectExceptionMessage( + 'A connection to the test database MUST be established before invoking ' . $this->connectionAdapter::class . '::underlyingConnection' + ); + + $this->connectionAdapter->underlyingConnection(); + } + + public function testGettingConnectionAfterEstablishingConnectionHasCorrectType() : void { + $this->connectionAdapter->establishConnection(); + + self::assertInstanceOf( + $this->expectedConnectionType(), + $this->connectionAdapter->underlyingConnection() + ); + } + + public function testClosingConnectionBeforeEstablishConnectionThrowsError() : void { + $this->expectException(ConnectionNotEstablished::class); + $this->expectExceptionMessage( + 'A connection to the test database MUST be established before invoking ' . $this->connectionAdapter::class . '::closeConnection' + ); + + $this->connectionAdapter->closeConnection(); + } + + public function testClosingConnectionAfterEstablishingConnectionProperlyClosesConnection() : void { + $this->connectionAdapter->establishConnection(); + $this->connectionAdapter->closeConnection(); + + $this->assertConnectionClosed(); + } + + public function testInsertBeforeEstablishingConnectionThrowsError() : void { + $this->expectException(ConnectionNotEstablished::class); + $this->expectExceptionMessage( + 'A connection to the test database MUST be established before invoking ' . $this->connectionAdapter::class . '::insert' + ); + + $this->connectionAdapter->insert([ + new SingleRecordFixture('my_table', ['name' => 'Harry']) + ]); + } + + public function testInsertAfterEstablishingConnectionResultsInAppropriateRecords() : void { + $this->connectionAdapter->establishConnection(); + + $sql = 'SELECT * FROM my_table'; + $records = $this->executeSelectSql($sql); + + self::assertEmpty($records); + + $this->connectionAdapter->insert([ + new SingleRecordFixture('my_table', ['name' => 'Harry']), + new SingleRecordFixture('my_table', ['name' => 'Mack']), + new SingleRecordFixture('my_table', ['name' => 'Sterling']) + ]); + + $insertedRecords = $this->executeSelectSql($sql); + + self::assertCount(3, $insertedRecords); + self::assertSame(['Harry', 'Mack', 'Sterling'], array_column($insertedRecords, 'name')); + + $this->executeDeleteSql('DELETE FROM my_table'); + + self::assertEmpty($this->executeSelectSql($sql)); + } + + public function testBeginTransactionAndRollbackResultsInRecordsNotPersisted() : void { + $this->connectionAdapter->establishConnection(); +// + $sql = 'SELECT * FROM my_table'; +// $records = $this->executeSelectSql($sql); +// +// self::assertEmpty($records); + + $this->connectionAdapter->beginTransaction(); + + $this->connectionAdapter->insert([ + new SingleRecordFixture('my_table', ['name' => 'Harry']), + new SingleRecordFixture('my_table', ['name' => 'Mack']), + new SingleRecordFixture('my_table', ['name' => 'Sterling']) + ]); + + $insertedRecords = $this->executeSelectSql($sql); + + self::assertCount(3, $insertedRecords); + + $this->connectionAdapter->rollback(); + + self::assertEmpty($this->executeSelectSql($sql)); + } + + public function testTruncateTableEnsuresTableIsClearedOfRecords() : void { + $this->connectionAdapter->establishConnection(); + + $sql = 'SELECT * FROM my_table'; + $records = $this->executeSelectSql($sql); + + self::assertEmpty($records); + + $this->connectionAdapter->insert([ + new SingleRecordFixture('my_table', ['name' => 'Harry']), + new SingleRecordFixture('my_table', ['name' => 'Mack']), + new SingleRecordFixture('my_table', ['name' => 'Sterling']) + ]); + + $insertedRecords = $this->executeSelectSql($sql); + + self::assertCount(3, $insertedRecords); + + $this->connectionAdapter->truncateTable('my_table'); + + self::assertEmpty($this->executeSelectSql($sql)); + } + + public function testFetchingTableFromConnectionAdapterAllowsInspectingCorrectRecords() : void { + $this->connectionAdapter->establishConnection(); + + $sql = 'SELECT * FROM my_table'; + $records = $this->executeSelectSql($sql); + + self::assertEmpty($records); + + $this->connectionAdapter->insert([ + new SingleRecordFixture('my_table', ['name' => 'Harry']), + ]); + + $table = $this->connectionAdapter->selectAll('my_table'); + + self::assertSame('my_table', $table->name()); + self::assertCount(1, $table); + self::assertSame('Harry', $table->row(0)->get('name')); + + $this->connectionAdapter->insert([ + new SingleRecordFixture('my_table', ['name' => 'Mack']), + ]); + + $table->reload(); + + self::assertSame('my_table', $table->name()); + self::assertCount(2, $table); + self::assertSame('Harry', $table->row(0)->get('name')); + self::assertSame('Mack', $table->row(1)->get('name')); + + $this->connectionAdapter->truncateTable('my_table'); + + $table->reload(); + + self::assertSame('my_table', $table->name()); + self::assertCount(0, $table); + } + + +} \ No newline at end of file diff --git a/src/Internal/FixtureAttributeAwareDatabaseTest.php b/src/Internal/FixtureAttributeAwareDatabaseTest.php new file mode 100644 index 0000000..0819d23 --- /dev/null +++ b/src/Internal/FixtureAttributeAwareDatabaseTest.php @@ -0,0 +1,49 @@ +getAttributes(LoadFixture::class); + $fixtures = []; + if ($loadFixtureReflections !== []) { + // there can only be one LoadFixture because it is not repeatable + $fixtures = $loadFixtureReflections[0]->newInstance()->fixtures; + } + + return new self($class, $method, $fixtures); + } + + #[Override] + public function class() : string { + return $this->class; + } + + #[Override] + public function method() : string { + return $this->method; + } + + #[Override] + public function fixtures() : array { + return $this->fixtures; + } +} \ No newline at end of file diff --git a/src/PdoConnectionAdapter.php b/src/PdoConnectionAdapter.php deleted file mode 100644 index e7e8072..0000000 --- a/src/PdoConnectionAdapter.php +++ /dev/null @@ -1,65 +0,0 @@ -dsn($adapterConfig)), $pdoDriver); - } - - public static function fromExistingConnection(PDO $pdo, PdoDriver $pdoDriver) : self { - return new self(static fn() => $pdo, $pdoDriver); - } - - public function establishConnection() : void { - $this->connection = ($this->pdoSupplier)(); - } - - public function onTestStart() : void { - $this->connection->query($this->pdoDriver->startTransactionSql()); - } - - public function onTestStop() : void { - $this->connection->query('ROLLBACK'); - } - - public function closeConnection() : void { - unset($this->connection); - $this->connection = null; - } - - - public function getUnderlyingConnection() : PDO { - return $this->connection; - } - - - protected function executeInsertSql(string $sql, array $parameters) : void { - $statement = $this->getUnderlyingConnection()->prepare($sql); - foreach ($parameters as $col => $val) { - $statement->bindValue($col, $val); - } - $statement->execute(); - } - - protected function executeSelectAllSql(string $table) : array { - $query = sprintf('SELECT * FROM %s', $table); - return $this->connection->query($query)->fetchAll(PDO::FETCH_ASSOC); - } -} diff --git a/src/PdoDriver.php b/src/PdoDriver.php deleted file mode 100644 index 07f6647..0000000 --- a/src/PdoDriver.php +++ /dev/null @@ -1,43 +0,0 @@ - sprintf( - 'sqlite:%s', - $adapterConfig->host - ), - default => sprintf( - '%s:host=%s;port=%d;dbname=%s;user=%s;password=%s', - $this->dsnIdentifier(), - $adapterConfig->host, - $adapterConfig->port, - $adapterConfig->database, - $adapterConfig->user, - $adapterConfig->password - ), - }; - - } - - private function dsnIdentifier() : string { - return match ($this) { - self::Postgresql => 'pgsql', - self::Mysql => 'mysql', - self::Sqlite => 'sqlite', - }; - } - - public function startTransactionSql() : string { - return match ($this) { - self::Sqlite => 'BEGIN TRANSACTION', - default => 'START TRANSACTION', - }; - } -} \ No newline at end of file diff --git a/src/RequiresTestDatabase.php b/src/RequiresTestDatabase.php new file mode 100644 index 0000000..b3581d6 --- /dev/null +++ b/src/RequiresTestDatabase.php @@ -0,0 +1,24 @@ +connectionAdapterFactory; + } + + public function cleanupStrategy() : CleanupStrategy { + return $this->cleanupStrategy; + } +} \ No newline at end of file diff --git a/src/RequiresTestDatabaseSettings.php b/src/RequiresTestDatabaseSettings.php new file mode 100644 index 0000000..94bfb87 --- /dev/null +++ b/src/RequiresTestDatabaseSettings.php @@ -0,0 +1,14 @@ +connectionAdapterFactory(), + $requiresTestDatabase->cleanupStrategy() + ); + } + + /** + * Allow for introspection of a database table. + * + * @param non-empty-string $name + * @return Table + */ + public static function table(string $name) : Table { + self::verifyConnectionEstablished(__METHOD__); + return self::$connectionAdapter->selectAll($name); + } + + /** + * @template UnderlyingConnection of object + * @return UnderlyingConnection + */ + public static function connection() : object { + self::verifyConnectionEstablished(__METHOD__); + return self::$connectionAdapter->underlyingConnection(); + } + + public function establishConnection() : void { + if (self::$connectionAdapter !== null) { + throw ConnectionAlreadyEstablished::fromConnectionAlreadyEstablished(); + } + self::$connectionAdapter = $this->connectionAdapterFactory->createConnectionAdapter(); + self::$connectionAdapter->establishConnection(); + } + + public function prepareForTest(DatabaseAwareTest $databaseAwareTest) : void { + self::verifyConnectionEstablished(__METHOD__); + $this->cleanupStrategy->cleanupBeforeTest($databaseAwareTest, self::$connectionAdapter); + self::$connectionAdapter->insert($databaseAwareTest->fixtures()); + } + + public function cleanupAfterTest(DatabaseAwareTest $databaseAwareTest) : void { + self::verifyConnectionEstablished(__METHOD__); + $this->cleanupStrategy->teardownAfterTest($databaseAwareTest, self::$connectionAdapter); + } + + public function closeConnection() : void { + self::verifyConnectionEstablished(__METHOD__); + self::$connectionAdapter->closeConnection(); + self::$connectionAdapter = null; + } + + private static function verifyConnectionEstablished(string $method) : void { + if (self::$connectionAdapter === null) { + throw ConnectionNotEstablished::fromInvalidInvocationBeforeConnectionEstablished($method); + } + } + +} \ No newline at end of file diff --git a/tests/Integration/AmpPostgresConnectionAdapterTest.php b/tests/Integration/AmpPostgresConnectionAdapterTest.php deleted file mode 100644 index 7f891f3..0000000 --- a/tests/Integration/AmpPostgresConnectionAdapterTest.php +++ /dev/null @@ -1,27 +0,0 @@ -query('SELECT COUNT(*) AS count FROM ' . $table)->fetchRow()['count']; - } - - protected static function getConnectionAdapter() : ConnectionAdapter { - return AmpPostgresConnectionAdapter::fromConnectionConfig(new PostgresConnectionConfig()); - } -} \ No newline at end of file diff --git a/tests/Integration/ConnectionAdapterTestCase.php b/tests/Integration/ConnectionAdapterTestCase.php deleted file mode 100644 index 24123b3..0000000 --- a/tests/Integration/ConnectionAdapterTestCase.php +++ /dev/null @@ -1,78 +0,0 @@ -getExpectedUnderlyingConnectionClassName(), self::getUnderlyingConnection()); - } - - public function testMethodWithNoFixtureHasEmptyTable() : void { - $tableCount = $this->executeCountSql('my_table'); - self::assertSame(0, $tableCount, 'Expected to start with a fresh table on each test but did not'); - } - - #[LoadFixture( - new MyTableFixture('Charles'), - new MyTableFixture('Dyana'), - new MyTableFixture('Nick') - )] - public function testLoadingFixtures() : void { - $tableCount = $this->executeCountSql('my_table'); - self::assertSame(3, $tableCount, 'Expected to have table loaded with records from fixture'); - } - - public function testGetTableDoesNotExistThrowsException() : void { - $this->expectException(UnableToGetTable::class); - $this->expectExceptionMessage('Unable to fetch table "not_a_db_table", please check previous Exception for more details.'); - - $this->getTable('not_a_db_table'); - } - - public function testGetTableWithNoRecordsHasEmptyTable() : void { - $table = $this->getTable('my_table'); - - self::assertSame('my_table', $table->getName()); - self::assertEmpty($table); - } - - #[LoadFixture( - new MyTableFixture('Charles'), - new MyTableFixture('Dyana'), - new MyTableFixture('Nick') - )] - public function testGettingTableWithLoadedFixturesReturnsCorrectRowCount() : void { - $tableCount = $this->executeCountSql('my_table'); - self::assertSame(3, $tableCount, 'Expected to have table loaded with records from fixture'); - - $table = $this->getTable('my_table'); - - self::assertCount(3, $table); - self::assertContainsOnlyInstancesOf(Row::class, iterator_to_array($table)); - } - - #[LoadFixture( - new MyTableFixture('Harry Mack'), - )] - public function testGettingTableWithLoadedFixtureReturnsCorrectData() : void { - $table = $this->getTable('my_table'); - - self::assertCount(1, $table); - $row = $table->getRow(0); - self::assertNotNull($row->get('id')); - self::assertSame('Harry Mack', $row->get('name')); - self::assertNotNull($row->get('created_at')); - } - -} \ No newline at end of file diff --git a/tests/Integration/ExistingPdoConnectionAdapterTest.php b/tests/Integration/ExistingPdoConnectionAdapterTest.php deleted file mode 100644 index 66c639d..0000000 --- a/tests/Integration/ExistingPdoConnectionAdapterTest.php +++ /dev/null @@ -1,30 +0,0 @@ -query('SELECT COUNT(*) AS "count" FROM ' . $table) - ->fetch(PDO::FETCH_ASSOC)['count']; - } - - protected static function getConnectionAdapter() : ConnectionAdapter { - $pdo = new PDO('sqlite::memory:'); - $pdo->query(file_get_contents(dirname(__DIR__, 2) . '/resources/schemas/sqlite.sql')); - return PdoConnectionAdapter::fromExistingConnection($pdo, PdoDriver::Sqlite); - } -} \ No newline at end of file diff --git a/tests/Integration/Helper/MyTableFixture.php b/tests/Integration/Helper/MyTableFixture.php deleted file mode 100644 index ba3b179..0000000 --- a/tests/Integration/Helper/MyTableFixture.php +++ /dev/null @@ -1,19 +0,0 @@ - $this->name]) - ]; - } -} \ No newline at end of file diff --git a/tests/Integration/Helper/PostgresConnectionConfig.php b/tests/Integration/Helper/PostgresConnectionConfig.php deleted file mode 100644 index 333ec6d..0000000 --- a/tests/Integration/Helper/PostgresConnectionConfig.php +++ /dev/null @@ -1,19 +0,0 @@ -query('SELECT COUNT(*) AS "count" FROM ' . $table) - ->fetch(PDO::FETCH_ASSOC)['count']; - } - - protected static function getConnectionAdapter() : ConnectionAdapter { - return PdoConnectionAdapter::fromConnectionConfig( - new ConnectionAdapterConfig( - 'mysql', - 'mysql', - 3306, - 'mysql', - 'mysql' - ), - PdoDriver::Mysql - ); - } -} \ No newline at end of file diff --git a/tests/Integration/PostgresPdoConnectionAdapterTest.php b/tests/Integration/PostgresPdoConnectionAdapterTest.php deleted file mode 100644 index c3959bd..0000000 --- a/tests/Integration/PostgresPdoConnectionAdapterTest.php +++ /dev/null @@ -1,33 +0,0 @@ -query('SELECT COUNT(*) AS "count" FROM ' . $table) - ->fetch(PDO::FETCH_ASSOC)['count']; - } -} diff --git a/tests/Unit/DatabaseCleanup/FixtureAwareTablesToTruncateTest.php b/tests/Unit/DatabaseCleanup/FixtureAwareTablesToTruncateTest.php new file mode 100644 index 0000000..5e256e5 --- /dev/null +++ b/tests/Unit/DatabaseCleanup/FixtureAwareTablesToTruncateTest.php @@ -0,0 +1,122 @@ +subject = new FixtureAwareTablesToTruncate(); + $this->databaseAwareTest = Phake::mock(DatabaseAwareTest::class); + } + + public function testFixtureTablesAreTruncatedInReverseOrderByDefault() : void { + $fixture = Phake::mock(Fixture::class); + Phake::when($this->databaseAwareTest)->class()->thenReturn(__CLASS__); + Phake::when($this->databaseAwareTest)->method()->thenReturn(__FUNCTION__); + Phake::when($this->databaseAwareTest)->fixtures()->thenReturn([$fixture]); + Phake::when($fixture)->records()->thenReturn([ + new FixtureRecord('foo', []), + new FixtureRecord('bar', []), + new FixtureRecord('baz', []) + ]); + + $tables = $this->subject->tables($this->databaseAwareTest); + + self::assertSame(['baz', 'bar', 'foo'], $tables); + } + + public function testMultipleEntriesForTableOnlyTruncatesTableOnce() : void { + $fixture = Phake::mock(Fixture::class) ; + Phake::when($this->databaseAwareTest)->class()->thenReturn(__CLASS__); + Phake::when($this->databaseAwareTest)->method()->thenReturn(__FUNCTION__); + Phake::when($this->databaseAwareTest)->fixtures()->thenReturn([$fixture]); + Phake::when($fixture)->records()->thenReturn([ + new FixtureRecord('foo', []), + new FixtureRecord('foo', []), + new FixtureRecord('bar', []), + ]); + + $tables = $this->subject->tables($this->databaseAwareTest); + + self::assertSame(['bar', 'foo'], $tables); + } + + #[ForceTruncateBeforeFixtureTables(new ListOfTablesToTruncate(['before-one', 'before-two', 'before-three']))] + public function testMethodHasTablesToForceTruncateBeforeFixtureTablesAreProperlySorted() : void { + $fixture = Phake::mock(Fixture::class); + Phake::when($this->databaseAwareTest)->class()->thenReturn(__CLASS__); + Phake::when($this->databaseAwareTest)->method()->thenReturn(__FUNCTION__); + Phake::when($this->databaseAwareTest)->fixtures()->thenReturn([$fixture]); + Phake::when($fixture)->records()->thenReturn([ + new FixtureRecord('foo', []), + new FixtureRecord('bar', []), + new FixtureRecord('baz', []) + ]); + + $tables = $this->subject->tables($this->databaseAwareTest); + + self::assertSame(['before-one', 'before-two', 'before-three', 'baz', 'bar', 'foo'], $tables); + } + + #[ForceTruncateAfterFixtureTables(new ListOfTablesToTruncate(['after-one', 'after-two', 'after-three']))] + public function testMethodHasTablesToForceTruncateAfterFixtureTablesAreProperlySorted() : void { + $fixture = Phake::mock(Fixture::class); + Phake::when($this->databaseAwareTest)->class()->thenReturn(__CLASS__); + Phake::when($this->databaseAwareTest)->method()->thenReturn(__FUNCTION__); + Phake::when($this->databaseAwareTest)->fixtures()->thenReturn([$fixture]); + Phake::when($fixture)->records()->thenReturn([ + new FixtureRecord('foo', []), + new FixtureRecord('bar', []), + new FixtureRecord('baz', []) + ]); + + $tables = $this->subject->tables($this->databaseAwareTest); + + self::assertSame(['baz', 'bar', 'foo', 'after-one', 'after-two', 'after-three'], $tables); + } + + #[ForceTruncateBeforeFixtureTables(new ListOfTablesToTruncate(['before-one', 'before-two', 'before-three']))] + #[ForceTruncateAfterFixtureTables(new ListOfTablesToTruncate(['after-one', 'after-two', 'after-three']))] + public function testMethodHasBeforeAndAfterForceTruncateTablesAreProperlySorted() : void { + $fixture = Phake::mock(Fixture::class); + Phake::when($this->databaseAwareTest)->class()->thenReturn(__CLASS__); + Phake::when($this->databaseAwareTest)->method()->thenReturn(__FUNCTION__); + Phake::when($this->databaseAwareTest)->fixtures()->thenReturn([$fixture]); + Phake::when($fixture)->records()->thenReturn([ + new FixtureRecord('foo', []), + new FixtureRecord('bar', []), + new FixtureRecord('baz', []) + ]); + + $tables = $this->subject->tables($this->databaseAwareTest); + + self::assertSame([ + 'before-one', + 'before-two', + 'before-three', + 'baz', + 'bar', + 'foo', + 'after-one', + 'after-two', + 'after-three' + ], $tables); + } + +} \ No newline at end of file diff --git a/tests/Unit/DatabaseCleanup/TransactionWithRollbackTest.php b/tests/Unit/DatabaseCleanup/TransactionWithRollbackTest.php new file mode 100644 index 0000000..a6aea27 --- /dev/null +++ b/tests/Unit/DatabaseCleanup/TransactionWithRollbackTest.php @@ -0,0 +1,40 @@ +subject = new TransactionWithRollback(); + $this->connectionAdapter = Phake::mock(ConnectionAdapter::class); + $this->databaseAwareTest = Phake::mock(DatabaseAwareTest::class); + } + + public function testCleanupDatabaseBeforeTestCallsConnectionAdapterBeginTransaction() : void { + $this->subject->cleanupBeforeTest($this->databaseAwareTest, $this->connectionAdapter); + + Phake::verify($this->connectionAdapter, Phake::times(1))->beginTransaction(); + } + + public function testTeardownAfterTestCallsConnectionAdapterRollback() : void { + $this->subject->teardownAfterTest($this->databaseAwareTest, $this->connectionAdapter); + + Phake::verify($this->connectionAdapter, Phake::times(1))->rollback(); + } + +} \ No newline at end of file diff --git a/tests/Unit/DatabaseCleanup/TruncateTablesTest.php b/tests/Unit/DatabaseCleanup/TruncateTablesTest.php new file mode 100644 index 0000000..57605b3 --- /dev/null +++ b/tests/Unit/DatabaseCleanup/TruncateTablesTest.php @@ -0,0 +1,46 @@ +connectionAdapter = Phake::mock(ConnectionAdapter::class); + $this->tablesToTruncate = Phake::mock(TablesToTruncate::class); + $this->databaseAwareTest = Phake::mock(DatabaseAwareTest::class); + $this->subject = new TruncateTables($this->tablesToTruncate); + } + + public function testCleanUpBeforeTestTruncatesTablesBasedOnOrderProvidedByTablesToTruncate() : void { + Phake::when($this->tablesToTruncate)->tables($this->databaseAwareTest)->thenReturn(['foo', 'bar', 'baz']); + + $this->subject->cleanupBeforeTest($this->databaseAwareTest, $this->connectionAdapter); + + Phake::inOrder( + Phake::verify($this->connectionAdapter)->truncateTable('foo'), + Phake::verify($this->connectionAdapter)->truncateTable('bar'), + Phake::verify($this->connectionAdapter)->truncateTable('baz') + ); + } + + public function testTeardownAfterTestHasNoInteractionWithTheConnectionAdapter() : void { + $this->subject->teardownAfterTest($this->databaseAwareTest, $this->connectionAdapter); + + Phake::verifyNoInteraction($this->connectionAdapter); + } +} diff --git a/tests/Unit/DatabaseRepresentation/RowTest.php b/tests/Unit/DatabaseRepresentation/RowTest.php deleted file mode 100644 index 4ee538c..0000000 --- a/tests/Unit/DatabaseRepresentation/RowTest.php +++ /dev/null @@ -1,45 +0,0 @@ -get('column')); - } - - public function testWithColumnValueIsImmutable() : void { - $a = Row::forValue('column', 'foo'); - $b = $a->withValue('second_column', 'bar'); - - self::assertNotSame($a, $b); - } - - public function testWithColumnValueHasCorrectValue() : void { - $subject = Row::forValue('first', 'foo')->withValue('second', 'bar'); - - self::assertSame('foo', $subject->get('first')); - self::assertSame('bar', $subject->get('second')); - } - - public function testIterateOverAddedValues() : void { - $subject = Row::forValue('first', 'foo') - ->withValue('second', 'bar') - ->withValue('third', 'baz'); - - self::assertSame([ - 'first' => 'foo', - 'second' => 'bar', - 'third' => 'baz', - ], iterator_to_array($subject)); - } - - -} \ No newline at end of file diff --git a/tests/Unit/DatabaseRepresentation/TableTest.php b/tests/Unit/DatabaseRepresentation/TableTest.php deleted file mode 100644 index 129ee95..0000000 --- a/tests/Unit/DatabaseRepresentation/TableTest.php +++ /dev/null @@ -1,52 +0,0 @@ -getName()); - } - - public function testForNameDefaultsToEmptyRows() : void { - $subject = Table::forName('some_table'); - - self::assertEmpty(iterator_to_array($subject)); - } - - public function testGetRowWithNoRowsIsNull() : void { - $subject = Table::forName('something'); - - self::assertNull($subject->getRow(0)); - } - - public function testGetRowWithRowsIsRow() : void { - $subject = Table::forName('something')->withRow($row = Row::forValue('foo', 'bar')); - - self::assertSame($row, $subject->getRow(0)); - } - - public function testWithRowAddsToRows() : void { - $subject = Table::forName('another_table')->withRow($row = Row::forValue('foo', 'bar')); - - self::assertSame([$row], iterator_to_array($subject)); - } - - public function testWithMultipleRowsReturnsAllOfThem() : void { - $subject = Table::forName('another_table') - ->withRow($a = Row::forValue('foo', 'bar')) - ->withRow($b = Row::forValue('bar', 'baz')) - ->withRow($c = Row::forValue('baz', 'qux')); - - self::assertSame([$a, $b, $c], iterator_to_array($subject)); - } - -} diff --git a/tests/Unit/DatabaseTestCaseTest.php b/tests/Unit/DatabaseTestCaseTest.php deleted file mode 100644 index 309cc5d..0000000 --- a/tests/Unit/DatabaseTestCaseTest.php +++ /dev/null @@ -1,91 +0,0 @@ -recorder = new MethodRecorder(); - $this->adapter = new StubConnectionAdapter($this->recorder); - } - - private function executeSubject(DatabaseTestCase $subject) : void { - $subject::setUpBeforeClass(); - $subject->run(); - $subject::tearDownAfterClass(); - } - - public function testConnectionMethodsCalledInCorrectOrder() : void { - $subject = new StubDatabaseTestCase('testSomething', $this->adapter, $this->recorder); - self::assertEmpty($this->recorder->getRecordedCalls()); - $this->executeSubject($subject); - self::assertSame([ - [StubConnectionAdapter::class . '::establishConnection', []], - [StubDatabaseTestCase::class . '::beforeAll', []], - [StubConnectionAdapter::class . '::onTestStart', []], - [StubDatabaseTestCase::class . '::beforeEach', []], - [StubDatabaseTestCase::class . '::testSomething', []], - [StubDatabaseTestCase::class . '::afterEach', []], - [StubConnectionAdapter::class . '::onTestStop', []], - [StubDatabaseTestCase::class . '::afterAll', []], - [StubConnectionAdapter::class . '::closeConnection', []] - ], $this->recorder->getRecordedCalls()); - } - - public function testGetUnderlyingConnectionBeforeConnectionEstablishedThrowsException() : void { - $subject = new StubDatabaseTestCase('testSomething', $this->adapter, $this->recorder); - - $this->expectException(ConnectionNotYetEstablished::class); - $this->expectExceptionMessage('Attempted to get a connection that has not been established yet. Please ensure the DatabaseTestCase::setupBeforeClass hook runs before calling this method.'); - - $subject->callGetUnderlyingConnection(); - } - - public function testGetUnderlyingConnectionAfterConnectionEstablishedReturnsCorrectObject() : void { - $subject = new StubDatabaseTestCase('testSomething', $this->adapter, $this->recorder); - - $subject::setUpBeforeClass(); - - $connection = $subject->callGetUnderlyingConnection(); - - self::assertInstanceOf(\stdClass::class, $connection); - self::assertSame(StubConnectionAdapter::class, $connection->from); - - $subject::tearDownAfterClass(); - - $this->expectException(ConnectionNotYetEstablished::class); - $this->expectExceptionMessage('Attempted to get a connection that has not been established yet. Please ensure the DatabaseTestCase::setupBeforeClass hook runs before calling this method.'); - - $subject->callGetUnderlyingConnection(); - } - - public function testLoadingFixtures() : void { - $subject = new StubDatabaseTestCase('testLoadFixtures', $this->adapter, $this->recorder); - - $this->executeSubject($subject); - - $calls = $this->recorder->getRecordedCalls(); - - self::assertCount(10, $calls); - self::assertSame(StubConnectionAdapter::class . '::loadFixture', $calls[3][0]); - self::assertSame( - [['name' => 'foo'], ['name' => 'bar'], ['name' => 'baz']], - array_map(static fn(Fixture $fixture) => $fixture->getFixtureRecords()[0]->parameters, $calls[3][1]) - ); - } - -} \ No newline at end of file diff --git a/tests/Unit/GenericSqlFixtureTest.php b/tests/Unit/Fixture/GenericSqlFixtureTest.php similarity index 62% rename from tests/Unit/GenericSqlFixtureTest.php rename to tests/Unit/Fixture/GenericSqlFixtureTest.php index 13d415c..7f9be20 100644 --- a/tests/Unit/GenericSqlFixtureTest.php +++ b/tests/Unit/Fixture/GenericSqlFixtureTest.php @@ -1,9 +1,9 @@ expectException(InvalidFixture::class); - $this->expectExceptionMessage('A valid table name must be provided when using ' . SingleRecordFixture::class); + $this->expectExceptionMessage('A valid, non-empty table name must be provided with a Fixture'); new SingleRecordFixture('', []); } public function testEmptyRecordThrowsException() : void { $this->expectException(InvalidFixture::class); - $this->expectExceptionMessage('A valid, non-empty record must be provided when using ' . SingleRecordFixture::class); + $this->expectExceptionMessage('A valid, non-empty record must be provided with a Fixture'); new SingleRecordFixture('some_table', []); } @@ -27,9 +27,9 @@ public function testEmptyRecordThrowsException() : void { public function testFixtureRecordHasFixtureConstructorParameters() : void { $subject = new SingleRecordFixture('another_table', ['foo' => 'bar', 'bar' => 'baz']); - self::assertCount(1, $subject->getFixtureRecords()); - self::assertSame('another_table', $subject->getFixtureRecords()[0]->table); - self::assertSame(['foo' => 'bar', 'bar' => 'baz'], $subject->getFixtureRecords()[0]->parameters); + self::assertCount(1, $subject->records()); + self::assertSame('another_table', $subject->records()[0]->table); + self::assertSame(['foo' => 'bar', 'bar' => 'baz'], $subject->records()[0]->parameters); } } diff --git a/tests/Unit/Helper/MethodRecorder.php b/tests/Unit/Helper/MethodRecorder.php deleted file mode 100644 index 64525be..0000000 --- a/tests/Unit/Helper/MethodRecorder.php +++ /dev/null @@ -1,17 +0,0 @@ -recordedCalls; - } - - public function record(string $method, array $args) : void { - $this->recordedCalls[] = [$method, $args]; - } - -} diff --git a/tests/Unit/Helper/StubConnectionAdapter.php b/tests/Unit/Helper/StubConnectionAdapter.php deleted file mode 100644 index 18f3db9..0000000 --- a/tests/Unit/Helper/StubConnectionAdapter.php +++ /dev/null @@ -1,44 +0,0 @@ -recorder->record(__METHOD__, []); - } - - public function onTestStart() : void { - $this->recorder->record(__METHOD__, []); - } - - public function onTestStop() : void { - $this->recorder->record(__METHOD__, []); - } - - public function closeConnection() : void { - $this->recorder->record(__METHOD__, []); - } - - public function loadFixture(Fixture $fixture, Fixture... $fixtures) : void { - $this->recorder->record(__METHOD__, [$fixture, ...$fixtures]); - } - - public function getUnderlyingConnection() : object { - $class = new \stdClass(); - $class->from = self::class; - return $class; - } - - public function getTable(string $name) : Table { - // TODO: Implement getTable() method. - } -} \ No newline at end of file diff --git a/tests/Unit/Helper/StubDatabaseTestCase.php b/tests/Unit/Helper/StubDatabaseTestCase.php deleted file mode 100644 index 68fed56..0000000 --- a/tests/Unit/Helper/StubDatabaseTestCase.php +++ /dev/null @@ -1,62 +0,0 @@ -record(__METHOD__, []); - } - - protected function beforeEach() : void { - self::$recorder->record(__METHOD__, []); - } - - protected function afterEach() : void { - self::$recorder->record(__METHOD__, []); - } - - protected static function afterAll() : void { - self::$recorder->record(__METHOD__, []); - } - - public function testSomething() : void { - self::$recorder->record(__METHOD__, []); - $this->expectNotToPerformAssertions(); - } - - #[LoadFixture( - new SingleRecordFixture('my_table', ['name' => 'foo']), - new SingleRecordFixture('my_table', ['name' => 'bar']), - new SingleRecordFixture('my_table', ['name' => 'baz']), - )] - public function testLoadFixtures() : void { - self::$recorder->record(__METHOD__, []); - $this->expectNotToPerformAssertions(); - } - - public function callGetUnderlyingConnection() : object { - return self::getUnderlyingConnection(); - } - - protected static function getConnectionAdapter() : ConnectionAdapter { - return self::$connectionAdapter; - } -} \ No newline at end of file diff --git a/tests/Unit/Internal/ClosureDataProviderTableTest.php b/tests/Unit/Internal/ClosureDataProviderTableTest.php new file mode 100644 index 0000000..0308f70 --- /dev/null +++ b/tests/Unit/Internal/ClosureDataProviderTableTest.php @@ -0,0 +1,105 @@ + [] + ); + + self::assertSame('my_table_name', $subject->name()); + } + + public function testFetchingRowThatDoesNotExistReturnsNull() : void { + $subject = new ClosureDataProviderTable( + 'ny_table', + fn() => [] + ); + + self::assertNull($subject->row(0)); + } + + public function testFetchingRowThatDoesExistsReturnsRowInstance() : void { + $subject = new ClosureDataProviderTable( + 'my_table', + fn() => [ + ['name' => 'Harry', 'profession' => 'goat', 'revolutionary' => true] + ] + ); + + self::assertInstanceOf(Row::class, $subject->row(0)); + self::assertSame('Harry', $subject->row(0)->get('name')); + self::assertSame('goat', $subject->row(0)->get('profession')); + self::assertSame(true, $subject->row(0)->get('revolutionary')); + } + + public function testFetchingRowIteratesProperlyOverValues() : void { + $subject = new ClosureDataProviderTable( + 'my_table', + fn() => [ + ['name' => 'Harry', 'profession' => 'goat', 'revolutionary' => true] + ] + ); + + self::assertSame( + ['name' => 'Harry', 'profession' => 'goat', 'revolutionary' => true], + iterator_to_array($subject->row(0)) + ); + } + + public function testCountingTableReturnsCorrectNumberForAmountOfRecords() : void { + $subject = new ClosureDataProviderTable( + 'my_table', + fn() => [ + ['name' => 'Sterling'], + ['name' => 'Lana'], + ['name' => 'Cyril'] + ] + ); + + self::assertCount(3, $subject); + } + + public function testIteratingOverTableHasCorrectCountOfRecords() : void { + $subject = new ClosureDataProviderTable( + 'my_table', + fn() => [ + ['name' => 'Sterling'], + ['name' => 'Lana'], + ['name' => 'Cyril'] + ] + ); + + self::assertCount(3, iterator_to_array($subject)); + } + + public function testReloadingFetchesNewRowsFromDataProvider() : void { + $data = new stdClass(); + $data->counter = 0; + $subject = new ClosureDataProviderTable( + 'my_table', + fn() => [ + ['counter' => ++$data->counter] + ] + ); + + self::assertCount(1, $subject); + self::assertSame(1, $subject->row(0)->get('counter')); + + $subject->reload(); + + self::assertCount(1, $subject); + self::assertSame(2, $subject->row(0)->get('counter')); + } + +} \ No newline at end of file diff --git a/tests/Unit/Internal/FixtureAttributeAwareDatabaseTestTest.php b/tests/Unit/Internal/FixtureAttributeAwareDatabaseTestTest.php new file mode 100644 index 0000000..7d67487 --- /dev/null +++ b/tests/Unit/Internal/FixtureAttributeAwareDatabaseTestTest.php @@ -0,0 +1,44 @@ +class()); + self::assertSame(__FUNCTION__, $subject->method()); + } + + public function testDatabaseAwareTestMethodWithNoLoadFixtureAttributesWillReturnEmptyCollection() : void { + $subject = FixtureAttributeAwareDatabaseTest::fromTestMethodWithPossibleFixtures(__CLASS__, __FUNCTION__); + + self::assertSame([], $subject->fixtures()); + } + + #[LoadFixture(new SingleRecordFixture('table', ['foo' => 'bar']))] + public function testDatabaseAwareTestMethodWithLoadFixtureAttributeHasCorrectFixturesInCollection() : void { + $subject = FixtureAttributeAwareDatabaseTest::fromTestMethodWithPossibleFixtures(__CLASS__, __FUNCTION__); + + /** @var list $fixtures */ + $fixtures = $subject->fixtures(); + self::assertCount(1, $fixtures); + self::assertContainsOnlyInstancesOf(Fixture::class, $fixtures); + + $records = $fixtures[0]->records(); + self::assertCount(1, $records); + self::assertSame('table', $records[0]->table); + self::assertSame(['foo' => 'bar'], $records[0]->parameters); + } + +} diff --git a/tests/Unit/TestDatabaseTest.php b/tests/Unit/TestDatabaseTest.php new file mode 100644 index 0000000..629d9fc --- /dev/null +++ b/tests/Unit/TestDatabaseTest.php @@ -0,0 +1,214 @@ +setStaticPropertyValue('connectionAdapter', null); + } + + public function testAttemptingToCallConnectionWithoutProperlyEstablishingConnectionThrowsException() : void { + $this->expectException(ConnectionNotEstablished::class); + $this->expectExceptionMessage( + 'A connection to the test database MUST be established before invoking ' . TestDatabase::class . '::connection' + ); + + TestDatabase::connection(); + } + + /** + * @return array{ + * 0:RequiresTestDatabaseSettings&IMock, + * 1:ConnectionAdapter&IMock, + * 2:CleanupStrategy&IMock, + * 3:ConnectionAdapterFactory&IMock, + * } + */ + private function defaultMocks() : array { + $cleanupStrategy = Phake::mock(CleanupStrategy::class); + $connectionAdapterFactory = Phake::mock(ConnectionAdapterFactory::class); + $connectionAdapter = Phake::mock(ConnectionAdapter::class); + $requiresTestDatabase = Phake::mock(RequiresTestDatabaseSettings::class); + + Phake::when($requiresTestDatabase)->connectionAdapterFactory()->thenReturn($connectionAdapterFactory); + Phake::when($requiresTestDatabase)->cleanupStrategy()->thenReturn($cleanupStrategy); + Phake::when($connectionAdapterFactory)->createConnectionAdapter()->thenReturn($connectionAdapter); + + return [$requiresTestDatabase, $connectionAdapter, $cleanupStrategy, $connectionAdapterFactory]; + } + + public function testEstablishingConnectionCreatesConnectionAdapterAndConnectsToTestDatabase() : void { + [$requiresTestDatabase, $connectionAdapter] = $this->defaultMocks(); + + $subject = TestDatabase::createFromTestCaseRequiresDatabase(__CLASS__, $requiresTestDatabase); + $subject->establishConnection(); + + Phake::verify($connectionAdapter, Phake::times(1))->establishConnection(); + } + + public function testCallingEstablishingConnectionMultipleTimesWithoutClosingConnectionThrowsException() : void { + [$requiresTestDatabase] = $this->defaultMocks(); + + $subject = TestDatabase::createFromTestCaseRequiresDatabase(__CLASS__, $requiresTestDatabase); + $subject->establishConnection(); + + $this->expectException(ConnectionAlreadyEstablished::class); + $this->expectExceptionMessage( + 'Attempting to establish an already established connection. Please ensure you call ' . TestDatabase::class . '::closeConnection before calling establishConnection again.' + ); + + $subject->establishConnection(); + } + + public function testCallingConnectionAfterEstablishingConnectionReturnsTheCorrectConnectionObject() : void { + [$requiresTestDatabase, $connectionAdapter] = $this->defaultMocks(); + + $connection = new \stdClass(); + Phake::when($connectionAdapter)->underlyingConnection()->thenReturn($connection); + + $subject = TestDatabase::createFromTestCaseRequiresDatabase(__CLASS__, $requiresTestDatabase); + $subject->establishConnection(); + + self::assertSame($connection, TestDatabase::connection()); + } + + public function testPrepareForTestWithoutEstablishingConnectionThrowsException() : void { + [$requiresTestDatabase] = $this->defaultMocks(); + $databaseAwareTest = Phake::mock(DatabaseAwareTest::class); + + $subject = TestDatabase::createFromTestCaseRequiresDatabase(__CLASS__, $requiresTestDatabase); + + $this->expectExceptionMessage(ConnectionNotEstablished::class); + $this->expectExceptionMessage( + 'A connection to the test database MUST be established before invoking ' . TestDatabase::class . '::prepareForTest' + ); + + $subject->prepareForTest($databaseAwareTest); + } + + public function testPrepareForTestWithEstablishedConnectionCallsCorrectOperationsInOrder() : void { + [$requiresTestDatabase, $connectionAdapter, $cleanupStrategy] = $this->defaultMocks(); + $databaseAwareTest = Phake::mock(DatabaseAwareTest::class); + $fixture = Phake::mock(Fixture::class); + Phake::when($databaseAwareTest)->fixtures()->thenReturn([$fixture]); + + $subject = TestDatabase::createFromTestCaseRequiresDatabase(__CLASS__, $requiresTestDatabase); + + $subject->establishConnection(); + $subject->prepareForTest($databaseAwareTest); + + Phake::inOrder( + Phake::verify($connectionAdapter, Phake::times(1))->establishConnection(), + Phake::verify($cleanupStrategy, Phake::times(1))->cleanupBeforeTest($databaseAwareTest, $connectionAdapter), + Phake::verify($connectionAdapter, Phake::times(1))->insert([$fixture]), + ); + } + + public function testCleanupAfterTestWithoutEstablishingConnectionThrowsException() : void { + [$requiresTestDatabase] = $this->defaultMocks(); + $databaseAwareTest = Phake::mock(DatabaseAwareTest::class); + + $subject = TestDatabase::createFromTestCaseRequiresDatabase(__CLASS__, $requiresTestDatabase); + + $this->expectExceptionMessage(ConnectionNotEstablished::class); + $this->expectExceptionMessage( + 'A connection to the test database MUST be established before invoking ' . TestDatabase::class . '::cleanupAfterTest' + ); + + $subject->cleanupAfterTest($databaseAwareTest); + } + + public function testCleanupAfterTestWithEstablishedConnectionsCallsCorrectOperationsInOrder() : void { + [$requiresTestDatabase, $connectionAdapter, $cleanupStrategy] = $this->defaultMocks(); + $databaseAwareTest = Phake::mock(DatabaseAwareTest::class); + $fixture = Phake::mock(Fixture::class); + Phake::when($databaseAwareTest)->fixtures()->thenReturn([$fixture]); + + $subject = TestDatabase::createFromTestCaseRequiresDatabase(__CLASS__, $requiresTestDatabase); + $subject->establishConnection(); + $subject->cleanupAfterTest($databaseAwareTest); + + Phake::inOrder( + Phake::verify($connectionAdapter, Phake::times(1))->establishConnection(), + Phake::verify($cleanupStrategy, Phake::times(1))->teardownAfterTest($databaseAwareTest, $connectionAdapter) + ); + } + + public function testCloseConnectionWithoutEstablishingConnectionThrowsException() : void { + [$requiresTestDatabase] = $this->defaultMocks(); + + $subject = TestDatabase::createFromTestCaseRequiresDatabase(__CLASS__, $requiresTestDatabase); + + $this->expectExceptionMessage(ConnectionNotEstablished::class); + $this->expectExceptionMessage( + 'A connection to the test database MUST be established before invoking ' . TestDatabase::class . '::closeConnection' + ); + + $subject->closeConnection(); + } + + public function testCloseConnectionWithEstablishedConnectionCallsCorrectConnectionAdapterMethodAndNullsTheStaticConnectionAdapter() : void { + [$requiresTestDatabase, $connectionAdapter] = $this->defaultMocks(); + + $subject = TestDatabase::createFromTestCaseRequiresDatabase(__CLASS__, $requiresTestDatabase); + $subject->establishConnection(); + $subject->closeConnection(); + + Phake::inOrder( + Phake::verify($connectionAdapter, Phake::times(1))->establishConnection(), + Phake::verify($connectionAdapter, Phake::times(1))->closeConnection() + ); + + $reflection = new \ReflectionClass(TestDatabase::class); + self::assertNull($reflection->getStaticPropertyValue('connectionAdapter')); + } + + public function testCallingTableWithoutEstablishingConnectionThrowsException() : void { + $this->expectExceptionMessage(ConnectionNotEstablished::class); + $this->expectExceptionMessage( + 'A connection to the test database MUST be established before invoking ' . TestDatabase::class . '::table' + ); + + TestDatabase::table('name'); + } + + public function testCallingTableWithEstablishedConnectionReturnsTableFromConnectionAdapterCall() : void { + [$requiresTestDatabase, $connectionAdapter] = $this->defaultMocks(); + $table = Phake::mock(Table::class); + Phake::when($connectionAdapter)->selectAll('table_name')->thenReturn($table); + + $subject = TestDatabase::createFromTestCaseRequiresDatabase(__CLASS__, $requiresTestDatabase); + $subject->establishConnection(); + + $actual = TestDatabase::table('table_name'); + + self::assertSame($table, $actual); + } + +} \ No newline at end of file