diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dc6d5af --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/build export-ignore +/tests export-ignore +/.github export-ignore \ No newline at end of file diff --git a/.github/workflows/php-package.yml b/.github/workflows/php-package.yml new file mode 100644 index 0000000..b1c4030 --- /dev/null +++ b/.github/workflows/php-package.yml @@ -0,0 +1,33 @@ +name: PHP Package +on: + push: + pull_request: +jobs: + format-check: + name: Check PSR12 Standarts + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + tools: composer:v2 + - run: composer install + shell: bash + - run: composer format:check + shell: bash + tests: + name: Run Tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + tools: composer:v2 + - run: composer install + shell: bash + - run: composer test + shell: bash diff --git a/.gitignore b/.gitignore index 4fbb073..7229ba2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /vendor/ /composer.lock +/.idea/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dfed73b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 EcomDev B.V. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8030aea --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# 🐳 Test-Containers for Quick Magento Development +[![Docker Build](https://github.com/EcomDev/testcontainer-magento-data/actions/workflows/docker-images.yml/badge.svg)](https://github.com/EcomDev/testcontainer-magento-data/actions/workflows/docker-images.yml) +[![PHP Package](https://github.com/EcomDev/testcontainer-magento-data-php/actions/workflows/php-package.yml/badge.svg)](https://github.com/EcomDev/testcontainer-magento-data-php/actions/workflows/php-package.yml) + +This package simplifies the process of automated testing with real database and search engine + +## ✨ Features + +- 📦 **Pre-configured database and search containers**: Instantly spin up containers with ready-to-use Magento data +- ⚙️ **Easy setup and use**: Use PHP package to automatically discard container after tests +- 🎯 **Blazingly Fast**: Container takes only few seconds to start, so you can focus on testing instead of waiting for db initialization + +## 📋 Requirements + +- **🐳 Docker**: Ensure Docker is installed and operational on your system. + +## 📦 Available images + +All the available Docker image version can be found in build repository [EcomDev/testcontainer-magento-data](https://github.com/EcomDev/testcontainer-magento-data?tab=readme-ov-file#-available-images) + +## Installation + +Use composer with `--dev` flag to add it as dependency for your tests +```bash +composer require --dev ecomdev/testcontainers-magento-data +``` + + +## Examples + +### MySQL container + +Create Latest Magento Database Build +```php +use EcomDev\TestContainers\MagentoData\DbContainerBuilder; + +$container = DbContainerBuilder::mysql() + ->build(); +``` + +Create Latest Magento Database Build with sample data +```php +use EcomDev\TestContainers\MagentoData\DbContainerBuilder; + +$container = DbContainerBuilder::mysql() + ->withSampleData() + ->build(); +``` + +Create 2.4.7-p2 with sample data and fetch number of products +```php +use EcomDev\TestContainers\MagentoData\DbContainerBuilder; +use PDO; + +$container = DbContainerBuilder::mysql() + ->withMagentoVersion('2.4.7-p2') + ->withSampleData() + ->build(); + +$connectionSettings = $container->getConnectionSettings(); +$connection = new PDO( + $connectionSettings->dsn(), + $connectionSettings->user, + $connectionSettings->password +); + +$result = $connection->query('SELECT COUNT(*) FROM catalog_product_entity'); +// Outputs 2040 +echo $result->fetch(PDO::FETCH_COLUMN); +``` + +### MariaDB container +Everything the same as for MySQL container, just a different builder method + +```php +use EcomDev\TestContainers\MagentoData\DbContainerBuilder; + +$container = DbContainerBuilder::mariadb() + ->withMagentoVersion('2.4.7-p2') + ->withSampleData() + ->build(); +``` + +## OpenSearch container + +For OpenSearch container there is a different builder and container, that allows building base url for http connection + +Here is a small example + +```php +use EcomDev\TestContainers\MagentoData\OpenSearchContainerBuilder; +use GuzzleHttp\Client; + +$container = OpenSearchContainerBuilder::new() + ->withSampleData() + ->build(); + +$client = new Client([ + 'base_uri' => $container->getBaseUrl() +]); + +$result = json_decode( + $client->get('magento2_product_1/_count')->getBody()->getContents(), + true +); + +// Outputs 181 +echo $result['count']; +``` + +## 📜 License + +This project is licensed under the MIT License. + +See the [LICENSE](LICENSE) file for more details. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..253c7a8 --- /dev/null +++ b/composer.json @@ -0,0 +1,40 @@ +{ + "name": "ecomdev/testcontainers-magento-data", + "description": "TestContainers for Magento data in integration/functional tests", + "type": "library", + "require": { + "php": "^8.3", + "testcontainers/testcontainers": "^0.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.5", + "ext-pdo": "*", + "brianium/paratest": "^7.7", + "guzzlehttp/guzzle": "^7.0", + "squizlabs/php_codesniffer": "^3.0" + }, + "license": "MIT", + "autoload": { + "psr-4": { + "EcomDev\\TestContainers\\MagentoData\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "EcomDev\\TestContainers\\MagentoData\\": "tests" + } + }, + "scripts": { + "test": "paratest ./tests/ ", + "unit": "paratest ./tests/ --exclude-group=slow", + "format:check": "phpcs --standard=PSR12 ./", + "format:write": "phpcbf --standard=PSR12 ./" + }, + "authors": [ + { + "name": "Ivan Chepurnyi", + "email": "ivan.chepurnyi@ecomdev.org" + } + ], + "minimum-stability": "stable" +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..e69de29 diff --git a/src/ContainerBuilder.php b/src/ContainerBuilder.php new file mode 100644 index 0000000..34b2098 --- /dev/null +++ b/src/ContainerBuilder.php @@ -0,0 +1,49 @@ +magentoVersion = $version; + return $other; + } + + /** + * Sets the variation and returns a new instance with the updated value. + */ + public function withVariation(string $variation): self + { + $other = clone $this; + $other->variation = $variation; + return $other; + } + + /** + * Configures the instance to use sample data and returns a new instance with the updated configuration. + */ + public function withSampleData(): self + { + return $this->withVariation('sampledata'); + } + + private function generateImageTag(): string + { + return $this->magentoVersion . ($this->variation ? '-' . $this->variation : ''); + } +} diff --git a/src/ContainerMetadata.php b/src/ContainerMetadata.php new file mode 100644 index 0000000..48a6cf2 --- /dev/null +++ b/src/ContainerMetadata.php @@ -0,0 +1,13 @@ +getImageName(); + self::$containers[$imageName][$id] = $container; + return $container; + } + + /** + * Returns container by image and id if it was registered before + */ + public static function findContainer(string $image, string $id): ?RunningContainer + { + return self::$containers[$image][$id] ?? null; + } +} diff --git a/src/ContainerWithVolume.php b/src/ContainerWithVolume.php new file mode 100644 index 0000000..ed8192e --- /dev/null +++ b/src/ContainerWithVolume.php @@ -0,0 +1,79 @@ +getId()); + parse_str(implode('&', $inspect[0]['Config']['Env']), $this->environmentVariables); + } catch (JsonException) { + $this->environmentVariables = []; + } + + return $this; + } + + /** + * St + * @return array + */ + public function getEnvironmentVariables(): array + { + return $this->environmentVariables; + } + + /** + * Removes container together with attached volume + * + * @return self + */ + #[Override] + public function remove(): self + { + $remove = new Process(['docker', 'rm', '-f', '-v', $this->getId()]); + $remove->mustRun(); + + Registry::remove($this); + return $this; + } +} diff --git a/src/DbConnectionSettings.php b/src/DbConnectionSettings.php new file mode 100644 index 0000000..2afaba7 --- /dev/null +++ b/src/DbConnectionSettings.php @@ -0,0 +1,47 @@ +database) { + return sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $this->host, + $this->port, + $this->database, + $this->charset + ); + } + + return sprintf('mysql:host=%s;port=%d;charset=%s', $this->host, $this->port, $this->charset); + } +} diff --git a/src/DbContainer.php b/src/DbContainer.php new file mode 100644 index 0000000..269cd8d --- /dev/null +++ b/src/DbContainer.php @@ -0,0 +1,47 @@ +withWait(new WaitForLog('ready for connections')); + $container->run(); + + return new self($container); + } + + public function getConnectionSettings(): DbConnectionSettings + { + return DbConnectionSettings::fromEnvironment($this->container->getEnvironmentVariables(), $this->getAddress()); + } + + public function getAddress(): string + { + return $this->container->getAddress(); + } + + public function getId(): string + { + return $this->container->getId(); + } + + public function getImageName(): string + { + return $this->container->image; + } + + public function __destruct() + { + $this->container->remove(); + } +} diff --git a/src/DbContainerBuilder.php b/src/DbContainerBuilder.php new file mode 100644 index 0000000..48faf1c --- /dev/null +++ b/src/DbContainerBuilder.php @@ -0,0 +1,57 @@ +getImageName(), $id) + ?? ContainerRegistry::registerContainer($id, $this->build()); + return $container; + } + + #[ReturnTypeWillChange] + public function build(): DbContainer + { + return DbContainer::fromImage($this->getImageName()); + } + + public function getImageName(): string + { + return sprintf('%s:%s', ContainerMetadata::getImageName($this->type), $this->generateImageTag()); + } +} diff --git a/src/OpenSearchContainer.php b/src/OpenSearchContainer.php new file mode 100644 index 0000000..c797820 --- /dev/null +++ b/src/OpenSearchContainer.php @@ -0,0 +1,48 @@ +withWait(new WaitForLog('Cluster health status changed from [RED]')) + ->withEnvironment('discovery.type', 'single-node') + ->withEnvironment('DISABLE_SECURITY_PLUGIN', 'true') + ->run(); + + return new self($container); + } + + public function getBaseUrl(): string + { + return sprintf('http://%s:9200/', $this->getAddress()); + } + + public function getAddress(): string + { + return $this->container->getAddress(); + } + + public function getId(): string + { + return $this->container->getId(); + } + + public function getImageName(): string + { + return $this->container->image; + } + + public function __destruct() + { + $this->container->remove(); + } +} diff --git a/src/OpenSearchContainerBuilder.php b/src/OpenSearchContainerBuilder.php new file mode 100644 index 0000000..c7dba19 --- /dev/null +++ b/src/OpenSearchContainerBuilder.php @@ -0,0 +1,39 @@ +getImageName()); + } + + #[ReturnTypeWillChange] + public function shared(string $id): OpenSearchContainer + { + /** @var OpenSearchContainer $container */ + $container = ContainerRegistry::findContainer($this->getImageName(), $id) + ?? ContainerRegistry::registerContainer($id, $this->build()); + return $container; + } + + public function getImageName(): string + { + return sprintf( + "%s:%s", + ContainerMetadata::getImageName('opensearch'), + $this->generateImageTag() + ); + } +} diff --git a/src/RunningContainer.php b/src/RunningContainer.php new file mode 100644 index 0000000..d576b82 --- /dev/null +++ b/src/RunningContainer.php @@ -0,0 +1,30 @@ +assertEquals( + new DbConnectionSettings('localhost'), + DbConnectionSettings::fromEnvironment([], 'localhost') + ); + } + + #[Test] + public function usesMysqlUserFromEnvironment() + { + $this->assertEquals( + new DbConnectionSettings('localhost', user: 'magento'), + DbConnectionSettings::fromEnvironment( + ['MYSQL_USER' => 'magento'], + 'localhost' + ) + ); + } + + #[Test] + public function usesMysqlPasswordFromEnvironment() + { + $this->assertEquals( + new DbConnectionSettings('localhost', password: 'magento-pass'), + DbConnectionSettings::fromEnvironment( + ['MYSQL_PASSWORD' => 'magento-pass'], + 'localhost' + ) + ); + } + + #[Test] + public function usesMysqlDatabaseFromEnvironment() + { + $this->assertEquals( + new DbConnectionSettings('localhost', database: 'magento2'), + DbConnectionSettings::fromEnvironment( + ['MYSQL_DATABASE' => 'magento2'], + 'localhost' + ) + ); + } + + #[Test] + public function createsDsnWithDefaultOptions() + { + $connection = new DbConnectionSettings('db'); + + $this->assertEquals( + 'mysql:host=db;port=3306;charset=utf8mb4', + $connection->dsn() + ); + } + + #[Test] + public function createsDsnWithDatabaseName() + { + $connection = new DbConnectionSettings('db2', database: 'magento2'); + + $this->assertEquals( + 'mysql:host=db2;port=3306;dbname=magento2;charset=utf8mb4', + $connection->dsn() + ); + } +} diff --git a/tests/DbContainerTest.php b/tests/DbContainerTest.php new file mode 100644 index 0000000..8eba78c --- /dev/null +++ b/tests/DbContainerTest.php @@ -0,0 +1,126 @@ +dsn(), + $connectionSettings->user, + $connectionSettings->password, + [ + 'attributes' => [ + PDO::ATTR_STRINGIFY_FETCHES => false, + ] + ] + ); + } + + #[Test] + #[Group("slow")] + public function startsLatestMySQLContainerByDefault() + { + $container = DbContainerBuilder::mysql() + ->build(); + + $connectionSettings = $container->getConnectionSettings(); + + $connection = $this->createConnection($connectionSettings); + $result = $connection->query('SELECT COUNT(*) FROM catalog_product_entity'); + $this->assertEquals(0, $result->fetch(PDO::FETCH_COLUMN)); + } + + #[Test] + #[Group("slow")] + public function killsContainerOnDestruct() + { + $container = DbContainerBuilder::mysql() + ->build(); + + $connectionSettings = $container->getConnectionSettings(); + unset($container); + + $this->expectException(\PDOException::class); + $this->createConnection($connectionSettings); + } + + #[Test] + #[Group("slow")] + public function doesNotKillContainerWhenBuildAsShared() + { + $container = DbContainerBuilder::mysql() + ->shared('container1'); + + $connectionSettings = $container->getConnectionSettings(); + unset($container); + + $connection = $this->createConnection($connectionSettings); + $result = $connection->query('SELECT COUNT(*) FROM catalog_product_entity'); + $this->assertEquals(0, $result->fetch(PDO::FETCH_COLUMN)); + } + + #[Test] + #[Group("slow")] + public function containersAreIdenticalWhenShared() + { + $this->assertSame( + DbContainerBuilder::mysql() + ->shared('container1'), + DbContainerBuilder::mysql() + ->shared('container1') + ); + + $this->assertNotSame( + DbContainerBuilder::mysql() + ->shared('container1'), + DbContainerBuilder::mysql() + ->shared('container2') + ); + } + + #[Test] + #[Group("slow")] + public function loadsMagentoSampleDataContainer() + { + $container = DbContainerBuilder::mysql() + ->withMagentoVersion('2.4.7-p3') + ->withSampleData() + ->build(); + + $connectionSettings = $container->getConnectionSettings(); + $connection = $this->createConnection($connectionSettings); + $result = $connection->query('SELECT COUNT(*) FROM catalog_product_entity'); + $this->assertEquals(2040, $result->fetch(PDO::FETCH_COLUMN)); + } + + #[Test] + public function generatesImageForSpecificMagentoVersion() + { + $this->assertEquals( + 'ghcr.io/ecomdev/testcontainer-magento-data/mysql:2.4.7-p2-sampledata', + DbContainerBuilder::mysql() + ->withSampleData() + ->withMagentoVersion('2.4.7-p2') + ->getImageName() + ); + } + + #[Test] + public function generatesMariadbImageForMagentoVersion() + { + $this->assertEquals( + 'ghcr.io/ecomdev/testcontainer-magento-data/mariadb:2.4.7-p2-sampledata', + DbContainerBuilder::mariadb() + ->withSampleData() + ->withMagentoVersion('2.4.7-p2') + ->getImageName() + ); + } +} diff --git a/tests/OpenSearchContainerTest.php b/tests/OpenSearchContainerTest.php new file mode 100644 index 0000000..94c2485 --- /dev/null +++ b/tests/OpenSearchContainerTest.php @@ -0,0 +1,93 @@ +assertEquals( + 'ghcr.io/ecomdev/testcontainer-magento-data/opensearch:latest', + OpenSearchContainerBuilder::new() + ->getImageName() + ); + } + + + #[Test] + public function generatesDefaultImageNameWithCustomMagentoVersion() + { + $this->assertEquals( + 'ghcr.io/ecomdev/testcontainer-magento-data/opensearch:2.4.7-p2', + OpenSearchContainerBuilder::new() + ->withMagentoVersion('2.4.7-p2') + ->getImageName() + ); + } + + + #[Test] + public function generatesSampleDataImageNameWithCustomMagentoVersion() + { + $this->assertEquals( + 'ghcr.io/ecomdev/testcontainer-magento-data/opensearch:2.4.7-p2-sampledata', + OpenSearchContainerBuilder::new() + ->withMagentoVersion('2.4.7-p2') + ->withSampleData() + ->getImageName() + ); + } + + #[Test] + #[Group("slow")] + public function createsContainerWithSampleData() + { + $container = OpenSearchContainerBuilder::new() + ->withSampleData() + ->withMagentoVersion('2.4.7-p2') + ->build(); + + $client = new Client([ + 'base_uri' => $container->getBaseUrl() + ]); + + $result = json_decode( + $client->get('magento2_product_1/_count')->getBody()->getContents(), + true + ); + + $this->assertEquals( + 181, + $result['count'] + ); + } + + #[Test] + #[Group("slow")] + public function sharedContainersSmokeTest() + { + $this->assertSame( + OpenSearchContainerBuilder::new() + ->withSampleData() + ->shared('instance1'), + OpenSearchContainerBuilder::new() + ->withSampleData() + ->shared('instance1'), + ); + + $this->assertNotSame( + OpenSearchContainerBuilder::new() + ->withSampleData() + ->shared('instance1'), + OpenSearchContainerBuilder::new() + ->withSampleData() + ->shared('instance2'), + ); + } +}