Clicking a Row in a Table (i.e. Complex Selectors)
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.
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:
// ... 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:
// ... 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!
// ... 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:
// ... 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:
// ... 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
:
// ... 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
:
// ... 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:
// ... 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:
// ... 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:
// ... 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!
Finding Links and Buttons in a Row
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
:
// ... 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!
// ... 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:
// ... 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:
// ... 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
inFeatureContext
line 256.
Hmm that sounds like a Ryan mistake. Yep: I meant to call this variable $rowText
:
// ... 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:
// ... 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:
// ... 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:
// ... 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
Click/Follow Links, Press Buttons
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:
// ... 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
:
// ... 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:
// ... 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:
// ... 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
.
// ... 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()
:
// ... 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.