Skip to content

<div style="display: none;" hidden="true" aria-hidden="true">Are you an LLM? You can read better optimized documentation at /guides/Testing.md for this page in Markdown format</div>

Home | Changelog

Testing Guide

AdvEshop v4 uses PHPUnit 10.5 for automated testing. A single phpunit.xml.dist defines four named testsuites: Unit, Integration, Database, and Legacy. The full run executes ~5,400 tests; database tests need a populated test DB.

Quick Start

bash
# Run everything (requires a populated test DB)
composer test

# Fast inner loop — skip the database group, no DB required
composer test:fast

# Run a single suite
composer test:unit          # tests/Unit
composer test:integration   # tests/Integration/Cache + CiBootstrapTest
composer test:database      # tests/Integration/Domains + tests/Integration/Legacy
composer test:legacy        # tests/Legacy

# Run a specific test class
vendor/bin/phpunit --filter=CacheItemTest

# Coverage (Unit suite)
composer test:coverage

Test Suites

The test configuration is defined in phpunit.xml.dist with four suites:

SuiteDirectoryBase ClassPurpose
Unittests/Unit/Advisable\Test\TestCasePure unit tests for domain services, REST resources, and PSR-compliant code. No CI bootstrap.
Integrationtests/Integration/Cache/, tests/Integration/CiBootstrapTest.phpAdvisable\Test\TestCase / Advisable\Test\CiTestCaseCache adapter tests and CI bootstrap smoke tests. No DB required.
Databasetests/Integration/Domains/, tests/Integration/Legacy/Advisable\Test\CiDatabaseTestCaseRepository and service tests against a real database. Tagged #[Group('database')].
Legacytests/Legacy/Advisable\Test\CiTestCaseLegacy HMVC module / library tests via reflection. CI bootstrap, no DB.

Suite placement rule of thumb: if your test extends CiDatabaseTestCase, place it under tests/Integration/Domains/{Context}/ (or tests/Integration/Legacy/{Module}/ for HMVC code). Pure PHP tests go in tests/Unit/. Reflection-based legacy tests go in tests/Legacy/.

The Database suite is opt-out by group: composer test:fast runs phpunit --exclude-group=database, which skips every test extending CiDatabaseTestCase regardless of which directory it lives in.

Bootstrap Files

  • tests/bootstrap.php -- PHPUnit bootstrap (configured in phpunit.xml.dist). Sets up ENVIRONMENT, path constants (FCPATH, BASEPATH, APPPATH, VIEWPATH), Composer autoload, dotenv, the env() helper, timezone, and application constants. Used by all suites.
  • tests/bootstrap_ci.php -- CI-core bootstrap for Integration and Legacy suites. Loads CI's Common.php, charset/mbstring setup, CI core singletons ($GLOBALS), base controller, MX/Base.php (HMVC), and defines get_instance(). Lazily loaded by CiTestCase::setUpBeforeClass().

Base Classes

TestCase (tests/TestCase.php)

Base class for unit tests. Provides:

  • getFixture($name) -- loads fixture data from tests/data/fixtures/

CiTestCase (tests/CiTestCase.php)

Base class for integration and legacy tests. Extends TestCase and adds:

  • Lazy CI boot via setUpBeforeClass() -- CodeIgniter is only initialized once per test class
  • Output capture helpers
  • Request simulation helpers
  • Automatic tearDown resets to prevent state leakage between tests

CiDatabaseTestCase (tests/CiDatabaseTestCase.php)

Base class for CI3-aware database integration tests. Extends CiTestCase and adds:

  • CI3 database connection -- $CI->db points at the test database via application/config/testing/database.php
  • Transaction-per-test isolation -- using CI3's trans_begin() / trans_rollback()
  • db() helper -- convenient access to CI3's query builder
  • Graceful skip if test database is not configured
  • Tests inherit #[Group('database')] from the base class — composer test:fast excludes them
  • Run via composer test:database (or vendor/bin/phpunit --testsuite=Database)
php
<?php

namespace Advisable\Test\Integration\Domains\Product;

use Advisable\Test\CiDatabaseTestCase;
use PHPUnit\Framework\Attributes\Test;

