Skip to content

Testing

Muhammet Şafak edited this page May 24, 2026 · 1 revision

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.

Requirements

  • ext-pdo
  • ext-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 test base

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:

  1. 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.
  2. DB::replaceImmutable(null) in tearDown() clears the facade slot so the next test does not inherit it.

Helpers

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.

Fixture models

The package's fixtures live under tests/Support/Fixtures/:

  • PostModel — soft-delete-enabled with auto-filled timestamps.
  • PostEntity — accessor + mutator (both using setAttribute()).
  • 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.

A complete example

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
    }
}

Testing your own model

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']);
    }
}

Asserting against the generated SQL

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']);
}

Running the suite

composer test                  # phpunit
composer test:coverage         # phpunit with HTML coverage at build/coverage/
composer qa                    # phpcs + phpstan + phpunit

CI runs the same matrix across PHP 8.1, 8.2, 8.3, and 8.4 on every push and pull request — see .github/workflows/.

Testing against a real database

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).

Common pitfalls

Forgetting to reseed

: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.

Forgetting to clear the facade

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.

Dynamic property warnings hiding behind tests

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.

Read also

Clone this wiki locally