codeception/specify
BDD-style specifications for PHP tests, built as a lightweight extension for Codeception. Adds a clean “specify” syntax to structure examples and expectations, making tests easier to read, write, and maintain across unit and functional suites.
Installation
composer require --dev codeception/specify
Add to composer.json under require-dev if not using autoloading.
Basic Usage Import the trait in your test class:
use Codeception\Specify;
class MyTest extends \Codeception\Test\Unit
{
use Specify;
}
First Use Case
Replace traditional assert blocks with specify():
public function testUserCreation()
{
$user = User::create(['name' => 'John']);
specify('the user has a valid name', function () use ($user) {
$user->name->shouldBe('John');
});
specify('the user is saved', function () use ($user) {
User::where('id', $user->id)->shouldHaveCount(1);
});
}
tests/ in the package repo for real-world usage.Descriptive Test Blocks
Replace verbose assert chains with readable specify() blocks:
public function testOrderProcessing()
{
$order = Order::create(['status' => 'pending']);
specify('order starts as pending', function () use ($order) {
$order->status->shouldBe('pending');
});
$order->markAsPaid();
specify('order updates to paid', function () use ($order) {
$order->refresh()->status->shouldBe('paid');
});
}
Data-Driven Specifications
Use specify() with loops or factories for dynamic tests:
public function testUserRoles()
{
$roles = ['admin', 'editor', 'guest'];
foreach ($roles as $role) {
specify("a $role has correct permissions", function () use ($role) {
$user = User::factory()->create(['role' => $role]);
$user->can($role)->shouldBeTrue();
});
}
}
Integration with Codeception
Works seamlessly with Codeception’s actors (e.g., ActorTestCase):
class UserCest
{
use Specify;
public function _before(Actor $I)
{
$I->wantTo('test user workflow');
}
public function testLogin(Actor $I)
{
$I->amOnPage('/login');
$I->fillField('email', 'user@example.com');
$I->fillField('password', 'password');
$I->click('Login');
specify('user is redirected to dashboard', function () use ($I) {
$I->seeCurrentUrlEquals('/dashboard');
});
}
}
Combining with PHPUnit
Mix specify() with PHPUnit’s @dataProvider:
public function testEdgeCases()
{
$this->specifyDataProvider('edgeCaseProvider', function ($input, $expected) {
$result = process($input);
$result->shouldBe($expected);
});
}
public function edgeCaseProvider()
{
return [
['', null],
[null, null],
['invalid', false],
];
}
Laravel\LaravelTestCase for HTTP tests:
use Laravel\LaravelTestCase;
use Codeception\Specify;
class FeatureTest extends LaravelTestCase
{
use Specify;
}
Specify to add domain-specific specs:
trait CustomSpecify
{
public function specifyPaymentIsValid($payment)
{
specify('payment amount is positive', function () use ($payment) {
$payment->amount->shouldBeGreaterThan(0);
});
specify('payment has valid currency', function () use ($payment) {
$payment->currency->shouldBeIn(['USD', 'EUR']);
});
}
}
specify() to isolate assertions in large test methods, improving readability and maintainability.Scope Leaks
Avoid referencing outer variables in specify() without use:
// ❌ Fails (undefined $user in closure)
specify('user exists', function () {
$user->id->shouldBe(1); // Error!
});
// ✅ Correct
specify('user exists', function () use ($user) {
$user->id->shouldBe(1);
});
Overusing specify()
specify() block can lead to noise.Codeception-Specific Quirks
ActorTestCase, ensure specify() closures have access to the actor ($I) via use ($I).Db, Rest) may require chaining with specify() carefully to avoid state pollution.Performance
specify() adds minimal overhead, but avoid nesting it deeply (e.g., 100+ blocks in one test).specify() blocks over many trivial ones.Failed Specifications
specify() closures show up as PHPUnit failures with the block’s description in the message.1) Tests\UserTest::testCreation
specify 'the user is saved' failed: User with id [1] not found.
Variable Scope Issues
var_dump or dd() inside the closure to inspect variables:
specify('debug user', function () use ($user) {
var_dump($user->toArray());
});
Integration with Laravel Debugbar
barryvdh/laravel-debugbar, specify() blocks appear in the "Tests" tab with their descriptions.Naming Conventions
specify() descriptions:
shouldBe*)Grouping with @group
@group for CI filtering:
/**
* @group regression
*/
public function testRegressionCases()
{
specify('...');
}
Dynamic Specifications
specify() blocks dynamically for data-heavy tests:
$testCases = [
['input' => 1, 'expected' => 1],
['input' => 2, 'expected' => 4],
];
foreach ($testCases as $case) {
specify("squares {$case['input']} to {$case['expected']}", function () use ($case) {
square($case['input'])->shouldBe($case['expected']);
});
}
Extending Functionality
specify* methods to the trait:
trait ExtendedSpecify
{
public function specifyModelExists($model)
{
specify('model exists in database', function () use ($model) {
$model::where('id', $model->id)->shouldHaveCount(1);
});
}
}
use Codeception\Specify;
use ExtendedSpecify;
Legacy Code
assert* to specify() without rewriting all tests at once.public function testHybrid()
{
assertTrue(true); // Legacy
specify('new spec style', function () {
true->shouldBeTrue();
});
}
How can I help you explore Laravel packages today?