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:

  1. Behavior — what the user sees, with examples and edge cases.
  2. Driven — Given / When / Then scenarios that lead the work.
  3. Development — scenario → test → code → green (this page).

👉 Overview: Behavior-Driven Development for WordPress


The order of work

TEXT
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:

TEXT
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:

TEXT
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:

TEXT
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():

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:

CodeBehavior
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 fithashtags 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

TEXT
$ vendor/bin/phpunit --filter BlueskyTest
PHPUnit 9.6.38

.....                                                               5 / 5 (100%)

OK (5 tests, 11 assertions)

And the whole plugin:

TEXT
$ 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

  1. Write the behavior in plain words, with a real example and the edge cases.
  2. Turn each case into a scenario: Given / When / Then, one behavior per scenario.
  3. Name the test after the scenario.
  4. Write the test first; watch it fail.
  5. Write (or let AI write) only the code the tests ask for.
  6. Run everything; keep it green.

AI writes the code. You define the behavior.


Next