Skip to content

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-phalcon C extension.
  • Phalcon v6 - the phalcon/phalcon PHP 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/phalcon package. See the installation page for the extension.
  • symfony/browser-kit and symfony/dom-crawler are pulled in as dependencies and power the browser tests.

Installation

Install Talon as a development dependency:

composer require --dev phalcon/talon

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:

vendor/bin/talon run unit -- --filter FooTest --testdox

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

vendor/bin/talon schema         # every dialect
vendor/bin/talon schema mysql   # one dialect

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.

<env name="dump_file" value="resources/schema/mysql"/>

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.

References