Buy

Clicking a Row in a Table (i.e. Complex Selectors)

Time for a real challenge! Deleting products, it's actually a bit harder than you might think. Here comes some curve balls -- eh eh like that baseball pun?

We need a delete button to remove individual products. BDD Time! Start with the scenario:

54 lines features/web/product_admin.feature
... lines 1 - 29
Scenario: Deleting a product
... lines 31 - 54

To delete a product, we need to start with one in the database. In fact, if we start with two products, we can delete the second and check that the first is unaffected.

Add a Given that's similar to the one from the "show published/unpublished" scenario, but with a slight difference:

54 lines features/web/product_admin.feature
... lines 1 - 30
Given the following product exists:
| name |
| Bar |
| Foo1 |
... lines 35 - 54

Since we only care about the name, we don't need to bother with adding an "is published" row: keep things minimal! Create a product Bar and another Foo1. Man, those dinos can't wait to get a hold of such interesting product names!

54 lines features/web/product_admin.feature
... lines 1 - 34
When I go to "/admin/products"
... lines 36 - 54

This is where it gets tricky: we'll have two rows in our table that both have 'delete' buttons, but I only want to click "Delete" on the second row. Add another step to do that:

54 lines features/web/product_admin.feature
... lines 1 - 35
And I click "Delete" in the "Foo1" row
... lines 37 - 54

Then, it would be super to see a flash message that confirms that the product was deleted. Make sure Foo1 no longer appears in this list of products. And double check that Bar was not also deleted:

54 lines features/web/product_admin.feature
... lines 1 - 36
Then I should see "The product was deleted"
And I should not see "Foo1"
But I should see "Bar"
... lines 40 - 54

This is the first time we've seen But. But, it has the same functionality as And: it extends a Then, When or Given and sounds natural.

Try it! Run just this scenario:

./vendor/bin/behat features/product_admin.feature:42

Copy the new function into FeatureContext:

239 lines features/bootstrap/FeatureContext.php
... lines 1 - 129
/**
* @When I click :arg1 in the :arg2 row
*/
public function iClickInTheRow($arg1, $arg2)
{
throw new PendingException();
}
... lines 137 - 239

Change arg1 to linkText and arg2 to rowText:

254 lines features/bootstrap/FeatureContext.php
... lines 1 - 128
/**
* @When I click :linkText in the :rowText row
*/
public function iClickInTheRow($linkText, $rowText)
{
... lines 134 - 138
}
... lines 140 - 254

This isn't the first time we've looked for a row by finding text inside of it. Let's re-use some code.

Make a new private function findRowByText() and give it a $linkText argument:

254 lines features/bootstrap/FeatureContext.php
... lines 1 - 241
/**
* @param $rowText
* @return \Behat\Mink\Element\NodeElement
*/
private function findRowByText($rowText)
{
... lines 248 - 251
}
... lines 253 - 254

Copy the two lines that find the row and return $row. That'll make life a little bit easier:

254 lines features/bootstrap/FeatureContext.php
... lines 1 - 247
$row = $this->getPage()->find('css', sprintf('table tr:contains("%s")', $rowText));
assertNotNull($row, 'Cannot find a table row with this text!');
return $row;
... lines 252 - 254

Now use $this->findRowByText($rowText); in the original method and also in the new definition:

254 lines features/bootstrap/FeatureContext.php
... lines 1 - 121
public function theProductRowShouldShowAsPublished($rowText)
{
$row = $this->findRowByText($rowText);
... lines 125 - 126
}
... lines 128 - 131
public function iClickInTheRow($linkText, $rowText)
{
$row = $this->findRowByText($rowText);
... lines 135 - 138
}
... lines 140 - 254

Consider the row found!

To find the link, we don't want to use css: $linkText is the name of the text: what a user would see on the site. Instead, use $row->findLink() and pass it $linkText:

254 lines features/bootstrap/FeatureContext.php
... lines 1 - 131
public function iClickInTheRow($linkText, $rowText)
{
$row = $this->findRowByText($rowText);
$link = $row->findLink($linkText);
... lines 137 - 138
}
... lines 140 - 254

I'll repeat this one more time for fun. you can find three things by their text: links, buttons and fields. Use findLink(), findButton() and findField() on the page or individual elements to drill down to find things. Add assertNotNull($link, 'Could not find link '.$linkText); in case something goes wrong. Finally click that link!

254 lines features/bootstrap/FeatureContext.php
... lines 1 - 136
assertNotNull($link, 'Cannot find link in row with text '.$linkText);
$link->click();
... lines 139 - 254

We haven't done any coding yet, but the scenario is done. Run it!

./vendor/bin/behat features/product_admin.feature:42

It fails... but not in the way that I expected. It says

Undefined index: is published in FeatureContext line 110.

That's happening because - this time - we don't have the 'is published' column in our table. But on line 110, we're assuming it's always there:

254 lines features/bootstrap/FeatureContext.php
... lines 1 - 100
public function theFollowingProductsExist(TableNode $table)
{
... lines 103 - 108
if ($row['is published'] == 'yes') {
$product->setIsPublished(true);
}
... lines 112 - 116
}
... lines 118 - 254

That's fine: I like to start lazy and assume everything is there. When I need the steps to be more flexible, I'll add more code. Add an isset('is published') so if it's set and equals yes, we'll publish it:

254 lines features/bootstrap/FeatureContext.php
... lines 1 - 108
if (isset($row['is published']) && $row['is published'] == 'yes') {
$product->setIsPublished(true);
}
... lines 112 - 254

