phalcon / talon
Test harness and Phalcon bootstrapping for PHPUnit and beyond
Requires
- php: ^8.1
- phalcon/cli-options-parser: ^2.0
- symfony/browser-kit: ^6.4 || ^7.0
- symfony/dom-crawler: ^6.4 || ^7.0
- symfony/http-client: ^6.4 || ^7.0
- symfony/mime: ^6.4 || ^7.0
Requires (Dev)
- ext-pdo: *
- ext-sqlite3: *
- friendsofphp/php-cs-fixer: ^3
- pds/composer-script-names: ^1
- pds/skeleton: ^1
- phalcon/phalcon: ^6.0@alpha || ^6.0 || ^6.0 || ^6.0
- phpstan/phpstan: ^2
- phpunit/phpcov: ^9
- phpunit/phpunit: ^10.5
- predis/predis: ^2 || ^3
- squizlabs/php_codesniffer: ^3 || ^4
Suggests
- ext-memcached: For the Services (Memcached) helpers
- ext-phalcon: Phalcon C extension (^5) - one of ext-phalcon or phalcon/phalcon is required
- phalcon/phalcon: Phalcon PHP implementation (^6) - alternative to the C extension
- predis/predis: For the Services (Redis) helpers
README
Test harness and Phalcon bootstrapping for PHPUnit and beyond - the part of Phalcon that catches the bugs.
Talon provides framework-neutral traits (the core), ready-to-extend PHPUnit base classes, and a one-liner bootstrap so any Phalcon project can write unit, integration, and functional tests with minimal boilerplate.
Requirements
- PHP
^8.1 - Phalcon - either the
ext-phalconC extension (^5) or thephalcon/phalconPHP implementation (^6). Talon detects whichever is present.
Install
composer require --dev phalcon/talon
Bootstrap (one-liner)
// tests/bootstrap.php require __DIR__ . '/../vendor/autoload.php'; use Phalcon\Talon\Settings; use Phalcon\Talon\Talon; Talon::boot(Settings::fromEnv());
Need setup hooks (the old loadIni / loadFolders)? Use the bootstrap runner:
use Phalcon\Talon\Bootstrap\Runner; use Phalcon\Talon\Bootstrap\Stage; use Phalcon\Talon\Settings; Runner::for(Settings::fromArray(['root' => __DIR__ . '/..'])) ->before(Stage::Environment, fn () => ini_set('memory_limit', '512M')) ->after(Stage::Directories, fn ($settings) => mkdir($settings->outputPath('screens'), 0777, true)) ->boot();
Unit tests
use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; final class CalculatorTest extends AbstractUnitTestCase { public function testInternal(): void { $this->assertSame(5, $this->callProtectedMethod(new Calculator(), 'add', 2, 3)); } }
AbstractUnitTestCase gives you callProtectedMethod(), getProtectedProperty(), setProtectedProperty(), invokeMethod(), getNewFileName(), safeDeleteFile(), safeDeleteDirectory(), assertFileContentsContains(), checkExtensionIsLoaded(), and checkPhalconAvailable().
Database tests
use Phalcon\Talon\PHPUnit\AbstractDatabaseTestCase; final class UserTest extends AbstractDatabaseTestCase { public function testSeeded(): void { $this->assertInDatabase('users', ['email' => 'john.connor@skynet.dev']); } }
The driver comes from the driver env (sqlite, mysql, mariadb, pgsql); credentials come from Settings (env vars by default - see resources/.env.example).
Schema fixtures
A schema fixture declares one table's DDL per dialect. Talon generates the SQL dumps from them; your tests use the same classes to truncate and populate.
use Phalcon\Talon\Database\Schema\AbstractSchema; final class WidgetSchema extends AbstractSchema { protected string $table = 'widgets'; public function insert(int $id, string $label): int { return $this->execute( 'INSERT INTO widgets (id, label) VALUES (:id, :label)', [':id' => $id, ':label' => $label] ); } protected function getStatementsMysql(): array { return ['CREATE TABLE widgets (id INT PRIMARY KEY, label VARCHAR(64));']; } protected function getStatementsPgsql(): array { return ['CREATE TABLE widgets (id INTEGER PRIMARY KEY, label VARCHAR(64));']; } protected function getStatementsSqlite(): array { return ['CREATE TABLE widgets (id INTEGER PRIMARY KEY, label TEXT);']; } }
The three per-dialect methods are abstract on purpose - a new dialect cannot be silently forgotten. There is no mariadb method: MariaDB uses the MySQL dialect.
- Creation statements only. Do not write a
DROP TABLEyourself; the generator prepends one from the table name. - An empty list means the table does not exist in that dialect. It is skipped entirely, drop included, and gets no manifest entry.
getDependencies()returns the table names that must exist first. Override it when the table carries a foreign key.insert()is yours. The contract covers the lifecycle -create(),drop(),clear()- never the data shape, so each fixture types its own insert signature.
Generating the artifacts
talon schema # every dialect talon schema mysql # one dialect
Configure it with these settings (env vars, or keys in Settings::fromArray()):
| Setting | Meaning |
|---|---|
schema_source |
Directory holding the fixture classes, relative to the project root |
schema_namespace |
Namespace prefix for those classes |
schema_output |
Directory the artifacts are written to, relative to the project root |
schema_pre |
FQCN run before every table - session setup, namespace creation |
schema_post |
FQCN run after every table - closes whatever schema_pre opened |
Each dialect gets its own directory:
schema/mysql/_preSchema.sql always written, even when empty
schema/mysql/users.sql one file per table: its DROP, then its creation statements
schema/mysql/manifest.json load order, dependencies, per-dialect presence
schema/mysql/_postSchema.sql always written, even when empty
The manifest is generated, never hand-edited - if it is wrong, fix a fixture class and regenerate.
Point dump_file at the dialect directory and AbstractDatabaseTestCase loads it on the first connection: pre-schema, the manifest's tables in order, then post-schema.
<env name="dump_file" value="resources/schema/mysql"/>
loadSchema() also still accepts a single flat .sql file, so a project can migrate to the directory format on its own schedule.
Rebuilding one table
Loading the whole schema once and truncating stays the default - fast, ordered, dependency-safe. addTable() is the escape hatch for a test that needs one table rebuilt:
$this->addTable('users');
It is standalone only, and enforced: a table's declared dependencies must already exist, or it throws SchemaDependencyMissing naming the missing one. Call it once per table, dependency first. The strictness is deliberate - schema_pre is live only during the bulk load, so a table with foreign keys that loads fine in bulk can fail standalone on MySQL for reasons nothing at the call site suggests.
Functional tests
The package never owns your container - hand it your configured application:
use Phalcon\Talon\PHPUnit\AbstractFunctionalTestCase; final class HomeTest extends AbstractFunctionalTestCase { protected function appFactory(): callable { return fn () => require __DIR__ . '/../app/bootstrap.php'; // returns a configured Application/Micro } public function testHome(): void { $this->dispatch('/'); $this->assertController('index'); $this->assertResponseContentContains('Welcome'); } }
Browser tests
For multi-request flows - login, forms, redirects - AbstractBrowserTestCase drives your app in-process (no web server) through a symfony/browser-kit bridge, keeping cookies and the session across requests:
use Phalcon\Talon\PHPUnit\AbstractBrowserTestCase; final class LoginTest extends AbstractBrowserTestCase { protected function appFactory(): callable { return fn () => require __DIR__ . '/../app/bootstrap.php'; } public function testLogin(): void { $this->visitPage('/session/login'); $this->fillField('email', 'sarah.connor@skynet.dev'); $this->fillField('password', 'password1'); $this->pressButton('Log In'); $this->assertPageContainsText('Search users'); } }
Verbs: visitPage, fillField, selectOption, clickLink, pressButton, getCookie/setCookie; assertions: assertPageContainsText / assertPageMissingText. Redirects are followed automatically. Needs symfony/browser-kit + symfony/dom-crawler.
Service tests (Redis / Memcached)
use Phalcon\Talon\PHPUnit\AbstractServicesTestCase; final class CacheTest extends AbstractServicesTestCase { public function testRedis(): void { $this->setRedisKey('key', 'value'); $this->assertSame('value', $this->getRedisKey('key')); } }
Service tests skip automatically when the backend is unreachable.
Mocking a Resultset (no database)
use Phalcon\Talon\Traits\ResultSetTrait; final class ReportTest extends \PHPUnit\Framework\TestCase { use ResultSetTrait; public function testReport(): void { $resultset = $this->mockResultSet([$modelA, $modelB]); $this->assertCount(2, $resultset); } }
Custom configuration
Override getSettings() in a project base class, or pass Settings::fromArray([...]) to Talon::boot():
Talon::boot(Settings::fromArray([ 'root' => dirname(__DIR__), 'db' => [ 'mysql' => ['host' => '127.0.0.1', 'port' => 3306, 'dbname' => 'app', 'username' => 'root', 'password' => ''], 'sqlite' => ['dbname' => ':memory:'], ], ]));
Command line runner
vendor/bin/talon fronts PHPUnit per mapped suite:
vendor/bin/talon run # default suite (unit) vendor/bin/talon run mysql vendor/bin/talon run mysql pgsql vendor/bin/talon run all # every mapped suite, sequentially vendor/bin/talon suites # list mapped suites
With zero configuration, suites are discovered from phpunit*.xml files in the project root and resources/ (phpunit.mysql.xml becomes mysql; phpunit.xml.dist becomes unit, the default). Projects that need php ini flags or env vars declare a talon.php at the project root:
return [ 'php' => ['extension=ext/modules/phalcon.so'], // global ini flags, optional 'suites' => [ 'unit' => ['config' => 'resources/phpunit.xml.dist'], 'mariadb' => ['config' => 'resources/phpunit.mariadb.xml'], 'mysql' => ['config' => 'resources/phpunit.mysql.xml'], 'pgsql' => ['config' => 'resources/phpunit.pgsql.xml'], 'sqlite' => ['config' => 'resources/phpunit.sqlite.xml'], ], 'default' => 'unit', ];
Per-suite keys: config (required), php (extra ini flags), env (extra env vars) and args (default PHPUnit arguments) - suite entries merge over the global php/env. Options are forwarded to PHPUnit starting at the first option talon does not recognize itself, and everything after -- is always forwarded verbatim:
vendor/bin/talon run unit -- --filter FooTest --testdox
Each suite runs as its own subprocess (per-suite extensions and env vars work), a single suite's exit code is forwarded verbatim, and multiple suites exit with the maximum code after a per-suite summary.
Beyond PHPUnit
The traits are the core public API and carry no PHPUnit base-class requirement for their non-assertion helpers, so Pest (uses(...)) and other runners can consume them too. Pest and Codeception adapters are planned for a future release.
Contributing
Talon is developed entirely in Docker - see CONTRIBUTING.md for the full local-development guide. The short version:
cp resources/.env.example .env sed -i "s/^UID=.*/UID=$(id -u)/;s/^GID=.*/GID=$(id -g)/" .env docker compose run --rm app composer install # one-time: writes vendor to your checkout docker compose run --rm app composer test # or work inside the container: docker compose up -d && docker compose exec app bash
License
BSD-3-Clause. See LICENSE.