Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Behat Hooks Background

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

Behat experts coming through! Seriously, we've covered it: Behat reads the steps, finds a function using this nice little pattern, calls that function, and then goes out to lunch. That's all that Behat does, plus a few nice extras.

Let's dive into some of those extras! This scenario creates the "john" and "hammond" files inside this directory but doesn't even clean up afterwards. What a terrible roommate.

Let's first put these into a temporary directory. We'll use the __construct function because that's called before each scenario. Type mkdir('test'); and chdir('test');:

... lines 1 - 11
class FeatureContext extends MinkContext implements Context, SnippetAcceptingContext
{
... lines 14 - 22
public function __construct()
{
mkdir('test');
chdir('test');
}
... lines 28 - 53
}

Over in the terminal, delete the "john" and "hammond" files so we can have a fresh start at this. Rerun Behat for our ls scenario:

vendor/bin/behat features/ls.feature

Everything still passes and hey look there's a little test/ directory and the "john" and "hammond" are inside of that. Cool.

Ready for the problem? Rerun that test one more time. Now, errors show up that say:

mkdir(): file exists.

This error didn't break our test but it does highlight the problem that we don't have any cleanup. After our tests run these files stick around.

We need to run some code after every scenario. Behat has a system called "hooks" where you can make a function inside of your context and tell Behat to call it before or after your scenario, entire test suite or individual steps.

Create a new public function inside called public function moveOutOfTestDir():

... lines 1 - 11
class FeatureContext extends MinkContext implements Context, SnippetAcceptingContext
{
... lines 14 - 66
public function moveOutOfTestDir()
{
... lines 69 - 72
}
}

This will be our cleanup function. Use chdir('..'); to go up one directory. Then, if the test/ directory exists - which it should - then we'll run a command to remove that:

... lines 1 - 68
chdir('..');
if (is_dir('test')) {
system('rm -r '.realpath('test'));
}
... lines 73 - 75

To get Behat to actually call this after every scenario, add an @AfterScenario annotation above the method:

... lines 1 - 63
/**
* @AfterScenario
*/
public function moveOutOfTestDir()
... lines 68 - 75

That's it!

Let's give this a try:

vendor/bin/behat features/ls.feature

The first time we run this we still get the warning since our clean up function hasn't been called yet. But when we run it again, the warnings are gone! And if we run ls, we see that there is no test directory.

We can do this same thing with the mkdir(); and chdir(); stuff. Create a new public function moveIntoTestDir():

... lines 1 - 11
class FeatureContext extends MinkContext implements Context, SnippetAcceptingContext
{
... lines 14 - 55
public function moveIntoTestDir()
{
... lines 58 - 61
}
... lines 63 - 73
}

And we can make it even a bit more resistant by checking to see if the test directory is already there and only create it if we need to. Above this, add @BeforeScenario:

... lines 1 - 52
/**
* @BeforeScenario
*/
public function moveIntoTestDir()
{
if (!is_dir('test')) {
mkdir('test');
}
chdir('test');
}
... lines 63 - 75

This is basically the same as putting the code in __construct(), but with some subtle differences. @BeforeScenario is the proper way to do this.

When we run things now, everything looks really nice. I think this ls command is going to be a success!

PHPUnit Assert Functions

So bonus feature #1 is the hook system. And bonus feature #2, has nothing to do with Behat at all. It actually comes from PHPUnit. Our first step will be to install PHPUnit with composer require phpunit/phpunit --dev. That will add it under a new require-dev section in composer.json:

29 lines composer.json
{
... lines 2 - 21
"require-dev": {
... lines 23 - 25
"phpunit/phpunit": "^4.8"
}
}

Full disclosure, I should have put all the Behat and Mink stuff inside of the require-dev too: it is a better place for it since we only need them while we're developing.

I installed PHPUnit because it has really nice assert functions that we can get a hold of. To get access to them we just need to add a require statement in our FeatureContext.php file, require_once then count up a couple of directories and find vendor/phpunit/phpunit/src/Framework/Assert/Functions.php:

... lines 1 - 8
require_once __DIR__.'/../../vendor/phpunit/phpunit/src/Framework/Assert/Functions.php';
... lines 10 - 79

Tip

This is a redundant step if you use Symfony 5 or higher. Read more about how to configure Behat & PHPUnit properly with Symfony 5+ here

