Skip to content

Commit e334f8a

Browse files
committed
feat: added support for database testing via IntegrationUtility
chore: added examples for database testing chore: simplified code coverage to not use execution paths chore: added suggestion to install testcontainers additionally to this package
1 parent e3fbde4 commit e334f8a

11 files changed

+373
-22
lines changed

README.md

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Essentials for testing Magento 2 modules
1+
# 🎯 Essentials for testing Magento 2 modules
22

33
Using mocking frameworks for testing Magento 2 modules is counterproductive as you replicate line by line your actual calls to a magento implementation.
44

@@ -11,11 +11,16 @@ As well as set of fake objects there is an `ObjectManagerInterface` implementat
1111
Each fake object is covered by automated tests to make sure that behaviour is correct your tests can test your code specific behaviour by using different scenarios.
1212

1313

14-
## Installation
14+
## 📦 Installation
1515
```bash
1616
composer require --dev ecomdev/magento2-test-essentials
1717
```
1818

19+
For database based tests it is recommended also to install collection of [testcontainers](https://github.com/EcomDev/testcontainer-magento-data):
20+
```bash
21+
composer require --dev ecomdev/testcontainers-magento-data
22+
```
23+
1924
## Examples
2025

2126
### Testing Class with Store and Configuration
@@ -56,7 +61,7 @@ class YourService
5661
}
5762
```
5863

59-
64+
**YourServiceTest.php**
6065
```php
6166
use PHPUnit\Framework\TestCase;
6267
use PHPUnit\Framework\Attributes\Test;
@@ -91,7 +96,7 @@ class YourServiceTest extends TestCase
9196

9297
// Creates product without any constructor
9398
// but if you rely on data fields, it works great
94-
$product = $this->objectManager->create(Product::class);
99+
$product = $this->objectManager->get(Product::class);
95100

96101
$applier->applyCurrentStoreToProduct(
97102
$product
@@ -109,7 +114,7 @@ class YourServiceTest extends TestCase
109114
$this->objectManager->get(ScopeConfig::class)
110115
);
111116

112-
$product = $this->objectManager->create(Product::class);
117+
$product = $this->objectManager->get(Product::class);
113118

