Talon¶
Overview¶
Talon is the Phalcon test harness. It is the part of Phalcon that catches the bugs. Talon bootstraps a Phalcon test environment and fronts PHPUnit, so any Phalcon project can write unit, database, functional, and browser tests with minimal boilerplate.
Talon provides three things:
- Traits - the framework-neutral core. Each trait carries a group of helpers (reflection, filesystem, database, functional, browser, services).
- PHPUnit base classes - ready-to-extend test cases that compose the traits for each kind of test.
- A command-line runner -
vendor/bin/talon, which runs PHPUnit once per mapped suite.
Talon runs on both Phalcon distributions and uses whichever is present:
- Phalcon v5 - the
ext-phalconC extension. - Phalcon v6 - the
phalcon/phalconPHP package.
The same test suite runs against either one. Talon is used across the Phalcon projects, including cphalcon, the sample applications, and the PHP framework itself. To see it driving the cphalcon suites, see the Testing environment guide.
Requirements¶
- PHP 8.1 or later.
- Phalcon, either the v5 C extension or the v6
phalcon/phalconpackage. See the installation page for the extension. symfony/browser-kitandsymfony/dom-crawlerare pulled in as dependencies and power the browser tests.
Installation¶
Install Talon as a development dependency:
Bootstrapping Your Tests¶
Point PHPUnit at a bootstrap file that boots Talon. The one-liner form reads configuration from the environment:
<?php
// tests/bootstrap.php
require __DIR__ . '/../vendor/autoload.php';
use Phalcon\Talon\Settings;
use Phalcon\Talon\Talon;
Talon::boot(Settings::fromEnv());
When you need setup hooks (for example, raising the memory limit or creating output directories), use the bootstrap Runner. It runs your callbacks before or after each bootstrap Stage - Stage::Settings, Stage::Environment, and Stage::Directories:
<?php
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();
The Command-Line Runner¶
vendor/bin/talon runs PHPUnit once 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
vendor/bin/talon schema # generate the schema artifacts
Each suite runs as its own subprocess, so per-suite extensions and environment variables take effect. A single suite forwards its exit code verbatim. Multiple suites print a per-suite summary and exit with the highest code.
Suite discovery¶
With zero configuration, suites are discovered from phpunit*.xml files in the project root and in resources/. phpunit.xml.dist becomes the unit suite (the default), and phpunit.mysql.xml becomes the mysql suite.
Projects that need PHP ini flags or environment variables declare a talon.php at the project root:
<?php
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:
| Key | Required | Description |
|---|---|---|
config |
Yes | Path to the PHPUnit configuration for the suite |
php |
No | Extra PHP ini flags, merged over the global php |
env |
No | Extra environment variables, merged over global env |
args |
No | Default PHPUnit arguments for the suite |
Forwarding arguments to PHPUnit¶
Options are forwarded to PHPUnit starting at the first option Talon does not recognize itself. Everything after -- is always forwarded verbatim:
Test Case Base Classes¶
Each base class composes the relevant traits for one kind of test. They live in the Phalcon\Talon\PHPUnit namespace:
| Base class | For | Highlights |
|---|---|---|
AbstractUnitTestCase |
Unit tests | Reflection and filesystem helpers |
AbstractDatabaseTestCase |
Database tests | assertInDatabase(); driver from the driver env |
AbstractFunctionalTestCase |
Functional tests | Dispatch a route through your application and assert the result |
AbstractBrowserTestCase |
Multi-request flows | In-process browser; cookies and session preserved across requests |
AbstractServicesTestCase |
Redis / Memcached | Cache helpers; auto-skip when the backend is unreachable |
Unit tests¶
<?php
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 provides callProtectedMethod(), getProtectedProperty(), setProtectedProperty(), invokeMethod(), getNewFileName(), safeDeleteFile(), safeDeleteDirectory(), assertFileContentsContains(), checkExtensionIsLoaded(), and checkPhalconAvailable().
Database tests¶
<?php
use Phalcon\Talon\PHPUnit\AbstractDatabaseTestCase;
final class UserTest extends AbstractDatabaseTestCase
{
public function testSeeded(): void
{
$this->assertInDatabase('users', ['email' => '[email protected]']);
}
}
The driver comes from the driver environment variable (sqlite, mysql, mariadb, or pgsql). Credentials come from Settings, read from environment variables by default.
Each driver reads its own block, so MySQL and MariaDB are configured independently and can point at different servers:
| Driver | Variable | Default | Notes |
|---|---|---|---|
mariadb |
DATA_MARIADB_HOST |
127.0.0.1 |
|
mariadb |
DATA_MARIADB_PORT |
3306 |
|
mariadb |
DATA_MARIADB_NAME |
talon |
Database name |
mariadb |
DATA_MARIADB_USER |
root |
|
mariadb |
DATA_MARIADB_PASS |
empty | |
mariadb |
DATA_MARIADB_CHARSET |
utf8mb4 |
|
mysql |
DATA_MYSQL_HOST |
127.0.0.1 |
|
mysql |
DATA_MYSQL_PORT |
3306 |
|
mysql |
DATA_MYSQL_NAME |
talon |
Database name |
mysql |
DATA_MYSQL_USER |
root |
|
mysql |
DATA_MYSQL_PASS |
empty | |
mysql |
DATA_MYSQL_CHARSET |
utf8mb4 |
|
pgsql |
DATA_POSTGRES_HOST |
127.0.0.1 |
|
pgsql |
DATA_POSTGRES_PORT |
5432 |
|
pgsql |
DATA_POSTGRES_NAME |
talon |
Database name |
pgsql |
DATA_POSTGRES_USER |
postgres |
|
pgsql |
DATA_POSTGRES_PASS |
empty | |
pgsql |
DATA_POSTGRES_SCHEMA |
empty | Sets the connection search path |
sqlite |
DATA_SQLITE_NAME |
:memory: |
A file path, or :memory: |
Two further variables apply to every driver:
| Variable | Default | Notes |
|---|---|---|
dump_file |
empty | Schema artifact loaded on the first connection. A dialect directory, or a flat .sql file |
initial_queries |
empty | SQL run immediately after connecting, before any other statement |
MariaDB connects through pdo_mysql, so Settings::getDatabaseDsn('mariadb') returns a DSN carrying the mysql: prefix. No additional PHP extension is required.
DATA_POSTGRES_SCHEMA is applied to the connection as SET search_path immediately after connecting, before any initial_queries run.
Functional tests¶
Talon never owns your container. Hand it your configured application through an appFactory():
<?php
use Phalcon\Talon\PHPUnit\AbstractFunctionalTestCase;
final class HomeTest extends AbstractFunctionalTestCase
{
protected function appFactory(): callable
{
// returns a configured Application or Micro
return fn () => require __DIR__ . '/../app/bootstrap.php';
}
public function testHome(): void
{
$this->dispatch('/');
$this->assertController('index');
$this->assertResponseContentContains('Welcome');
}
}
Browser tests¶
For multi-request flows - login, forms, redirects - AbstractBrowserTestCase drives your application in-process, with no web server, through a symfony/browser-kit bridge. Cookies and the session are kept across requests, and redirects are followed automatically:
<?php
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', '[email protected]');
$this->fillField('password', 'password1');
$this->pressButton('Log In');
$this->assertPageContainsText('Search users');
}
}
The browser verbs are visitPage, fillField, selectOption, clickLink, pressButton, and getCookie/setCookie. The assertions are assertPageContainsText and assertPageMissingText. Browser tests require symfony/browser-kit and symfony/dom-crawler, which Talon installs.
Service tests¶
<?php
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 (Redis or Memcached) is unreachable, so the suite stays green on a host without those services.
The backends read these environment variables:
| Service | Variable | Default | Notes |
|---|---|---|---|
redis |
DATA_REDIS_HOST |
127.0.0.1 |
|
redis |
DATA_REDIS_PORT |
6379 |
|
redis |
DATA_REDIS_NAME |
0 |
The database index, not a name |
redisCluster |
DATA_REDIS_CLUSTER_HOSTS |
empty | Comma-separated host:port list |
redisCluster |
DATA_REDIS_CLUSTER_AUTH |
empty | |
memcached |
DATA_MEMCACHED_HOST |
127.0.0.1 |
|
memcached |
DATA_MEMCACHED_PORT |
11211 |
|
memcached |
DATA_MEMCACHED_WEIGHT |
0 |
Server weight passed to addServer() |
beanstalk |
DATA_BEANSTALKD_HOST |
empty | |
beanstalk |
DATA_BEANSTALKD_PORT |
empty |
ServicesTrait provides helpers for Redis and Memcached only. The redisCluster and beanstalk options are read and exposed through Settings::getServiceOptions() for a project to consume, but Talon ships no assertions for them.
Mocking a resultset¶
To assert against model logic without a database, mock a resultset with ResultSetTrait:
<?php
use Phalcon\Talon\Traits\ResultSetTrait;
use PHPUnit\Framework\TestCase;
final class ReportTest extends TestCase
{
use ResultSetTrait;
public function testReport(): void
{
$resultset = $this->mockResultSet([$modelA, $modelB]);
$this->assertCount(2, $resultset);
}
}
Schema Fixtures¶
A schema fixture is a class that declares the DDL for one table, per dialect. Talon generates the SQL artifacts from those classes, and your tests use the same classes to create, truncate, and populate the table.
- MySQL and MariaDB share one set of statements. MariaDB uses the MySQL dialect, so there is no separate method for it.
- PostgreSQL has its own set.
- SQLite has its own set.
Extend Phalcon\Talon\Database\Schema\AbstractSchema and declare the table name and the statements for each dialect. The three per-dialect methods are abstract, so a new dialect cannot be forgotten:
<?php
use Phalcon\Talon\Database\Schema\AbstractSchema;
final class InvoicesSchema extends AbstractSchema
{
protected string $table = 'co_invoices';
public function insert(int $id, string $title, float $total): int
{
return $this->execute(
'INSERT INTO co_invoices (inv_id, inv_title, inv_total) '
. 'VALUES (:id, :title, :total)',
[':id' => $id, ':title' => $title, ':total' => $total]
);
}
protected function getStatementsMysql(): array
{
return [
'CREATE TABLE `co_invoices` ('
. '`inv_id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, '
. '`inv_title` VARCHAR(100) NULL, '
. '`inv_total` DECIMAL(10,2) NOT NULL, '
. 'PRIMARY KEY (`inv_id`)'
. ') ENGINE=InnoDB;',
];
}
protected function getStatementsPgsql(): array
{
return [
'CREATE TABLE co_invoices ('
. 'inv_id SERIAL PRIMARY KEY, '
. 'inv_title VARCHAR(100) NULL, '
. 'inv_total NUMERIC(10,2) NOT NULL'
. ');',
];
}
protected function getStatementsSqlite(): array
{
return [
'CREATE TABLE co_invoices ('
. 'inv_id INTEGER PRIMARY KEY AUTOINCREMENT, '
. 'inv_title TEXT NULL, '
. 'inv_total REAL NOT NULL'
. ');',
];
}
}
Four rules govern the statement lists:
| Rule | Detail |
|---|---|
| Creation statements only | Do not write a DROP TABLE for the declared table. The generator prepends one from the table name |
| An empty list means absent | The table does not exist in that dialect. It is skipped entirely and gets no manifest entry |
| One fixture, one table | The table name is the artifact file name and the manifest key. Two fixtures declaring the same table throw SchemaTableDuplicate |
insert() is yours |
The contract covers create(), drop(), and clear(), never the data shape, so each fixture types its own insert signature |
clear() empties the table, but do not assert on its return value. MySQL and PostgreSQL clear with TRUNCATE, which reports no affected rows, so both return 0. Only SQLite's DELETE returns a count.
A fixture whose statements create a second table receives no generated DROP for that table. Write the drop yourself, or split the fixture in two.
Override getDependencies() to declare the tables that must exist first:
<?php
use Phalcon\Talon\Database\Schema\AbstractSchema;
final class InvoiceLinesSchema extends AbstractSchema
{
protected string $table = 'co_invoice_lines';
/**
* @return list<string>
*/
public function getDependencies(): array
{
return ['co_invoices'];
}
// getStatementsMysql(), getStatementsPgsql(), getStatementsSqlite() omitted
}
Generating the artifacts¶
The command is driven by five settings, read from the environment or from Settings::fromArray():
| Setting | Description |
|---|---|
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 |
Class emitted before every table, for session setup or schema creation |
schema_post |
Class emitted after every table, closing whatever schema_pre opened |
schema_pre and schema_post are ordinary AbstractSchema subclasses with an empty $table. Use them for statements that belong to the load as a whole, such as SET FOREIGN_KEY_CHECKS=0 on MySQL or CREATE SCHEMA IF NOT EXISTS on PostgreSQL.
Each dialect is written to its own directory:
schema/mysql/_preSchema.sql session setup
schema/mysql/co_invoices.sql the table's DROP, then its creation statements
schema/mysql/manifest.json load order, dependencies, per-dialect presence
schema/mysql/_postSchema.sql closes what _preSchema opened
File names follow your table names, so a schema-qualified name keeps its dot: private.co_orders.sql. The manifest is generated, never hand-edited. When it is wrong, correct a fixture class and regenerate.
Loading a schema¶
Point dump_file at the dialect directory. AbstractDatabaseTestCase loads it on the first connection, in this order: _preSchema.sql, the manifest's tables, _postSchema.sql.
Connection::loadSchema() also accepts a single flat .sql file, so a project can move to the directory format on its own schedule.
Rebuilding one table¶
Loading the whole schema once and truncating between tests remains the default. addTable() is the escape hatch for a test that needs one table rebuilt:
<?php
use Phalcon\Talon\PHPUnit\AbstractDatabaseTestCase;
final class InvoiceRebuildTest extends AbstractDatabaseTestCase
{
public function testRebuild(): void
{
$this->addTable('co_invoices');
$this->assertTrue($this->getConnection()->tableExists('co_invoices'));
}
}
addTable() is standalone only, and that is enforced. A declared dependency that does not yet exist throws SchemaDependencyMissing. Dependencies are never resolved for you, so call the method once per table, dependency first:
$this->addTable('co_invoices'); // the dependency
$this->addTable('co_invoice_lines'); // the dependent
The restriction is deliberate. schema_pre is in effect only during the bulk load. By the time a test runs, schema_post has restored FOREIGN_KEY_CHECKS=1, so a table with foreign keys that loads correctly in bulk can fail on its own, on MySQL, for reasons the calling test does not suggest.
Traits¶
The traits are the core public API. Compose them directly when you do not want the base classes. They live in the Phalcon\Talon\Traits namespace:
| Trait | Provides |
|---|---|
ReflectionTrait |
callProtectedMethod, getProtectedProperty, setProtectedProperty, invokeMethod |
FileSystemTrait |
getNewFileName, safeDeleteFile, safeDeleteDirectory, assertFileContentsContains, assertFileContentsEqual |
DatabaseTrait |
assertInDatabase, assertNotInDatabase, getConnection, getDialect, getDriver, addTable |
FunctionalTrait |
dispatch, getContent |
FunctionalAssertionsTrait |
assertController, assertAction, assertResponseCode, assertRedirectTo, assertResponseContentContains, assertHeader, assertDispatchIsForwarded |
BrowserTrait |
visitPage, fillField, selectOption, clickLink, pressButton, getCookie, setCookie |
BrowserAssertionsTrait |
assertPageContainsText, assertPageMissingText |
ServicesTrait |
setRedisKey, getRedisKey, hasRedisKey, sendRedisCommand, setMemcachedKey, getMemcachedKey, clearMemcached |
ResultSetTrait |
mockResultSet |
Configuration¶
Configuration is held in a Settings object. The one-liner bootstrap uses Settings::fromEnv(), which reads environment variables. To configure Talon in code, pass Settings::fromArray() to Talon::boot(), or override getSettings() in a project base class:
<?php
use Phalcon\Talon\Settings;
use Phalcon\Talon\Talon;
Talon::boot(
Settings::fromArray(
[
'root' => dirname(__DIR__),
'db' => [
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'dbname' => 'app',
'username' => 'root',
'password' => '',
],
'sqlite' => [
'dbname' => ':memory:',
],
],
]
)
);
Beyond PHPUnit¶
The traits carry no PHPUnit base-class requirement for their non-assertion helpers, so Pest (through uses(...)) and other runners can consume them too. Pest and Codeception adapters are planned for a future release.