Programmers: Unit Testing Drupal 8 Form Validation with PHPUnit

TL;DR

In this post, we’ll cover some things I’ve picked up recently using PHPUnit, specifically working with test doubles and mocking methods, in order to unit test a Drupal form validation method. You should be able to easily adapt this code to test your own form validations too. I also stress the importance of having unit tests before refactoring a “legacy system,” as well as make the recommendation for reading Working Effectively With Legacy Code by Michael Feathers.

The Code

The sample code that accompanies this article is located here on GitHub, and the article assumes you have Drupal 8 running on PHP 7. You can download and install the module. It creates a simple form, located at the relative url “/form_validation_unit_test_module.” It is simply a select list where you pick a question to guess, followed by a text box where you enter a guess to the selected question, and lastly a submit button that validates the answer when clicked. The project also includes the unit tests.

Migrating from Drupal 6 to Drupal 8

Most recently, I’ve been working on migrating a website from Drupal 6 (D6) to Drupal 8 (D8). Currently there are automated UI tests covering important functionality, but no unit tests. With the move to Drupal 8 and object orientation, the benefits that arise from covering the code with unit tests become much easier to attain.

There are plenty of other good articles in existence about creating a Form or Controller object in D8, so that won’t be the focus here. Our focus will be on covering part of the form, specifically the implementation of the FormInterface::validateForm method, with unit tests. The FormInterface::validateForm method implementation on a form object is called right after the user clicks the submit button, but before the actual submission handler is executed, in order to give the application an opportunity to validate the user input. Covering this method with unit tests will require mocking the FormStateInterface object that is passed to FormInterface::validateForm by the Drupal framework.

I wrote the code such that the FormValidationUnitTestCase class and GeneralUnitTestHelperTrait trait can be used to unit test other FormInterface::validateForm method implementations — with some slight modification to the source code. We’ll be looking at excerpts of the GitHub repo throughout the rest of the article.

The Form We’ll Be Testing

There are 5 “validations” that are build into the GuessForm::validateForm method, each of which will call the setErrorByName method of the passed-in FormStateInterface instance. This ultimately displays an error message to the user. First, we’ll look at the actual implementation of the GuessForm::validateForm method:

public function validateForm(array &$form, FormStateInterface $form_state)
  {
    $values = $form_state->getValues();

    $selected_question_key = $values[GuessForm::select_list_key];
    $correct_answer_to_selected_question = $this->getAnswerToQuestion($selected_question_key);
    $entered_answer_value = $values[GuessForm::text_field_key];
        
    if (empty($entered_answer_value))
      $form_state->setErrorByName(GuessForm::text_field_key, "You didn't guess anything!");

    if ($selected_question_key == GuessForm::question_selection_default_key)
      $form_state->setErrorByName(GuessForm::select_list_key, "Please select a question to guess an answer!");

    if ($entered_answer_value == GuessForm::text_field_default_value)
      $form_state->setErrorByName(GuessForm::text_field_key, "You should remove the default guess and enter your own.");
    
    if ($entered_answer_value != $correct_answer_to_selected_question)
    {
      $entered_answer_is_correct_for_a_question_other_than_what_was_selected = false;
      $question_selection_list = $this->getQuestionSelectionList();

      foreach ($question_selection_list as $question_selection_key => $question_selection_value)
      {
        $correct_answer_to_currently_iterated_question = $this->getAnswerToQuestion($question_selection_key);
        
        if ($correct_answer_to_currently_iterated_question === $entered_answer_value)
        {
          $entered_answer_is_correct_for_a_question_other_than_what_was_selected = true;
          break;
        }
      }

      if ($entered_answer_is_correct_for_a_question_other_than_what_was_selected)
      {
        $form_state->setErrorByName(
          GuessForm::text_field_key, "You entered an incorrect answer to the question you were trying to guess, "
          . "but the answer was ironically correct for a different question. Change the question and then click 'guess again'!"
        );
      }
      else
      {
        $form_state->setErrorByName(GuessForm::text_field_key, "You entered an incorrect answer to the question you were trying to guess.");
      }
    }
  }

