Test-Driven Development in Drupal 8
Test-Driven Development is the concept of writing tests before writing actual code, which can be hard to grasp if you are just starting to learn how to test your code.
A Simple Example of Test-Driven Development in Drupal
Let's assume that we need to add a field to the form we created here, to capture the user's email address. Perhaps we'd like this information to help with tailoring the experience around the site, or maybe, we'd like to add the user to our newsletter mailing list. Whatever the case is, it would be very tempting to jump right in and add this code so that we could get to a working product quickly. But by writing a test first, we force ourselves to get into the discipline of defining what is expected before we write any code. This ultimately leads to better code and a functional deliverable.
/**
* Tests that the form has an email field.
*/
public function testFruitFormEmailFieldExists() {
$this->drupalGet('testmodule/ask-user');
$this->assertResponse(200);
$this->assertFieldById('edit-email-address');
}
Of course if we were to run this test right away, it would fail. This is okay because it highlights the primary purpose of Test Driven Development - catching issues early. Now we can turn around and write the necessary code to satisfy this test.
/**
* {@inheritDoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Orange', 'Strawberry'];
$form['favorite_fruit'] = array(
'#type' => 'select',
'#title' => $this->t('Tell us your favorite fruit.'),
'#required' => true,
'#options' => array_combine($fruits, $fruits)
);
$form['email_address'] = array(
'#type' => 'email',
'#title' => $this->t('What is your email address?'),
'#required' => true,
);
$form['submit'] = array(
'#type' => 'submit',
'#value' => $this->t('Submit!')
);
return $form;
}
While our new test passes, it seems like the testFruitFormSubmit test is failing. This is because when we added the email address field to our form, we also specified that it is a required field. But we didn't change anything about our form submit and Drupal won't allow a form to submit if any of the required fields are not filled in. We now need to update our test to pass a value for email address so that the form can be submitted:
/**
* Test the submission of the form.
* @throws \Exception
*/
public function testFruitFormSubmit() {
// submit the form with provided values
$this->drupalPostForm(
'testmodule/ask-user',
array(
'favorite_fruit' => 'Blueberry',
'email_address' => '[email protected]'
),
t('Submit!')
);
// we should now be on the homepage, and see the right form success message
$this->assertUrl('');
$this->assertText('Blueberry! Wow! Nice choice! Thanks for telling us!', 'The successful submission message was detected on the screen.');
}
Looking good!
Testing More Complex Requirements
Let's assume that we have a new request for the form - we only want to accept email addresses from Gmail or Yahoo. Here's how we can go about fulfilling this requirement.
Our form code includes an empty validateForm method that is called automatically when submitting forms in Drupal. We can add code here to check whether the user submitted any information, and enforce constraints on what is submitted prior to accepting it.
Before we think about how to solve the requirement, lets add enough validation code so the tests fail, and add tests that let us know when the validation is working. In the form, we simply set an error to prevent any submission from happening and now our testFruitFormSubmit method will fail again:
/**
* {@inheritDoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
$form_state->setError($form['email_address'], 'Error');
}
Next, we add the new scenarios to our test. We know we can only accept an email address from Gmail or Yahoo but lets also check that the email address is properly formatted. This will prevent someone from simply entering 'gmail.com' into the field and trying to submit it.
/**
* Test the submission of the form.
* @throws \Exception
*/
public function testFruitFormSubmit() {
// submit the form with a bad email address
$this->drupalPostForm(
'testmodule/ask-user',
array(
'favorite_fruit' => 'Blueberry',
'email_address' => 'anonymous@example'
),
t('Submit!')
);
$this->assertText('Email address is invalid.');
// submit the form with an unaccepted email address
$this->drupalPostForm(
'testmodule/ask-user',
array(
'favorite_fruit' => 'Blueberry',
'email_address' => '[email protected]'
),
t('Submit!')
);
$this->assertText('Sorry, we only accept Gmail or Yahoo email addresses at this time.');
// submit the form with an accepted email address
$this->drupalPostForm(
'testmodule/ask-user',
array(
'favorite_fruit' => 'Blueberry',
'email_address' => '[email protected]'
),
t('Submit!')
);
$this->assertNoText('Sorry, we only accept Gmail or Yahoo email addresses at this time.');
// submit the form with an accepted email address
$this->drupalPostForm(
'testmodule/ask-user',
array(
'favorite_fruit' => 'Blueberry',
'email_address' => '[email protected]'
),
t('Submit!')
);
$this->assertNoText('Sorry, we only accept Gmail or Yahoo email addresses at this time.');
// submit the form with valid values which should pass
$this->drupalPostForm(
'testmodule/ask-user',
array(
'favorite_fruit' => 'Blueberry',
'email_address' => '[email protected]'
),
t('Submit!')
);
// we should now be on the homepage, and see the right form success message
$this->assertUrl('');
$this->assertText('Blueberry! Wow! Nice choice! Thanks for telling us!', 'The successful submission message was detected on the screen.');
}
We can simulate a submission multiple times inside a single test. Each time, we are testing the submission with different values for email address - ones that should be accepted, and ones that should not. We can also check for error messages that appear on the screen to the user, which is what we will return in our actual form code. This gives us a really good guideline on the code we need to write.
Back in the form, lets add some real validation for our new requirement. You can solve this in any number of ways, but since this is just an example, let's keep it simple:
class FruitForm extends FormBase {
protected $accepted_domains = ['gmail.com', 'yahoo.com'];
// code hidden....
/**
* {@inheritDoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
if (!filter_var($form_state->getValue('email_address'), FILTER_VALIDATE_EMAIL)) {
$form_state->setError($form['email_address'], 'Email address is invalid.');
}
if (!$this->validEmailAddress($form_state->getValue('email_address'))) {
$form_state->setError($form['email_address'], 'Sorry, we only accept Gmail or Yahoo email addresses at this time.');
}
}
/**
* Check the supplied email address that it matches what we will accept.
* @param $email_address
* @return bool
*/
protected function validEmailAddress($email_address) {
$domain = explode('@', $email_address)[1];
return in_array($domain, $this->accepted_domains);
}
}
There we go. Now if someone enters a value that isn't an email address from Gmail or Yahoo, we set an error and kindly let the user know what went wrong with their entry.
Our form validation is working.
Now when we re-run the tests, everything should pass, letting us know that our code meets all requirements.
Onward!
Imagine if we started our development process over, using testmodule to execute the concept of test-driven development. We would write all of these same tests upfront before tackling the code needed to pass each one. This approach would help ensure that we are on track to fulfilling all of the defined requirements and we would know, without a doubt, that our custom module is working exactly the way we expect it to. Improvements can always be made by refactoring the code along the way. If new requirements are added, new tests can be written, followed by the code to satisfy the tests. This keeps development activities focused, and keeps a project on track with its timeline and milestones.
Feel free to browse the example code for this post, give it a try in your own code, and share your results via a comment below.