Rerun this now.

./vendor/bin/behat features/product_admin.feature:42

It fails with:

Undefined variable: rowText in FeatureContext line 256.

Hmm that sounds like a Ryan mistake. Yep: I meant to call this variable $rowText:

254 lines features/bootstrap/FeatureContext.php
... lines 1 - 241
/**
* @param $rowText
* @return \Behat\Mink\Element\NodeElement
*/
private function findRowByText($rowText)
{
... lines 248 - 251
}
... lines 253 - 254

Now we've got the proper failure: there is no link called Delete.

Let's code for this! Remember, do as little work as possible.

Coding the Delete

Add a new deleteAction() and a route of /admin/products/delete/{id}. Name it product_delete. We could get fancy and add an @Method annotation that say that this will only match POST or DELETE requests. Let's keep it simple for now:

66 lines src/AppBundle/Controller/ProductAdminController.php
... lines 1 - 50
/**
* @Route("/admin/products/delete/{id}", name="product_delete")
* @Method("POST")
*/
public function deleteAction(Product $product)
{
... lines 57 - 63
}
... lines 65 - 66

And instead of adding $id as an argument to deleteAction(), I'll be even lazier and type hint the Product so that Symfony queries for it for me.

Now, remove the $product, flush it, set a success flash message that matches what's in the scenario and finally redirect back to the product list route:

66 lines src/AppBundle/Controller/ProductAdminController.php
... lines 1 - 56
$em = $this->getDoctrine()->getManager();
$em->remove($product);
$em->flush();
$this->addFlash('success', 'The product was deleted');
return $this->redirectToRoute('product_list');
... lines 64 - 66

To add the delete link, find list.html.twig and add a column called Actions. Since you should POST to delete things, add a small form in each row, instead of a link tag. Make the form point to the product_delete route and add method="POST". And instead of having fields, it only needs a submit button whose text is "Delete". Add some CSS classes to make it look nice - don't get too lazy on me:

73 lines app/Resources/views/product/list.html.twig
... lines 1 - 19
<table class="table table-striped">
<thead>
<tr>
... lines 23 - 26
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for product in products %}
<tr>
... lines 33 - 38
<td>
<form action="{{ path('product_delete', {'id': product.id} ) }}" method="POST">
<button type="submit" class="btn btn-small btn-link">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
... lines 48 - 73

Perfect!

Try it!

./vendor/bin/behat features/product_admin.feature:42

Hmmm, it fails in the same spot:

And I click "Delete" in the "Foo1" row.

Either something is wrong with the way we wrote the code, there's an error on the page or we're not even on the right page. Right now, we can't tell.

Since it's failing on the "I click" line, hold command and click to see its step definition function. Var dump the $row variable to make sure we're finding the row we expected:

255 lines features/bootstrap/FeatureContext.php
... lines 1 - 131
public function iClickInTheRow($linkText, $rowText)
{
$row = $this->findRowByText($rowText);
var_dump($row->getHtml());
... lines 137 - 139
}
... lines 141 - 255

The other thing we can do is temporarily make this an @javascript scenario and add a break:

55 lines features/web/product_admin.feature
... lines 1 - 28
@javascript
Scenario: Deleting a product
... lines 31 - 34
When I go to "/admin/products"
And break
... lines 37 - 55

Try it again:

./vendor/bin/behat features/product_admin.feature:42

Ah-ha! We have an exception on our page and had no idea! I forgot to pass the id when generating the URL:

73 lines app/Resources/views/product/list.html.twig
... lines 1 - 39
<form action="{{ path('product_delete', {'id': product.id} ) }}" method="POST">
<button type="submit" class="btn btn-small btn-link">Delete</button>
</form>
... lines 43 - 73

Keep the debugging stuff in and try again:

./vendor/bin/behat features/product_admin.feature:42

It stops again, but no error this time: the delete button looks fine. Press enter to keep this moving.

But it still fails! The test could not find a "Delete" link to click in the "Foo1" row. The cause is subtle: links and buttons are not the same. We click links but we press buttons. In the scenario I should say I press Delete instead of click:

54 lines features/web/product_admin.feature
... lines 1 - 29
Scenario: Deleting a product
... lines 31 - 35
And I press "Delete" in the "Foo1" row
... lines 37 - 54

More importantly, inside of our FeatureContext, update to use findButton() and change the action from click to press.

254 lines features/bootstrap/FeatureContext.php
... lines 1 - 128
/**
* @When I press :linkText in the :rowText row
*/
public function iClickInTheRow($linkText, $rowText)
{
... lines 134 - 135
$link = $row->findButton($linkText);
assertNotNull($link, 'Cannot find button in row with text '.$linkText);
$link->press();
}
... lines 140 - 254

For clarity, change $link to $button and $linkText to $buttonText.

This should solve all 99 of our problems. I even have enough confidence to remove @javascript and the "break" line. Rerun the test!

./vendor/bin/behat features/product_admin.feature:42

Finally green!

Clean up the code a bit more by changing findButton() to pressButton():

250 lines features/bootstrap/FeatureContext.php
... lines 1 - 131
public function iClickInTheRow($linkText, $rowText)
{
$this->findRowByText($rowText)->pressButton($linkText);
}
... lines 136 - 250

Remember, this shortcut also works with clickLink() and fillField().

./vendor/bin/behat features/product_admin.feature:42

And it still passes. You just won the Behat and Mink World Series! I know, terrible baseball joke - but the world series is on right now.

Leave a comment!