Requiring this file gives you access to all of PHPUnit's assert functions as flat functions. Down in the iShouldSeeInTheOutput() method, use assertContains(), give it the needle which is $string and the haystack which is $this->output. Finally, add our helpful message which I'll just cut and paste. Remove the rest of the original if statement:

... lines 1 - 13
class FeatureContext extends MinkContext implements Context, SnippetAcceptingContext
{
... lines 16 - 44
/**
* @Then I should see :string in the output
*/
public function iShouldSeeInTheOutput($string)
{
assertContains(
$string,
$this->output,
sprintf('Did not see "%s" in output "%s"', $string, $this->output)
);
}
... lines 56 - 77
}

Run the test again!

vendor/bin/behat features/ls.feature

Beautiful, it looks just like it did before.

Using Background

To show you the final important extra for Behat, create another scenario for Linus' ls feature. This time we'll say:

... lines 1 - 12
Scenario: List 1 file and 1 directory
... lines 14 - 19

I'll copy all the steps from our first scenario and just edit the second line to:

... lines 1 - 13
Given I have a file named "john"
And I have a dir named "ingen"
When I run "ls"
Then I should see "john" in the output
... lines 18 - 19

And update the final line to:

... lines 1 - 17
And I should see "ingen" in the output

Man what a great looking scenario, let's run it!

vendor/bin/behat features/ls.feature

As expected it now says there's one missing step definition. Copy the PHP code that prints out into FeatureContext. Remove the throw exception line, and update the arg1's to dir:

... lines 1 - 13
class FeatureContext extends MinkContext implements Context, SnippetAcceptingContext
{
... lines 16 - 78
/**
* @Given I have a dir named :dir
*/
public function iHaveADirNamed($dir)
{
... line 84
}
}

Inside the function use mkdir($dir) to actually make that directory:

... lines 1 - 83
mkdir($dir);
... lines 85 - 87

Simple!

Back to the terminal to rerun the tests. It works! And that was easy. Once you're done celebrating you may start to notice the duplication we have in the scenarios. There are two ways to clean this up. The most important way is with Background::

... lines 1 - 5
Background:
Given I have a file named "john"
... lines 8 - 20

If every single scenario in your feature starts with the same lines then you should move that up into a new Background section.

Now, I'll change the first line of And in each of these scenarios to Given:

... lines 1 - 8
Scenario: List 2 files in a directory
Given I have a file named "hammond"
... lines 11 - 13
Scenario: List 1 file and 1 directory
Given I have a dir named "ingen"
... lines 17 - 20

I don't have to do this, but it reads better to me. Now Behat will run that Background line before each individual scenario and you'll even see that:

vendor/bin/behat features/ls.feature

The Background is read up here, but it actually is running before the top scenario and the bottom one. We know this because if it didn't, these tests wouldn't be passing.

Second, when you have duplication that's not on the first line of all of your scenarios like the "Then I should see...." you may want to use scenario outlines. It's a little less commonly used but we'll dive into that a bit later.

Ok, not only do you know how Behat works but you even know all of its top extra features -- check you out!

Leave a comment!

13
Login or Register to join the conversation
Default user avatar
Default user avatar Fancy name thingy | posted 2 years ago | edited

You should really put some quotes around that realpath call, I got far too close to accidentally wiping something important because I have spaces in my path

