If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
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!
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
:
{ | |
... 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.
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!
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!
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.
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!
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?
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);
}
}
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?
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!
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...
Awesome! DrupalCon is one of my absolute favorite conferences - incredibly well-run and great people. See you there!
// 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
}
}
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