Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Master JavaScript with Waits & Debugging

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

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.

Waiting for things to Happen

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!

Waiting the Wrong Way

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.

Waiting the Right Way

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.

Leave a comment!

2
Login or Register to join the conversation
GDIBass Avatar
GDIBass Avatar GDIBass | posted 5 years ago | edited

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:



$this->getSession()->wait(100);

But I was wondering if there was a better way of handling this.

Reply

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!

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