114119
$applier->applyCurrentStoreToProduct(
115120
$product
@@ -120,19 +125,87 @@ class YourServiceTest extends TestCase
120125
}
121126
```
122127

123-
### Easy Resource Model Testing
128+
### Easy Real Database Integration Testing
124129

130+
In combination with magento data [testcontainers](https://github.com/EcomDev/testcontainer-magento-data-php) it is possible to write quick integration tests
131+
132+
Imagine your service depends on Magento's `ResourceConnection` which is almost impossible to instantiate without installing whole Magento app:
133+
```php
134+
use Magento\Framework\App\ResourceConnection;
135+
136+
class SomeSimpleService
137+
{
138+
public function __construct(private readonly ResourceConnection $resourceConnection)
139+
{
140+
}
141+
142+
public function totalNumberOfSimpleProducts(): int
143+
{
144+
$connection = $this->resourceConnection->getConnection();
145+
$select = $connection->select()
146+
->from(
147+
$this->resourceConnection->getTableName('catalog_product_entity'),
148+
['count' => 'COUNT(*)']
149+
)
150+
->where('type_id = ?', 'simple')
151+
152+
return (int)$connection->fetchOne($select);
153+
}
154+
}
125155
```
126-
TBD
156+
157+
Now you can create all the required dependencies with the help of `IntegrationUtility` by specifying connection details:
158+
```php
159+
160+
use EcomDev\TestContainers\MagentoData\DbConnectionSettings;
161+
use EcomDev\TestContainers\MagentoData\DbContainerBuilder;
162+
use Magento\Framework\App\ResourceConnection;
163+
use PHPUnit\Framework\Attributes\Test;
164+
use PHPUnit\Framework\TestCase;
165+
166+
class IntegrationUtilityTest extends TestCase
167+
{
168+
#[Test]
169+
public function returnsCorrectAmountOfSimpleProductsInSampleDataDb()
170+
{
171+
$container = DbContainerBuilder::mysql()
172+
->withSampleData()
173+
->build();
174+
175+
$connectionSettings = $connection->getConnectionSettings();
176+
177+
$objectManager = IntegrationUtility::setupDatabaseObjects(
178+
DeploymentConfig::new()
179+
->withDatabaseConnection(
180+
$connection->host,
181+
$connection->user,
182+
$connection->password,
183+
$connection->database
184+
)
185+
);
186+
187+
$service = $objectManager->get(SomeSimpleService::class);
188+
189+
$this->assertEquals(
190+
1891,
191+
$service->totalNumberOfSimpleProducts()
192+
);
193+
}
194+
}
127195
```
128196

197+
Now there is no excuse to not write tests for your database components!
129198

130-
## Features
199+
## Features
131200

132201
- [x] `ObjectManagerInterface` implementation which mimics platform's behaviour
133202
- [x] `StoreInterface` implementation as a simple data object for testing store related behaviour
134203
- [x] `GroupInterface` implementation as a simple data object for testing store group related behaviour
135204
- [x] `WebsiteInterface` implementation as a simple data object for testing website related behaviour
136205
- [x] `ScopeConfigurationInterface` implementation for testing configuration dependent functionality
137206
- [x] `DeploymentConfig` implementation for using in configuration caches, db connections, http cache, etc
138-
- [ ] `ResourceConnection` implementation for quick testing of database components
207+
- [x] `ResourceConnection` implementation for quick testing of database components
208+
209+
## 📜 License
210+
211+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"require-dev": {
1414
"squizlabs/php_codesniffer": "^3.0",
1515
"phpunit/phpunit": "^11.5",
16-
"brianium/paratest": "^7.7"
16+
"brianium/paratest": "^7.7",
17+
"ecomdev/testcontainers-magento-data":"~1.1"
1718
},
1819
"suggest": {
1920
"ecomdev/testcontainers-magento-data": "Database Containers pre-populated with various data",

phpunit.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@
1010
<directory>src</directory>
1111
</include>
1212
</source>
13-
<coverage ignoreDeprecatedCodeUnits="true" pathCoverage="true">
13+
<coverage ignoreDeprecatedCodeUnits="true">
1414
</coverage>
1515
</phpunit>

src/DeploymentConfig.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public function withDatabaseConnection(
4343
->withSetting($this->databaseSettingPath($connectionName, 'dbname'), $dbname)
4444
->withSetting($this->databaseSettingPath($connectionName, 'active'), 1)
4545
->withSetting($this->databaseSettingPath($connectionName, 'engine'), 'innodb')
46-
->withSetting($this->databaseSettingPath($connectionName, 'initStatements'), ['SET NAMES utf8'])
46+
->withSetting($this->databaseSettingPath($connectionName, 'initStatements'), 'SET NAMES utf8')
4747
->withSetting($this->databaseSettingPath($connectionName, 'driver_options'), [
4848
PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false
4949
]);

src/IntegrationUtility.php

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
namespace EcomDev\Magento2TestEssentials;
4+
5+
use Magento\Backend\Helper\Js;
6+
use Magento\Framework\App\ResourceConnection;
7+
use Magento\Framework\App\ResourceConnection\ConfigInterface;
8+
use Magento\Framework\App\ResourceConnection\ConnectionAdapterInterface;
9+
use Magento\Framework\DB\Adapter\Pdo\MysqlFactory;
10+
use Magento\Framework\DB\Adapter\Pdo\Mysql as PdoMysql;
11+
use Magento\Framework\DB\Logger\Quiet;
12+
use Magento\Framework\DB\LoggerInterface;
13+
use Magento\Framework\DB\Select\ColumnsRenderer;
14+
use Magento\Framework\DB\Select\DistinctRenderer;
15+
use Magento\Framework\DB\Select\ForUpdateRenderer;
16+
use Magento\Framework\DB\Select\FromRenderer;
17+
use Magento\Framework\DB\Select\GroupRenderer;
18+
use Magento\Framework\DB\Select\HavingRenderer;
19+
use Magento\Framework\DB\Select\LimitRenderer;
20+
use Magento\Framework\DB\Select\OrderRenderer;
21+
use Magento\Framework\DB\Select\SelectRenderer;
22+
use Magento\Framework\DB\Select\UnionRenderer;
23+
use Magento\Framework\DB\Select\WhereRenderer;
24+
use Magento\Framework\DB\SelectFactory;
25+
use Magento\Framework\Model\ResourceModel\Type\Db\ConnectionFactory;
26+
use Magento\Framework\Model\ResourceModel\Type\Db\ConnectionFactoryInterface;
27+
use Magento\Framework\Model\ResourceModel\Type\Db\Pdo\Mysql;
28+
use Magento\Framework\Serialize\Serializer\Json;
29+
use Magento\Framework\Serialize\SerializerInterface;
30+
31+
final class IntegrationUtility
32+
{
33+
public static function withSetupDatabaseObjects(
34+
ObjectManager $objectManager,
35+
DeploymentConfig $deploymentConfig
36+
): ObjectManager {
37+
38+
return $objectManager
39+
->withObject(\Magento\Framework\App\DeploymentConfig::class, $deploymentConfig)
40+
->withObject(DeploymentConfig::class, $deploymentConfig)
41+
->withFactory(
42+
ConnectionFactoryInterface::class,
43+
static fn ($objectManager) => new ConnectionFactory($objectManager)
44+
)
45+
->withDefaultArguments(PdoMysql::class, [
46+
'serializer' => new Json()
47+
])
48+
->withObject(LoggerInterface::class, new Quiet())
49+
->withObject(SerializerInterface::class, new Json())
50+
->withFactory(SelectRenderer::class, static fn ($objectManager) => new SelectRenderer([
51+
'distinct' => [
52+
'renderer' => $objectManager->create(DistinctRenderer::class),
53+
'sort' => 100,
54+
'part' => 'distinct',
55+
],
56+
'columns' => [
57+
'renderer' => $objectManager->create(ColumnsRenderer::class),
58+
'sort' => 200,
59+
'part' => 'columns',
60+
],
61+
'union' => [
62+
'renderer' => $objectManager->create(UnionRenderer::class),
63+
'sort' => 300,
64+
'part' => 'union',
65+
],
66+
'from' => [
67+
'renderer' => $objectManager->create(FromRenderer::class),
68+
'sort' => 400,
69+
'part' => 'from',
70+
],
71+
'where' => [
72+
'renderer' => $objectManager->create(WhereRenderer::class),
73+
'sort' => 500,
74+
'part' => 'where',
75+
],
76+
'group' => [
77+
'renderer' => $objectManager->create(GroupRenderer::class),
78+
'sort' => 600,
79+
'part' => 'group',
80+
],
81+
'having' => [
82+
'renderer' => $objectManager->create(HavingRenderer::class),
83+
'sort' => 700,
84+
'part' => 'having',
85+
],
86+
'order' => [
87+
'renderer' => $objectManager->create(OrderRenderer::class),
88+
'sort' => 800,
89+
'part' => 'order',
90+
],
91+
'limit' => [
92+
'renderer' => $objectManager->create(LimitRenderer::class),
93+
'sort' => 900,
94+
'part' => 'limitcount',
95+
],
96+
'for_update' => [
97+
'renderer' => $objectManager->create(ForUpdateRenderer::class),
98+
'sort' => 1000,
99+
'part' => 'forupdate'
100+
]
101+
]))
102+
->withFactory(
103+
SelectFactory::class,
104+
static fn ($objectManager, array $arguments) => new SelectFactory(
105+
$objectManager->get(SelectRenderer::class),
106+
$arguments['parts'] ?? []
107+
)
108+
)
109+
->withFactory(
110+
ConnectionAdapterInterface::class,
111+
static fn ($objectManager, $arguments) => new Mysql($arguments['config'] ?? [], $objectManager->create(MysqlFactory::class))
112+
)
113+
->withFactory(
114+
ConfigInterface::class,
115+
static fn (ObjectManager $objectManager) => $objectManager
116+
->create(ResourceConnectionConfig::class)
117+
)
118+
->withFactory(
119+
ResourceConnection::class,
120+
static fn (ObjectManager $objectManager) => new ResourceConnection(
121+
$objectManager->get(ConfigInterface::class),
122+
$objectManager->get(ConnectionFactoryInterface::class),
123+
$objectManager->get(DeploymentConfig::class)
124+
)
125+
);
126+
}
127+
128+
public static function setupDatabaseObjects(DeploymentConfig $deploymentConfig): ObjectManager
129+
{
130+
return self::withSetupDatabaseObjects(ObjectManager::new(), $deploymentConfig);
131+
}
132+
}

src/ObjectManager.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ final class ObjectManager implements ObjectManagerInterface
2626
*/
2727
private array $factories = [];
2828

29+
/**
30+
* List of default arguments for an object creation
31+
*
32+
* @var array[]
33+
*/
34+
private array $defaultArguments = [];
35+
2936
/**
3037
* Creates a new instance of ObjectManager
3138
*/
@@ -83,6 +90,16 @@ public function withFactory(string $type, callable $factory): self
8390
return $objectManager;
8491
}
8592

93+
/**
94+
* Adds a factory for a specific type an object manager
95+
*/
96+
public function withDefaultArguments(string $type, array $arguments): self
97+
{
98+
$objectManager = clone $this;
99+
$objectManager->defaultArguments[$type] = $arguments;
100+
return $objectManager;
101+
}
102+
86103
/**
87104
* Creates an instance of an object
88105
*/
@@ -100,6 +117,7 @@ private function createObject(string $type, array $arguments = null): object
100117
return $class->newInstanceWithoutConstructor();
101118
}
102119

120+
$arguments += $this->defaultArguments[$type] ?? [];
103121
$constructorArgs = [];
104122

105123
foreach ($constructor->getParameters() as $parameter) {

src/ResourceConnectionConfig.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace EcomDev\Magento2TestEssentials;
4+
5+
use Magento\Framework\App\ResourceConnection\ConfigInterface;
6+
7+
final class ResourceConnectionConfig implements ConfigInterface
8+
{
9+
public function __construct(private readonly DeploymentConfig $deploymentConfig)
10+
{
11+
}
12+
13+
public function getConnectionName($resourceName)
14+
{
15+
if ($this->deploymentConfig->get('db/connection/' . $resourceName)) {
16+
return $resourceName;
17+
}
18+
19+
return 'default';
20+
}
21+
}

0 commit comments

Comments
 (0)