Quick Testing Tips: Write Unit Tests Like Scenarios

Posted on by Matthias Noback

I'm a big fan of the BDD Books by Gáspár Nagy and Seb Rose, and I've read a lot about writing and improving scenarios, like Specification by Example by Gojko Adzic and Writing Great Specifications by Kamil Nicieja. I can recommend reading anything from Liz Keogh as well. Trying to apply their suggestions in my development work, I realized: specifications benefit from good writing. Writing benefits from good thinking. And so does design. Better writing, thinking, designing: this will make us do a better job at programming. Any effort put into these activities has a positive impact on the other areas, even on the code itself.

Unit tests vs automated scenarios

For instance, when you write a test in your favorite test runner (like PHPUnit), you'll write code. You'll focus on technology, and on implementation details (methods, classes, argument types, etc.):

$config = Mockery::mock(Config::class);
$config->shouldReceive('get')
    ->with('reroute_sms_to_email')
    ->andReturn('developers@org.nl');

$fallbackMailer = Mockery::mock(Mailer::class);
$fallbackMailer->shouldReceive('send')
    ->andReturnUsing(function (Mail $mail) {
        self::assertEquals('The message', $mail->plainTextBody());
        self::assertEquals('SMS for 0612345678', $mail->subject());
    });

$smsSender = new SmsSender($config, $fallbackMailer);
$smsSender->send('0612345678', 'The message');

It takes a lot of reading and interpreting before you even understand what's going on here. When you write a scenario first, you can shift your focus to a higher abstraction level. It'll be easier to introduce words from the business domain as well:

Given the system has been configured to reroute all SMS messages to the email address developers@org.nl
When the system sends an SMS
Then the SMS message will be sent as an email to developers@org.nl instead

When automating the scenario steps it will be natural to copy the words from the scenario into the code, establishing the holy grail of Domain-Driven Design - a Ubiquitous Language; without too much effort. And it's definitely easier to understand, because you're describing in simple words what you're doing or are planning to do.

Most of the projects I've seen don't use scenarios like this. They either write technology-focused scenarios, like this (or the equivalent using Browserkit, WebTestCase, etc.):

Given I am on "/welcome"
When I click "Submit" 
Then I should see "Hi"

Or they don't specify anything, but just test everything using PHPUnit.

Writing scenario-style unit tests

Although it may seem like having any kind of test is already better than having no tests at all, if you're making an effort to test your code, I think your test deserves to be of a high quality. When aiming high, it'll be smart to take advantage of the vast knowledge base from the scenario-writing community. As an example, I've been trying to import a number of style rules for scenarios into PHPUnit tests. The result is that those tests now become more useful for the (future) reader. They describe what's going on, instead of just showing which methods will be called, what data will be passed, and what the result of that is. You can use simple tricks like:

  1. Givens should be in the past tense
  2. Whens should be in the present tense
  3. Thens should be in the future tense (often using "should" or "will")

But what if you don't want to use Behat or another tool that supports Gherkin (the formalized language for these scenarios)? The cool thing is, you can use "scenario language" in any test, also in unit tests. The trick is to just use comments. This is the unit test above rewritten with this approach:

// Given the system has been configured to reroute all SMS messages to the email address developers@org.nl
$config = Mockery::mock(Config::class);
$config->shouldReceive('get')
    ->with('reroute_sms_to_email')
    ->andReturn('developers@org.nl');

// When the system sends an SMS
$fallbackMailer = Mockery::spy(Mailer::class);
$smsSender = new SmsSender($config, $fallbackMailer);
$smsSender->send('0612345678', 'The message');

// Then the SMS message will be sent as an email to developers@org.nl instead
$fallbackMailer->shouldHaveReceived('send')
    ->with(function (Mail $mail) {
        self::assertEquals('The message', $mail->plainTextBody());
        self::assertEquals('SMS for 0612345678', $mail->subject());

        return true;
    });

Note that to match the Given/When/Then order we use a spy instead of a mock to verify that the right call was made to the fallback mailer. The resulting code is a lot easier to read than the original because you could read only the comments and skip the code, unless you want to zoom in on the details. This mimics the way it works with scenarios that have the scenario steps and their automated step definitions in different files. The difference is that you don't have to switch between the .feature files that contain the scenario steps, and the Context classes that contain the implementations for each step. It also saves you from installing another testing tool in your project and having to teach everyone how to use it.

The Friends convention for test method names

