BDD with PHPUnit in WordPress: Scenario → Test → Code → Green
The last word in BDD is Development. The behavior is described, the scenario is written; now it becomes a test, the test gets its code, and the run goes green. In WordPress you can do all of it with plain PHPUnit.
This is the third of three BDD principles on 4wp.dev:
- Behavior — what the user sees, with examples and edge cases.
- Driven — Given / When / Then scenarios that lead the work.
- Development — scenario → test → code → green (this page).
👉 Overview: Behavior-Driven Development for WordPress
The order of work
Behavior → Scenario → Test (red) → Code → Test (green)
The test is written before the code. If the code comes first, the test only repeats what the code already does, and the behavior is never checked.
Scenario → Test
The scenario from the Driven page:
Scenario: hashtags only while they fit
Given description "Demo #WordPress"
And tags "WordPress", "FAQ schema"
When 4WP Social builds the Bluesky text
Then the text is "Demo #WordPress\n\n#FAQschema"
The same scenario as a test in tests/unit/BlueskyTest.php of 4WP Social:
public function test_text_adds_hashtags_only_while_they_fit(): void {
$package = new Media_Package(
array(
'description' => 'Demo #WordPress',
'tags' => array( 'WordPress', 'FAQ schema' ),
)
);
$this->assertSame( "Demo #WordPress\n\n#FAQschema", Bluesky_Adapter::text( $package ) );
$full = new Media_Package( array( 'description' => str_repeat( 'a', 295 ), 'tags' => array( 'LongTag' ) ) );
$this->assertSame( str_repeat( 'a', 295 ), Bluesky_Adapter::text( $full ) );
}
- Given → the input object, with the real values from the example.
- When → one call:
Bluesky_Adapter::text(). - Then → one exact assertion with
assertSame, not “contains” or “not empty”.
The edge case about length has its own test:
public function test_long_text_is_cut_to_300(): void {
$text = Bluesky_Adapter::text( new Media_Package( array( 'description' => str_repeat( 'я', 400 ) ) ) );
$this->assertSame( 300, mb_strlen( $text ) );
$this->assertStringEndsWith( '…', $text );
}
Note the Cyrillic я: one letter is two bytes in UTF-8. The test proves the limit counts characters, not bytes.
Test → Code
Now the code. It does what the scenarios say, nothing more. The real Bluesky_Adapter::text():
public static function text( Media_Package $package ): string {
$text = trim( '' !== trim( $package->description() ) ? $package->description() : $package->title() );
if ( self::length( $text ) > self::TEXT_MAX ) {
return rtrim( self::cut( $text, self::TEXT_MAX - 1 ) ) . '…';
}
$hashtags = array();
foreach ( $package->tags() as $tag ) {
$tag = preg_replace( '/[^\p{L}\p{N}_]/u', '', $tag );
if ( '' !== $tag && false === stripos( $text, '#' . $tag ) ) {
$hashtags[] = '#' . $tag;
}
}
$glue = "\n\n";
foreach ( $hashtags as $hashtag ) {
$next = $text . $glue . $hashtag;
if ( self::length( $next ) > self::TEXT_MAX ) {
break;
}
$text = $next;
$glue = ' ';
}
return $text;
}
Every line maps back to a behavior:
| Code | Behavior |
|---|---|
TEXT_MAX = 300, cut to 299 + … | long text is cut to 300 |
preg_replace( '/[^\p{L}\p{N}_]/u', '', $tag ) | FAQ schema → #FAQschema |
stripos( $text, '#' . $tag ) | #WordPress is not added twice |
break when the next hashtag does not fit | hashtags only while they fit |
"\n\n" first, then ' ' | hashtags go after an empty line |
self::length() uses grapheme_strlen() when it is available, so an emoji or a letter with an accent counts as one character, the way the user sees it.
If a line of code has no behavior behind it, ask why it is there. If a behavior has no line of code, the test will be red.
Green
$ vendor/bin/phpunit --filter BlueskyTest
PHPUnit 9.6.38
..... 5 / 5 (100%)
OK (5 tests, 11 assertions)
And the whole plugin:
$ composer test
PHPUnit 9.6.38 by Sebastian Bergmann and contributors.
................................................................. 65 / 66 ( 98%)
. 66 / 66 (100%)
OK (66 tests, 217 assertions)
Run it after every change, especially after an AI agent’s change. Green means the behavior is still the behavior you defined.
No WordPress test suite, no Behat
4WP Social runs these tests without WordPress. tests/bootstrap.php loads small stubs of the core functions the code needs (tests/wp-stubs.php) and the Composer autoloader. The whole suite runs in well under a second, so there is no reason not to run it.
This works because the behavior lives in pure methods: Bluesky_Adapter::text() takes a Media_Package and returns a string. No database, no HTTP, no hooks. Keep the decisions in pure code and leave WordPress at the edges, and BDD stays cheap.
There are also no .feature files and no Behat in this plugin. BDD is in the names, the examples and the order of work. Behat, Codeception or Cucumber become useful when you test through the browser or when non-developers edit scenarios. The thinking is the same.
A short checklist
- Write the behavior in plain words, with a real example and the edge cases.
- Turn each case into a scenario: Given / When / Then, one behavior per scenario.
- Name the test after the scenario.
- Write the test first; watch it fail.
- Write (or let AI write) only the code the tests ask for.
- Run everything; keep it green.
AI writes the code. You define the behavior.