Design Decision: An Abstract Class and a Trait

To create a unit test, we need to ultimately subclass from the Drupal\Tests\UnitTestCase object. In this use case, I’ve created an abstract subclass of UnitTestCase called FormValidationUnitTestCase. To actually create a unit test, FormValidationUnitTestCase must be subclassed. This is to allow code reuse for when I need to test the FormInterface::validateForm method implementation of other Form objects.

In this use case, I’ve also created a trait, GeneralUnitTestHelperTrait. If you’re not familiar with PHP traits, know that they are basically a way to share methods and properties across multiple class inheritance hierarchies (as described here). This concept is analogous to a Class Category in Objective-C, or an Extension Method in C#.

So, helper methods for the FormInterface::validateForm unit tests exist in both the FormValidationUnitTestCase abstract class and also the GeneralUnitTestHelperTrait trait. I chose to put more general helper methods in the GeneralUnitTestHelperTrait, as the trait can be used across many unit test cases in different class inheritance hierarchies. Since the other helper methods are specific to testing the FormInterface::validateForm method implementation, I chose to put those methods in an abstract class, thus maintaining a degree of congruence within a single class hierarchy for this more specific situation.

Setting Up the Unit Test

For specifically testing GuessForm::validateForm, we’ll implement GuessFormValidateMethodTestCase, which subclasses from FormValidationUnitTestCase. There are three abstract methods on FormValidationUnitTestCase that require implementation.

On the GuessFormValidateMethodTestCase subclass, we must implement the getFullyNamespacedFormClassToTest abstract method to tell FormValidationUnitTestCase what the name of the form is that will be tested:

  protected function getFullyNamespacedFormClassToTest()
  {
    return 'Drupal\form_validation_unit_test_module\Form\GuessForm';
  }

In the same spirit, we must also implement getFormElementNamesAndDefaultValues. This provides default form values that will be used in the test, to save from having to specify every value for every form element in each unit test. We use constants on the GuessForm class heavily, in order to prevent unnecessary duplication of string values:

  protected function getFormElementNamesAndDefaultValues()
  {
    $form_element_names_and_default_values_for_test = [
      GuessForm::select_list_key => GuessForm::favorite_number_key,
      GuessForm::text_field_key => GuessForm::favorite_number
    ];
    
    return $form_element_names_and_default_values_for_test;
  }

Last, we must implement getMethodsToNotMockExcludingValidateFormMethod on the subclass. During the unit test, all methods are mocked on the tested GuessForm class (in order to prevent undesired side effects during unit testing), except for the validateForm method and any method names that are returned in the getMethodsToNotMockExcludingValidateFormMethod method implementation. Since the GuessForm::getQuestionSelectionList and GuessForm::getAnswerToQuestion methods contain logic needed by the GuessForm::validateForm method, we don’t want to mock them:

  protected function getMethodsToNotMockExcludingValidateFormMethod()
  {
    return ['getQuestionSelectionList', 'getAnswerToQuestion'];
  }

Writing the Unit Tests

There are two general cases of unit tests that we will want to run on GuessForm::validateForm – 1) the case(s) where GuessForm::validateForm does not generate a user error message (happy case), and 2) the case(s) where it does (unhappy cases). Here, I’ll only show one method from each general case of unit test, as the other unit tests in the same general case are very similar. The individual methods that actually contain code for running each test case will be implemented on the GuessFormValidateMethodTestCase class. PHPUnit requires these methods be of public visibility, and they also need to be prefixed with the “test” keyword.

Writing the Unit Tests: Happy Case

For the happy case example, we’ll look at the code for testing a situation where no user error messages should be generated:

  public function testFormValidationNoErrorThrownForCorrectInput() {
    $form_element_names_and_input_values = [
      GuessForm::select_list_key => GuessForm::favorite_aircraft_make_key,
      GuessForm::text_field_key => 'Cessna'      
    ];

    $this->assertFormElementDoesNotCausesErrorMessageWithValidFormInput($form_element_names_and_input_values);
  }