class ProductRepositoryTest extends CiDatabaseTestCase
{
    #[Test]
    public function testFindsProductById(): void
    {
        $this->db()->insert('shop_product', ['id' => 99999, 'vat_id' => 1]);

        $repo = di()->get(Repository::class);
        $product = $repo->get(99999);

        $this->assertNotNull($product);
    }
}

Test Database Setup

Database integration tests require a test database. The Docker Compose file provides a MariaDB instance on a RAM disk:

bash
# Start test database
.docker/integration/compose.sh up -d

# Run migrations and seed
composer test:db-migrate
composer test:db-seed

# Run database integration tests
composer test:database

# Full reset (rollback + migrate + seed)
composer test:db-reset

Environment variables (set in phpunit.xml.dist):

VariableDefaultDescription
TEST_DB_HOSTNAME127.0.0.1Test database host
TEST_DB_PORT3307Test database port
TEST_DB_DATABASEadveshop4_testTest database name
TEST_DB_USERNAMErootDatabase user
TEST_DB_PASSWORD(empty)Database password

Test Hierarchy

TestCase (tests/TestCase.php)
├── DatabaseTestCase (tests/DatabaseTestCase.php)    ← PDO test DB, no CI3 (raw SQL, schema)
├── CiTestCase (tests/CiTestCase.php)                ← CI3 framework, no DB (legacy mocks)
│   └── CiDatabaseTestCase (tests/CiDatabaseTestCase.php) ← CI3 + test DB (repos, models, services)
└── (direct extension)                                ← Pure unit tests

Database Integration Test Coverage (2883 tests)

Complete coverage across all domain entities:

