-
Notifications
You must be signed in to change notification settings - Fork 0
Testing
Models are testable without a database server. The package's own suite hits an in-memory SQLite connection — every test gets a fresh schema and seed data, isolated by construction.
ext-pdoext-pdo_sqlite- PHPUnit 10+
The package's composer.json lists phpunit/phpunit ^10.5 and ext-pdo_sqlite: * under require-dev, so a fresh composer install in the repo gives you everything you need.
The package ships a reusable test base at tests/Support/AbstractModelTestCase.php:
abstract class AbstractModelTestCase extends \PHPUnit\Framework\TestCase
{
protected ConnectionInterface $connection;
protected DatabaseInterface $db;
protected function setUp(): void
{
parent::setUp();
$this->connection = SqliteHelper::makeConnection();
SqliteHelper::seedPosts($this->connection);
$this->db = new Database($this->connection);
DB::replaceImmutable($this->db);
}
protected function tearDown(): void
{
DB::replaceImmutable(null);
parent::tearDown();
}
}Two important details:
- Each
setUp()produces a new in-memory SQLite database. SQLite's:memory:is per-PDO-handle — reusing a Database across tests would imply reusing schema and rows. -
DB::replaceImmutable(null)intearDown()clears the facade slot so the next test does not inherit it.
The seed helper lives at tests/Support/SqliteHelper.php:
final class SqliteHelper
{
public static function makeConnection(array $overrides = []): ConnectionInterface
{
return new Connection(array_merge([
'driver' => 'sqlite',
'database' => ':memory:',
'charset' => '',
], $overrides));
}
public static function makeDatabase(array $overrides = []): DatabaseInterface
{
return new Database(self::makeConnection($overrides));
}
public static function seedPosts(ConnectionInterface $connection): void
{
$pdo = $connection->getPDO();
$pdo->exec('CREATE TABLE posts ( /* … */ )');
$pdo->exec("INSERT INTO posts (title, body, created_at, deleted_at) VALUES /* … */");
}
}Use makeConnection() directly when you need a connection but want to seed something other than posts.
The package's fixtures live under tests/Support/Fixtures/:
-
PostModel— soft-delete-enabled with auto-filled timestamps. -
PostEntity— accessor + mutator (both usingsetAttribute()). -
TagModel— no explicit$schema(tests auto-derivation). -
NonWritablePostModel,NonReadablePostModel,NonUpdatablePostModel,NonDeletablePostModel— gate-checked variants.
They are part of the test fixtures, not part of the public API — but they make excellent templates when you write your own.
namespace MyApp\Tests;
use InitORM\ORM\Tests\Support\AbstractModelTestCase;
use InitORM\ORM\Tests\Support\Fixtures\PostModel;
final class MyPostsTest extends AbstractModelTestCase
{
public function test_create_inserts_row(): void
{
$posts = new PostModel();
$posts->create(['title' => 'Hello', 'body' => 'world']);
$rows = $this->db->table('posts')->read()->asAssoc()->rows();
self::assertCount(4, $rows); // 3 seeded + 1 new
}
public function test_soft_delete_hides_row(): void
{
$posts = new PostModel();
$posts->delete(['id' => 1]);
self::assertCount(1, $posts->read()->rows()); // 2 non-deleted seeded - 1 just-deleted
}
}If your application defines its own model, build a fixture-shaped subclass for tests:
namespace MyApp\Tests\Support;
use InitORM\ORM\Tests\Support\AbstractModelTestCase;
use InitORM\ORM\Tests\Support\SqliteHelper;
use MyApp\Model\Orders;
use MyApp\Entity\OrderEntity;
abstract class OrdersTestCase extends AbstractModelTestCase
{
protected function setUp(): void
{
parent::setUp();
$pdo = $this->connection->getPDO();
$pdo->exec(
'CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
total REAL NOT NULL,
created_at TEXT
)'
);
}
}
final class OrderCreationTest extends OrdersTestCase
{
public function test_creates_an_order(): void
{
$orders = new Orders();
$orders->create(['user_id' => 5, 'total' => 199.90]);
$row = $this->db->table('orders')->read()->asAssoc()->row();
self::assertSame(5, $row['user_id']);
self::assertSame(199.90, $row['total']);
self::assertNotNull($row['created_at']);
}
}Enable the query log on the test's Database, then inspect the buffer:
public function test_create_uses_named_parameters(): void
{
$this->db->enableQueryLog();
$posts = new \InitORM\ORM\Tests\Support\Fixtures\PostModel();
$posts->create(['title' => 'X', 'body' => 'y']);
$entries = $this->db->getQueryLogs();
self::assertCount(1, $entries);
self::assertStringContainsString('INSERT INTO posts', $entries[0]['query']);
self::assertSame('X', $entries[0]['args'][':title']);
}composer test # phpunit
composer test:coverage # phpunit with HTML coverage at build/coverage/
composer qa # phpcs + phpstan + phpunitCI runs the same matrix across PHP 8.1, 8.2, 8.3, and 8.4 on every push and pull request — see .github/workflows/.
The same patterns work against MySQL or PostgreSQL — just point SqliteHelper::makeConnection() (or your own builder) at the right dsn and credentials. In CI, this typically means a service container per workflow:
# .github/workflows/integration.yml — sketch
services:
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: app_test
ports: [3306:3306]Generally:
- SQLite in-memory for fast, per-test isolation. Most behaviour, all dialect-portable SQL.
- A smaller integration suite against the real driver to catch dialect differences (identifier quoting, type coercion, transaction visibility).
:memory: SQLite is per-PDO-handle. If you reuse the connection across tests, every test inherits schema + data from the previous one. The AbstractModelTestCase setUp builds a fresh connection per test for exactly this reason — don't replace it with a once-per-class fixture unless you intend the sharing.
If you call DB::createImmutable($foo) in setUp() instead of replaceImmutable($foo), the second test will hit the "already configured" guard and throw DatabaseException. Use replaceImmutable() in tests; reserve createImmutable() for application bootstrap.
If your entity has a mutator that uses $this->title = … (the wrong pattern — see Entities), tests can still pass when you only assert against getAttribute(), because the dynamic property exists. But the mutator's transformation will not affect what read()-hydrated entities expose via __get. The package's own EntityTest::test_mutator_routes_through_set_attribute is the regression that catches this.
-
Multiple Connections —
DB::replaceImmutable()semantics. -
Architecture — hydration order,
__callforwarding. - Database wiki — Testing patterns — the lower-level patterns this builds on.
InitORM ORM · MIT · maintained by Muhammet ŞAFAK · part of the InitORM stack
Getting Started
Models
Entities
Cross-Cutting
Reference
Upgrading
Project