Another thing we can learn from the scenario-writing community, a practice that can help us write good test method names (we're always struggling with that, right?). For test method names we can adopt the "Friends" naming convention, completing the sentence: "The one where ...". If you, like me, don't want to constantly be reminded of Friends, or want more direction, you can use the naming convention: "Here we specifically want to talk about what happens when ...". For example:

// Here we specifically want to talk about what happens when ...
public function the_system_has_been_configured_to_reroute_sms_messages(): void
{
}

// Here we specifically want to talk about what happens when ...
public function the_user_does_not_have_a_phone_number(): void
{
}

// etc.

Testing at higher abstraction levels

I think this approach is very useful. You can stick to your existing unit-testing practices and at the same time improve your scenario writing skills. What's still lacking is a description of "why". I'd want to describe the SMS sending feature as part of the bigger scenario, e.g. why do we send SMS messages in the first place? Where in the "user journey" does this happen?

I still prefer Behat for specifying the system on a level that's closer to the end user. But PHPUnit itself doesn't pose any limits on the abstraction level of your test. So it's certainly a viable option to write tests at all kinds of abstraction levels using just PHPUnit. When you treat your tests as scenarios, and keep focusing on your writing skills for them, you'll be writing truly valuable tests, that document behavior, the reason for that behavior, and allow the reader of your test to zoom in on the implementation details whenever they feel like it.

This post has been inspired by some development coaching work I'm doing for PinkWeb at the time of writing. Check out their vacancies if you'd like to join the team as well!

PHP testing BDD
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).
Łukasz Bujakowski

Thank you for this post. I like that you recently write more about testing. I would take a bit different approach to this example, which I'd like to share. Here's my version:

final class SmsReroutingTest extends TestCase
{
    private const CONFIG_OPTION = 'reroute_sms_to_email';
    private const GIVEN_PHONE = '0612345678';
    private const THE_MESSAGE = 'The message';
    private const TARGET_EMAIL = 'developers@org.nl';

    private Mock $config;
    private Mock $fallbackMailer;

    public function setUp(): void
    {
        $this->config = Mockery::mock(Config::class);
        $this->fallbackMailer = Mockery::spy(Mailer::class);
    }

    /**
     * @test
     */
    public function reroutesSmsToEmailWhenFeatureEnabledInConfig(): void
    {
        $this->configureReroutingIsEnabled();
        $this->sendSms();
        $this->assertAnEmailWasSentInstead();
    }

    /**
     * @test
     */
    public function noReroutingWhenFeatureIsDisabled()
    {
        $this->configureReroutingIsDisabled();
        $this->sendSms();
        $this->assertNoEmailsWereSent();
    }

    private function configureReroutingIsEnabled(): void
    {
        $this->config
            ->shouldReceive('get')
            ->with(self::CONFIG_OPTION)
            ->andReturn(self::TARGET_EMAIL);
    }

    private function configureReroutingIsDisabled(): void
    {
        $this->config
            ->shouldReceive('get')
            ->with(self::CONFIG_OPTION)
            ->andReturn(false);
    }

    private function sendSms(): void
    {
        $smsSender = new SmsSender($this->config, $this->fallbackMailer);
        $smsSender->send(self::GIVEN_PHONE, self::THE_MESSAGE);
    }

    private function assertAnEmailWasSentInstead(): void
    {
        $this->fallbackMailer->shouldHaveReceived('send')
            ->with(function (Mail $mail) {
                self::assertEquals(self::THE_MESSAGE, $mail->plainTextBody());
                self::assertEquals(sprintf('SMS for %s', self::GIVEN_PHONE), $mail->subject());
                return true;
            });
    }

    private function assertNoEmailsWereSent(): void
    {
        $this->fallbackMailer->shouldNotHaveReceived('send');
    }
}

So - given/when/then (3xA) is kept, but done via extracted private methods, instead of comments. I really like this style, because:

  • test method's body contains only descriptive high level instructions, almost as readable as a gherkin scenario
  • private methods are very well suited for encapsulating lower level details, and IDE is very good at navigatin between them, collapsing/expanding etc.
  • you build up a dictionary of these methods, just like you would build a dictionary of Behat steps - and the more there are, the easier is to compose more cases by reusin
Matthias Noback

Thanks for commenting! Yes, depending on how many test cases there are, I do the same. I also found this to result in something similar as those reusable step definitions when using Behat.

I do prefer passing the variables instead of relying on constants, but often the private methods have some default values in case providing them is irrelevant (just like with Behat again ;)).

P.S. I'll try to fix the formatting of your code sample. MailComments is doing something silly with it.

Matthias Noback

I fixed the Markdown processing code in MailComments; your code sample now looks good again.