In this tutorial we'll show you how to test the submission of a SilverStripe form by remote controlling a browser. Along the way, you'll learn how to create database fixtures and make assertions about any changed state in the database.
In order to illustrate these concepts, we'll create a "report this page" feature which shows at the bottom of each page, and gives its visitors an opportunity to help discover and fix problems with its content. The "report" consists of a form with a dropdown, its submission is stored in a custom SilverStripe database object.
First of all, check out a default SilverStripe project and ensure it runs on your environment. Detailed installation instructions can be found in the README of this module. Once you've got the SilverStripe project running, make sure you've started ChromeDriver. With all configuration in place, initialise Behat for
vendor/bin/behat --init @mysite
This will create a location for our feature files later on,
in mysite/tests/behat/features
.
Note that the module doesn't need its own behat.yml
configuration
file since it reuses the one in the root folder.
One major goal for "testing by example" through Behat is bringing the tests closer to the whole team, by making them part of the agile process and ideally have the customer write some tests in his own language (read more about this concept at dannorth.net).
In this spirit, we'll start "from the outside in", and write our features before implementing them. A first draft might look something like the following:
Feature: Report Abuse
As a website user
I want to report inappropriate content
In order to maintain high quality content
Scenario: Report abuse through preselected options
Given I go to a page
Then I should see "Report this page"
When I select "Outdated"
And I press the button
Then I should see "Thanks for your submission"
The "syntax" conventions used here are called the
"Gherkin" language.
It is fairly free-form, with only few rules about indentation and
keywords such as Feature:
, Scenario:
or Given
.
Each of the actual steps underneath Scenario
needs to map
to logic which knows how to tell the browser what to do.
Let's try to run our scenario:
vendor/bin/behat --ansi @mysite
We'll see all steps marked as "not implemented" (orange). Thankfully Behat already comes with a lot of step definitions, so our next move is to review what's already available:
vendor/bin/behat @mymodule --definitions=i
The step definitions include form interactions, so we only need to adjust our steps a bit to make them executable.
Scenario: Report abuse through preselected options
Given I go to a page
Then I should see "Report this page"
When I select "Outdated" from "Reason"
And I press "Submit Report"
Then I should see "Thanks for your submission"
This type of refactoring is quite common, since step definitions are ideally abstracted and shared between features. In this case, we needed to make some steps less ambiguous, for example referencing which form field to select from. We haven't written the code for this form field yet, but its easy enough to label it "Reason" without knowing much about its implementation. Run the tests again, and you should see some steps marked as "skipped" instead of "not implemented".
There's still a bit of ambiguity in our feature: Each test run starts with a clean database, meaning there's no pages to open in a browser either. Let's fix this by defining one, and asking Behat to open it:
Scenario: Report abuse through preselected options
Given a "page" "My Page"
Given I go to the "page" "My Page"
...
Enough theory, we have a good idea of what our feature should do,
let's get down to coding! We won't get too much into details,
but overall we're creating a new PageAbuseReport
class with
a has_one
relationship to Page
. This new object gets written
by a form generated through Page_Controller->ReportForm()
.
// mysite/code/Page.php
class Page extends SiteTree {
private static $has_many = array('PageAbuseReports' => 'PageAbuseReport');
}
class Page_Controller extends ContentController {
// ...
private static $allowed_actions = array('ReportForm');
public function ReportForm() {
return new Form(
$this,
'ReportForm',
new FieldList(
new HeaderField('ReportTitle', 'Report this page', 3),
(new DropdownField('Reason', 'Reason'))
->setSource(array(
'Inappropriate' => 'Inappropriate',
'Outdated' => 'Outdated',
'Misleading' => 'Misleading',
))
),
new FieldList(
new FormAction('doReport', 'Submit Report')
)
);
}
public function doReport($data, $form) {
(new PageAbuseReport(array(
'Reason' => $data['Reason'],
'PageID' => $this->ID,
)))->write();
$form->sessionMessage('Thanks for your submission!', 'good');
return $this->redirectBack();
}
}
// mysite/code/PageAbuseReport.php
class PageAbuseReport extends DataObject {
private static $db = array('Reason' => 'Text');
private static $has_one = array('Page' => 'Page');
}
Now we just need to render the form on every page,
by placing it at the bottom of themes/simple/templates/Layout/Page.ss
:
<div class="content-container unit size3of4 lastUnit">
<article>
<h1>$Title</h1>
<div class="content">$Content</div>
</article>
$ReportForm
</div>
You can try out this feature in your browser without Behat.
Don't forget to rebuild the database (dev/build
) and flush the
template cache (?flush=all
) first though. If its all looking good,
kick off another Behat run - it should pass now.
vendor/bin/behat @mysite
Can you see the flaw in our test? We haven't actually checked that a report record has been written, just that the user received a nice message after the form submission. In order to check this, we'll need to write some custom step definitions. This is where the SilverStripe extension to Behat comes in handy, since you're already connected to the same test database in Behat that the browser is using. Two separate processes, but same database - and a clean slate on each run.
At the end of our report-abuse.feature
file, add the following:
Scenario: Report abuse through preselected options
...
Then I should see "Thanks for your submission"
And there should be an abuse report for "My Page" with reason "Outdated"
Running behat again will produce an undefined step, with a helpful PHP boilerplate to get us started:
/**
* @Given /^there should be an abuse report for "([^"]*)" with reason "([^"]*)"$/
*/
public function thereShouldBeAnAbuseReportForWithReason($arg1, $arg2)
{
throw new PendingException();
}
This code can be placed in a "context" class which was created during our
module initialization. Its located in
mysite/tests/behat/features/bootstrap/Context/FeatureContext.php
.
The actual step implementation can vary quite a bit, and usually involves
triggering a browser action like clicking a button, or inspecting the
current browser state, e.g. check that a button is visible.
In our case, we want to talk to the SilverStripe test database,
and retrieve a record created through previous actions.
The FeatureContext
comes predefined with a $fixtureFactory
property,
an object which handles loading and saving of test fixtures.
You could also use DataObject::get()
directly, but the fixture factory
allows us to use more readable aliases (My Page
) rather than arbitrary
database identifiers. Since the fixture factory returns standard
SilverStripe ORM records, we can process them in the usual way
and check their relation for any existing reports.
The assertion framework used here is simply PHPUnit,
so if you have done unit testing before the assertEquals()
call might
look familiar. Either way, it will throw an exception if there's
not exactly one record found in the relation, and hence fail that step for Behat.
/**
* @Given /^there should be an abuse report for "([^"]*)" with reason "([^"]*)"$/
*/
public function thereShouldBeAnAbuseReportForWithReason($id, $reason)
{
$page = $this->fixtureFactory->get('Page', $id);
Assert::assertEquals(1, $page->PageAbuseReports()->filter('Reason', $reason)->Count());
}
Re-run the Behat test one last time, and you should see it pass with a more solid feature definition. Success! If you want to get your hands dirty, try to add a second page, and assert that this page doesn't have any reports assigned to it, ensuring that our relation setting indeed works as intended.