The above code sets the form element values to a situation that should produce no user error message, and then it calls the helper method FormValidationUnitTestCase::testFormValidationNoErrorThrownForCorrectInput, which we’ll look at below. The method below obtains a mock GuessForm object, as well as a mock FormStateInterface instance. Even if you’ve never used PHPUnit before, it’s not too difficult to see what’s going on here with the PHPUnit_Framework_MockObject_MockObject::expects call on the mocked FormStateInterface instance ($form_state). The first PHPUnit_Framework_MockObject_MockObject::expects call (commented “// 1” in the code below) basically tells the mock FormStateInterface instance to return the test values in the $form_element_names_and_input_values variable anytime (hence the PHPUnit_Framework_TestCase::any method call right after) the getValues method is called on the $form_state object. The second PHPUnit_Framework_MockObject_MockObject::expects call (commented “// 2” in the code below) tells the PHPUnit framework that, as part of the test, the FormStateInterface::setErrorByName should never be called on the $form_state object. If FormStateInterface::setErrorByName is ever called, then the test fails, which is what we want to check:

  protected function assertFormElementDoesNotCausesErrorMessageWithValidFormInput($form_element_names_and_input_values)
  {
    $mock_form = $this->getMockedFormObject();
    $form_state = $this->getMockedFormState();
    $form_structure = [];

    $form_element_names_and_input_values_to_use_for_test = $this->getFormValuesToUseForTestBasedOnDefaultAndOverrideValues($form_element_names_and_input_values);

    // 1
    $form_state->expects($this->any())
      ->method('getValues')
      ->will($this->returnValue($form_element_names_and_input_values_to_use_for_test));

    // 2
    $form_state->expects($this->exactly(0))
      ->method('setErrorByName');

    $mock_form->validateForm($form_structure, $form_state);
  }

Writing the Unit Tests: Unhappy Case

When you download the form_validation_unit_test_module code, you’ll notice a total of 6 different “test” methods in the GuessFormValidateMethodTestCase class. There is one test for the happy case (which we’ve already covered), and there are 5 tests to exercise each of 5 different unhappy possibilities where FormStateInterface::setErrorByName might be called within the GuessForm::validateForm method (i.e. there actually are 5 different places within GuessForm::validateForm where FormStateInterface::setErrorByName could potentially be called, and we want tests to cover all of those possibilities).

We’ll only cover one of those possibilities here, but the other 4 unhappy case unit tests are very similar. First, we set up the test form input data we want the test to use. Then we tell the unit test what form element we expect to generate the user error message, as well as what user error message to expect. The method that initiates the unhappy test case we’ll look at is shown below. This method is very similar to the happy case unit test, except that it also includes an expected user error message and the form element that the expected user error message should come from (in this case, the form element corresponding to the GuessForm::text_field_key constant should generate the user error message):

  public function testFormValidationNoAnswerGiven() {
    $form_element_names_and_input_values = [
      GuessForm::text_field_key => ""
    ];

    $expected_error_message = "You didn't guess anything!";

    $this->assertFormElementCausesErrorMessageWithInvalidFormInput(GuessForm::text_field_key, $expected_error_message, $form_element_names_and_input_values);
  }

The assertFormElementCausesErrorMessageWithInvalidFormInput method on the FormValidationUnitTestCase class is shown below, which is used for each of the unhappy test cases:

  
  protected function assertFormElementCausesErrorMessageWithInvalidFormInput($name_of_form_element_generating_error, $expected_error_message, $form_element_names_and_input_values)
  {
    $mock_form = $this->getMockedFormObject();
    $form_state = $this->getMockedFormState();
    $form_structure = [];

    $form_element_names_and_input_values_to_use_for_test = $this->getFormValuesToUseForTestBasedOnDefaultAndOverrideValues($form_element_names_and_input_values);

    $form_state->expects($this->any())
      ->method('getValues')
      ->will($this->returnValue($form_element_names_and_input_values_to_use_for_test));

    // 1
//      this doesn't work and must do getInvcation technique instead, because setErrorByName also called for other invalid input.
// $form_state->expects($form_state_set_error_by_name_spy = $this->any()) // nope 
//    $form_state->expects($form_state_set_error_by_name_spy = $this->once()) // nope 
//    $form_state->expects($form_state_set_error_by_name_spy = $this->exactly(1)) // don't think so
//    $form_state->expects($form_state_set_error_by_name_spy = $this->atLeastOnce()) // sorry, no luck
//      ->method('setErrorByName')
//      ->with($name_of_form_element_generating_error, $expected_error_message);

    // 2
    $form_state->expects($form_state_set_error_by_name_spy = $this->any())
      ->method('setErrorByName');

    $mock_form->validateForm($form_structure, $form_state);

    $parameters = [
      $name_of_form_element_generating_error, 
      $expected_error_message,
    ];

    // 3
    $method_invoked_with_valid_parameters = $this->checkThatMethodCalledAtLeastOnceWithDesiredParameters($form_state_set_error_by_name_spy, $parameters, 'setErrorByName');
    $this->assertTrue($method_invoked_with_valid_parameters, "Method setErrorByName was never called with valid parameters");
  }

In the assertFormElementCausesErrorMessageWithInvalidFormInput method above, you’ll notice a commented out section of code labeled “// 1”. I left this here for illustration purposes. At first when learning PHPUnit, I was expecting that I could simply use PHPUnit_Framework_TestCase::any, in conjunction with the PHPUnit_Framework_MockObject_Builder_InvocationMocker::with method, to indicate that I expected FormStateInterface::setErrorByName to be called with an expected user error message of “You didn’t guess anything!”.

However, in the current GuessForm::validateForm method, it’s very possible that the FormStateInterface::setErrorByName might be called more than once. And in fact, while it is called with the “You didn’t guess anything!” user error message at first, it is also called later on with the “You entered an incorrect answer to the question you were trying to guess.” user error message.

Utilizing PHPUnit_Framework_TestCase::any does allow for FormStateInterface::setErrorByName to be called any number of times. However, when PHPUnit_Framework_TestCase::any is used alongside PHPUnit_Framework_MockObject_Builder_InvocationMocker::with, the test will fail if FormStateInterface::setErrorByName is called with parameters that aren’t specified in the PHPUnit_Framework_MockObject_Builder_InvocationMocker::with call. So, using PHPUnit_Framework_TestCase::any alongside PHPUnit_Framework_MockObject_Builder_InvocationMocker::with works just fine if the first user error message was the only one that would be generated during form validation, but since the second one also is generated, the test fails.

(Similarly, as also shown in the commented out code (“// 1”), usage of PHPUnit_Framework_TestCase::exactly, PHPUnit_Framework_TestCase::once, or PHPUnit_Framework_TestCase::atLeastOnce does not make the test pass, and therefore are all undesirable in this case.)

Luckily, there’s a method within the PHPUnit mocking framework that allows us to see exactly how many times a given method was called, and with what parameters. This allows for much more finer grained control over what is asserted in the test regarding method calls. I first came across an introduction to the PHPUnit_Framework_MockObject_Matcher_InvokedRecorder::getInvocations method while reading this article.

In the assertFormElementCausesErrorMessageWithInvalidFormInput code above, we set an invocation spy ($form_state_set_error_by_name_spy) in the “// 2” section of code. This is the object that actually records the method calls to the mock FormStateInterface instance. In the “// 3” code section, we pass this invocation spy to the GeneralUnitTestHelperTrait::checkThatMethodCalledAtLeastOnceWithDesiredParameters method which we’ll look at below:

  protected function checkThatMethodCalledAtLeastOnceWithDesiredParameters(PHPUnit_Framework_MockObject_Matcher_InvokedRecorder $invocation_spy, array $desired_parameters, $desired_method_name)
  {    
    $invocations = $invocation_spy->getInvocations();
    $method_invoked_with_valid_parameters = false;

    foreach ($invocations as $invocation)
    {
      $invoked_method_name = $invocation->methodName;
      $invoked_parameters = $invocation->parameters;

      $parameters_invoked_but_not_desired = array_diff($invoked_parameters, $desired_parameters);
      $parameters_desired_but_not_invoked = array_diff($desired_parameters, $invoked_parameters);

      if (count($parameters_invoked_but_not_desired) === 0
          && count($parameters_desired_but_not_invoked) === 0
          && $invoked_method_name == $desired_method_name)
      {
        $method_invoked_with_valid_parameters = true;
        break;
      }      
    }

    return $method_invoked_with_valid_parameters;
  }

In the above code, the PHPUnit_Framework_MockObject_Matcher_InvokedRecorder::getInvocations call returns an array of PHPUnit_Framework_MockObject_Invocation_Object objects, each of which contains the method name that was called (stored in the “methodName” property), as well as an array of the parameters (stored in the “parameters” property) that were used in the method invocation. Basically, the GeneralUnitTestHelperTrait::checkThatMethodCalledAtLeastOnceWithDesiredParameters iterates all of the invocation objects and determines if any of those invocations are for the method name and method parameters that we are looking for. If any of those invocation objects fit the bill, then the method returns true, which ultimately makes the test pass. In this specific case, FormStateInterface::setErrorByName is actually called during the test with the parameters we want, so the test does pass.

Now that we’ve written all of our tests, we’ll cover how to actually run them, in case you’ve never utilized PHPUnit before.

Running PHPUnit Tests

PHPUnit comes baked into Drupal 8. This article pretty clearly illustrates how to setup and run the tests. Any unit tests for your module need to be located in the tests/src subdirectory of your module. I am putting the unit tests in their own specific directory, so the tests will ultimately reside at:
form_validation_unit_test_module/tests/src/Unit/

The unit test needs to have a doc block annotation specifying the group name prior to the class, so this code precedes the actual unit test class declaration:

/**
 * @group form_validation_unit_test_module
 */
class GuessFormValidateMethodTestCase extends FormValidationUnitTestCase 
...

To run the unit tests for this specific group, let’s start with the bash prompt in the Drupal root directory, and then go to the core directory by typing:

cd core

You can list all of the unit test groups by entering:

../vendor/bin/phpunit --list-groups

Then, to actually run the unit tests for our example here, we type:

../vendor/bin/phpunit --group form_validation_unit_test_module

You should be greeted with a wonderful, heart-warming message that looks like this:
Time: 11.06 seconds, Memory: 260.25Mb
OK (6 tests, 12 assertions)

That’s all there is to it!

Conclusion

We’ve just covered a use case for unit testing the FormInterface::validateForm method, which will typically be implemented on a FormBase subclass. Again, you should be able to incorporate the FormValidationUnitTestCase abstract class and GeneralUnitTestHelperTrait trait into your own module by changing any “form_validation_unit_test_module” namespace references to your own module, and updating the 3 abstract methods on FormValidationUnitTestCase to your own specific situation.

My Take On Unit Testing

In this article, all of the validation logic was entirely contained within the FormInterface::validateForm method implementation. Arguably, any business logic, including form validation logic, should not go in the form object itself, but in a business/model object (in the same light that business logic should not go in the view or controller layer of any MVC application).

However, it’s important to get the existing code under test before moving any of that logic from the FormInterface::validateForm method implementation to the model layer of the application. After the existing code is under test, then it’s possible to refactor as necessary, while being certain that nothing breaks in the process.

If you have never read a book called Working Effectively With Legacy Code by Michael Feathers, I strongly recommend you to do so. I see this book listed often as one of those “must reads” for software developers. I definitely second that motion, and honestly would consider reading it to be among the best “training” I have ever received as a professional developer.

One of the main focusses of this book is delving into techniques that can be used to place components of a legacy system into a unit test harness. After the target code section is “under test,” you can proceed with safely refactoring it or enhancing its functionality as necessary.

Taking the time to write unit tests, and also to write readable code, it not an expense, but an investment. The time invested in writing unit tests is paid back as soon as you need to make a change to enhance the functionality of the application (or have a change you should make a enhance the code clarity), by the confidence that nothing else in the application broke.

If you have a suite of unit tests, you can much more easily verify that nothing went awry after touching the code base, and it only takes a few seconds to make that verification. These steps will hopefully aid in preventing the need to take out a loan from the Federal Bank of Technical Debt.

 

Leave a Reply