rm: cannot remove 'C:\Users\MyFirstName': No such file or directory
rm: cannot remove 'MyLastName\Google': No such file or directory
rm: cannot remove 'Drive\Pro': No such file or directory
rm: cannot remove 'Testing\2.': No such file or directory
rm: cannot remove 'BDD,': No such file or directory
rm: cannot remove 'Behat,': No such file or directory
rm: cannot remove 'Mink': No such file or directory
rm: cannot remove 'etc\test_temp': No such file or directory```



Luckily none of those existed as directories


So not `system('rm -r '.realpath('test_temp'));` but `system('rm -r "'.realpath('test_temp').'"');`
Reply

Thanks for your input on that. I agree with you, it's safer to do that, and as a reminder, do not run scripts as sudo :)

Cheers!

Reply
Default user avatar
Default user avatar Florian | posted 5 years ago

Hey,
do you know why

//Works
if (is_dir('test')){
system('rm -r '.realpath('test'));
}

works but

//Don't work
if (is_dir('test')){
system('rm -r'.realpath('test'));
}

not? The difference is, there is a space between -r and '. Works -> 'rm -r ' | Don't work 'rm -r' This is strange.

Reply

Hi Florian!

Ah, it's because system() ultimately runs a command-line script. If you think about it, the two commands would be:


rm -r /path/to/test

rm -r/path/to/test

If there's no space, the -r flag "runs into" the path :).

Cheers!

Reply
Default user avatar

Not sure where is the best place to ask this - maybe here?
I am using @insulated tag for scenarios that run mink-selenium tests on SauceLabs, and SauceLabs creates a separate set of screenshots/video for each scenario. I have code that gets the URL of the SauceLabs job output and can write that to the log in the @AfterScenario - all good, I can look in the log for the inidividual link to the SauceLabs output for each scenario.
I can also send the pass-fail status of the scenario to SauceLabs (they have an API for that).
Is there some way, inside an AfterScenario method, to get the pass/fail status of the scenario?

Reply
Default user avatar

I just needed to look - scenario scopes have getTestResult which has isPassed()

/**
* After Scenario. Report the pass/fail status.
*
* @return void
* @AfterScenario
*/
public function reportResult(\Behat\Behat\Hook\Scope\AfterScenarioScope $afterScenarioScope) {
if ($afterScenarioScope->getTestResult()->isPassed()) {
$passOrFail = "pass";
$passed = "true";
} else {
$passOrFail = "fail";
$passed = "false";
}

$jobId = $this->getSessionId();
$sauceUsername = getenv('SAUCE_USERNAME');
$sauceAccessKey = getenv('SAUCE_ACCESS_KEY');

if ($sauceUsername && $sauceAccessKey) {
error_log("SAUCELABS RESULT: ($passOrFail) https://saucelabs.com/jobs/...");
exec('curl -X PUT -s -d "{\"passed\": ' . $passed . '}" -u ' . $sauceUsername . ':' . $sauceAccessKey . ' https://saucelabs.com/rest/... . $jobId);
}
}

Reply

Haha, awesome - thanks for sharing the solution Phil! :)

Reply
Default user avatar
Default user avatar Daniel Noyola | posted 5 years ago

is there a way to use the "use" sentence and avoid the "include_once"? it just feels ugly. what I should add to the featureContext class if I want to test an API using Guzzle?

Reply

Hey Daniel!

Inside FeatureContext, you *do* have access to any of your normal classes (e.g. anything in the vendor directory, etc). So, you should be able to use Guzzle without needing any require statements - and actually, we do this in the REST tutorial (http://knpuniversity.com/sc... or https://github.com/knpunive... to see usage - though this is an older version of Guzzle now).

We added the require_once in this case to get the PHPUnit *functions* - as function autoloading is a bit different. However, this may not be needed anymore even for functions - I believe PHPUnit takes care of autoloading these automatically now as well.

I hope that helps!

P.S. Sorry for the slow reply!

Reply
Default user avatar
Default user avatar Daniel Noyola | weaverryan | posted 5 years ago

Cool! this is getting more amazing!

P.S. it's okay as long you were working on this https://events.drupal.org/n... or this https://events.drupal.org/n...

Reply

Haha, let's say I was ;). Will you be there?

Reply
Default user avatar
Default user avatar Daniel Noyola | weaverryan | posted 5 years ago

Yes! First DrupalCon ever :)

Reply

Awesome! DrupalCon is one of my absolute favorite conferences - incredibly well-run and great people. See you there!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses a very old version of Symfony. The fundamentals of Behat are still valid, but integration with Symfony will be different.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.4.0, <7.3.0",
        "symfony/symfony": "^2.7", // v2.7.4
        "twig/twig": "^1.22", // v1.22.1
        "sensio/framework-extra-bundle": "^3.0", // v3.0.16
        "doctrine/doctrine-bundle": "^1.5", // v1.5.1
        "doctrine/orm": "^2.5", // v2.5.1
        "doctrine/doctrine-fixtures-bundle": "^2.2", // v2.2.1
        "behat/symfony2-extension": "^2.0" // v2.0.0
    },
    "require-dev": {
        "behat/mink-extension": "^2.0", // v2.0.1
        "behat/mink-goutte-driver": "^1.1", // v1.1.0
        "behat/mink-selenium2-driver": "^1.2", // v1.2.0
        "phpunit/phpunit": "^4.8" // 4.8.18
    }
}
userVoice