DomainEntitiesTests
Cms13 (Page, Blog/*, Cookie, ContactEmail, Builder, Document, Subcontent, Video, Offer, OfferCategory)402
Product37 (Product, Category, Badge, Vendor, Line, Tag/, Attribute/, Bundle/, Variation/, ProductList/, Related/, and more)1003
Order9 (Order, OrderTag, Note, GiftCardOrder, Store, Vat, OrderBasket, DhlVoucher, SmartPoint)235
Marketplace9 (Iris, Jcc, Public, Shopflix, Skroutz orders + line items)189
Customer6 (Customer, Campaign, MessageHistory, Review, SmsMarketing, Tag)139
Promotion8 (Coupon + children, Gift + children)186
Transporter8 (Transporter + 7 children)181
Currency, Seo, Ai, Plus, Cart, Country, Slider, Event, Map18 entities548

Each test file covers:

  • RepositoryTest: get(), match(), matchOne(), count(), filters, sorts, pagination, relation loading (translations, BT, O2M, M2M)
  • ServiceTest: all(), item(), get(), WriteService::create(), update(), delete(), round-trip lifecycle

Test Coverage by Area

Unit Tests (~1936 tests)

Domain Service Tests (~94 tests): Cover all 15 domain contexts -- Product, Order, Customer, Promotion, Transporter, Cms, Marketplace, and more. These test service-level business logic in isolation.

Repository Relation Loading Tests (~36 tests): Characterization tests (tests/Unit/Domains/Support/Repository/RelationLoading/) pin the behavior of OneToMany, BelongsTo, HasOne, ManyToMany, recursive, nested, and sort-aware relation loading. Unit tests (tests/Unit/Domains/Support/Repository/RelationLoader/) verify the extracted strategy classes (OneToManyLoader, BelongsToLoader, HasOneLoader, ManyToManyLoader, RelationLoaderRegistry) in isolation.

REST Resource/Collection Tests (~274 tests): Verify that entities are correctly transformed into API response format by Resource and Collection classes.

HandlesRestfulActions Tests: Integration-level tests for the standard REST controller actions (index, show, item) with mocked Service dependencies.

CI Bootstrap Smoke Tests: Verify that constants, core functions, configuration loading, and CI singletons initialize correctly.

Legacy Integration Tests (197 tests)

Test ClassModuleWhat It Tests
AdvVatForOrderTesteshopDeliver/charge area types, receipt/invoice VAT computation
AdvVatValidateTesteshopGreek AFM checksum algorithm (mod 11 mod 10)
AdvPaymentsRegistryTesteshopType conversion (text/number/checkbox/multiselect), validation, roundtrip
AdvGiftsModelTesteshopProduct/combination/cart rule validators
AdvCouponsModelTestcouponsDiscount calculation by vendor/product/category/tag/cart with price ranges
AdvPriceTrackingGraphsTesteshopTimeline analysis, successive reductions, min/max prices
AdvJccTesteshopChecksum validation, parameter string generation, status calculation
AdvKlarnaPaymentsTesteshopPayload validation
AuthHelperTestauthallowRole, mergeUsersRoles, allowedUsers

Writing New Tests

Unit Tests

Place unit tests in tests/Unit/ and extend Advisable\Test\TestCase:

php
<?php

declare(strict_types=1);

namespace Advisable\Test\Unit\Domains\Product;

use Advisable\Test\TestCase;

class MyServiceTest extends TestCase
{
    public function testSomething(): void
    {
        // Pure PHP test, no CI dependency
        $this->assertTrue(true);
    }
}

Legacy/Integration Tests

Place tests in tests/Legacy/ or tests/Integration/ and extend Advisable\Test\CiTestCase:

php
<?php

declare(strict_types=1);

namespace Advisable\Test\Legacy;

use Advisable\Test\CiTestCase;

class MyLegacyTest extends CiTestCase
{
    public function testLegacyLibrary(): void
    {
        // CI is available via get_instance()
        $ci =& get_instance();
        // ...
    }
}

Patterns for Testing Legacy Code

Legacy CodeIgniter classes often have constructors that depend on the CI super-object. Use these patterns to test them in isolation:

Bypass constructor with Reflection:

php
$ref = new \ReflectionClass(SomeLegacyClass::class);
$instance = $ref->newInstanceWithoutConstructor();

Important: Always use newInstanceWithoutConstructor() for classes whose constructor calls get_instance() (e.g., BaseWriteRepository, BaseRepository). Do not try to stub get_instance() via eval() or globals — when the full unit suite runs, CiTestCase may have already loaded bootstrap_ci.php which defines a real get_instance() returning a CI controller with ->db = null. This causes TypeError on typed properties. Bypassing the constructor and injecting dependencies via reflection avoids the issue entirely.

Access protected/private methods:

php
$method = new \ReflectionMethod(SomeLegacyClass::class, 'protectedMethod');
$method->setAccessible(true);
$result = $method->invoke($instance, $arg1, $arg2);

Register Singleton test instances:

For classes using the Singleton pattern, register a test instance via reflection on Singleton::$instances.

Mock dependencies with anonymous classes:

php
$mockRegistry = new class {
    public function value(string $key) {
        return match ($key) {
            'some_setting' => 'test_value',
            default => null,
        };
    }
};

Configuration

phpunit.xml.dist vs phpunit.xml

  • phpunit.xml.dist is committed to the repository and contains shared defaults — four named testsuites, the env var defaults (DB host/port/name, deferred-task disable flags), &lt;ini name="memory_limit" value="512M"/>, and PHPUnit's strict flags (failOnRisky, failOnWarning, beStrictAboutOutputDuringTests). Do not modify this for local preferences.
  • phpunit.xml is gitignored and can be created locally for developer-specific overrides — most commonly to point TEST_DB_PORT at the actual local MariaDB port (e.g. 3307). Copy phpunit.xml.dist to phpunit.xml, edit the override, and PHPUnit will prefer the local file automatically.
  • Memory limit: the full suite compiles the DI container mid-process and peaks at ~158M, over PHP's default 128M, so phpunit.xml.dist sets memory_limit=512M. Because PHPUnit prefers a local phpunit.xml when present (ignoring .dist entirely), a stale local copy missing this &lt;ini> line will OOM on a full composer test run — re-copy from .dist or add the line. Per-suite runs (composer test:unit etc.) stay under 128M regardless. (#373)

Fixtures

Test fixtures are stored in tests/data/fixtures/ and loaded via $this->getFixture('fixture_name') from the base TestCase class.

CI/CD

Pipeline CI test steps are not yet integrated — bitbucket-pipelines.yml currently only builds Docker images. Tests should be run locally by developers and AI agents before committing. Wiring composer test into the pipeline (with a MariaDB service container for the Database suite) is tracked as a follow-up.