If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
Let's talk JavaScript and the complications it can cause. Right now everything runs using the Goutte driver and background cURL requests. This means that if your behavior relies on JavaScript, it'll fail. We need to use Selenium - or Zombie.js - another supported JavaScript driver - for these scenarios.
I want this "New Product" button to not load a whole new page, but instead open up
a modal. I cheated and already did most of the work for this. Inside of list.html.twig
,
add the class js-add-new-product
to the "New Product" link:
... lines 1 - 12 | |
<a href="{{ path('product_new') }}" class="btn btn-primary pull-right js-add-new-product"> | |
<span class="fa fa-plus"></span> New Product | |
</a> | |
... lines 16 - 67 |
This triggers some JavaScript that I have at the bottom of the template:
... lines 1 - 45 | |
{% block javascripts %} | |
{{ parent() }} | |
<script type="text/javascript"> | |
$(document).ready(function() { | |
$('.js-add-new-product').on('click', function(e) { | |
e.preventDefault(); | |
var $modalContentHolder = $('#modal-content-holder'); | |
jQuery.ajax({ | |
'url': $(this).attr('href'), | |
'success': function(content) { | |
$modalContentHolder.find('.modal-body').html(content); | |
$modalContentHolder.modal(); | |
} | |
}); | |
}); | |
}); | |
</script> | |
{% endblock %} |
Next, make the new.html.twig
template only return a partial page by removing
the extends and Twig block tags:
<form action="{{ path('product_new') }}" method="POST"> | |
<fieldset> | |
<legend>New Product</legend> | |
<div class="form-group"> | |
<label for="article-name">Name</label> | |
<input type="text" class="form-control" id="article-name" name="name" /> | |
</div> | |
<div class="form-group"> | |
<label for="article-price">Price</label> | |
<input type="text" class="form-control" id="article-price" name="price" /> | |
</div> | |
<div class="form-group"> | |
<label for="article-body">Description</label> | |
<textarea class="form-control" id="article-body" name="description"></textarea> | |
</div> | |
</fieldset> | |
<button type="submit" class="btn btn-primary">Save</button> | |
</form> |
This will now only be loaded via AJAX. There are cooler ways to make this all work, but for the purposes of Behat, they all face the same complications that you'll see.
Now click the "New Product" button. It opens up in a modal and it even saves my $34 Foo product. Simple!
Since we just modified our code, we should rerun our tests to make sure that everything still works. Run the new product scenario:
./vendor/bin/behat features/product_admin.feature:19
It works! Wait... it shouldn't! It relies on JavaScript! In reality, the test clicked
to this URL, went to an ugly, but functional page, filled out the form and hit "Save".
That's kind of ok, it did test the form's functionality. But in reality, clicking
the link opens a modal, so that should happen in the test too. Add @javascript
to the top of the scenario:
... lines 1 - 19 | |
@javascript | |
Scenario: Add a new product | |
... lines 22 - 33 |
That changed bumped our scenario down one line. Put the new line into the terminal and run it:
./vendor/bin/behat features/product_admin.feature:20
Just make sure that the Selenium server is still running in the background. Watch closely. Well, don't watch that silly Firefox error. We log in, go to the products page, clicks the "New Product" button, fill in the form fields, and hit "Save". It's perfect.
Reality check: if I re-ran this 5 times, I bet it would fail at least once. Let me
show you why. In ProductAdminController
pretend this isn't such a fast ajax request.
Add a sleep(1)
to fake the time it would take to do some logic.
Re-run things:
./vendor/bin/behat features/product_admin.feature:20
We log in, click the button... and the browser closes! We didn't see it fill out any fields. In the terminal, it failed at:
... lines 1 - 20 | |
Scenario: Add a new product | |
... lines 22 - 25 | |
And I fill in "Name" with "Veloci-chew toy" | |
... lines 27 - 34 |
Because the "Name" field wasn't found. What is this madness?
Here's the secret: if you click a link or submit a form and it causes a full page refresh, Mink and Selenium will wait for that page refresh. But, if you do something in JavaScript, Selenium does not wait. It clicks the "New Products" link and immediately looks for the "Name" field. If that field isn't there almost instantly, it fails. We have to make Selenium wait after clicking the link.
To make this happen, add a new step like:
... lines 1 - 20 | |
Scenario: Add a new product | |
... lines 22 - 24 | |
And I wait for the modal to load | |
... lines 26 - 34 |
Run Behat so it'll generate the missing definition. Selenium pops open the browser,
then fails. Copy the new definition into FeatureContext
:
... lines 1 - 129 | |
/** | |
* @When I wait for the modal to load | |
*/ | |
public function iWaitForTheModalToLoad() | |
{ | |
... lines 135 - 138 | |
} | |
... lines 140 - 175 |
Yes!
Now, how do we wait for things? Well, there is a right way and a wrong way. Wrong
way first! Add $this->getSession()->wait(5000);
to wait for 5 seconds:
... lines 1 - 132 | |
public function iWaitForTheModalToLoad() | |
{ | |
$this->getSession()->wait(5000); | |
} | |
... lines 137 - 172 |
That should be overkill since the controller sleeps for just 1 second. Try this out anyways to see if it passes:
./vendor/bin/behat features/product_admin.feature:20
The test logs us in, clicks the button 1...2...3...4...5, then it finally fills in the fields. It passed, but took too long. If you litter your test suite with wait statements like this, your tests will start to crawl. And you know what? You'll just stop running them, the fences will go down and guests will get eaten by dinosaurs in your park. Do you want your guests to be eaten? No. I didn't think so. So let's look at the right way to do this.
The second argument to wait()
is a JavaScript expression that will run on your page
every 100 milliseconds. As soon as it equates to true, Mink will stop waiting and
move onto the next step. I'm using Bootstrap's modal, and when it opens, an element
with the class modal
becomes visible. In your browser's console, try running
$('.modal:visible').length
. Because the modal is open, that returns one. Now close
it: it returns zero. Pass this as the second argument to wait()
:
... lines 1 - 134 | |
$this->getSession()->wait( | |
5000, | |
"$('.modal:visible').length > 0" | |
); | |
... lines 139 - 175 |
This now says: "Wait until this JavaScript expression is true or wait a maximum of 5 seconds." Why am I allowed to use jQuery here? Because this runs on your page and you have access to any JavaScript loaded by your app.
Run it again:
./vendor/bin/behat features/product_admin.feature:20
This time it starts filling out the fields a lot faster. The most important thing for testing in JavaScript is mastering proper waits. I see people mess this up by using the "bad way" all the time.
Hey Matt,
Looks like you wait 0.1 second, but not a big deal. Hm, this line should help: $('.modal:visible').length > 0 - probably you need to debug the HTML in a tool like Google Chrome toolbar, maybe you just need to use a different from ".modal" class, probably some parent tag is "display:none" as well.
Cheers!
// 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
}
}
Mine sometimes tries to fill out the name field WHILE the modal is loading, which then fails (because it's not visible yet). I was able to get it to pass every time by adding a second wait:
But I was wondering if there was a better way of handling this.