Friday 12 March 2021

Vue.js and TDD: adding client-side form field validation

G'day:

In the previous article ("TDDing the reading of data from a web service to populate elements of a Vue JS component"), I started connecting my front-end form to the back-end web service that does its data-handling. The first step was easy: just fetching some data the form needed to display a multi-select:

The next step for the back-end will be to receive the form-field values, but before we do that we need to ensure we're doing a superficial level of value validation on the front-end. I say "superficial" because this sort of validation is only there to guide the user, not prevent any nefarious activity: any attempt to prevent nefarious activity on the front-end is too easy to circumvent by the baddy, so it's not worth sinking too much time into. The reallyreally validation needs to be done on the back-end, and that will be done in the next article.

The first TDD step is to identify a test case. I'm gonna cheat a bit here and list the cases I'm gonna address once, here. This is kinda getting ahead of myself, but it makes things clearer for the article. I'll only be addressing one of them at a time though. One thing to note with TDD: don't try to compile an exhaustive list of test cases first, then work through them: that'll just delay you getting on with it, and there's no need to identify every case before you start anyhow: sometimes that can seem like a daunting task in and of itself (not so much with this exercise, granted, as it's so trivial). Identify a case. Test it. Implement it. Identify the next case. Test it. Implement it. Maybe see if there's any refactoring that is bubbling-up; but probably wait until your third case for those refactoring opportunities to surface more clearly. Rinse and repeat. Eat that elephant one bite at a time.

Actually before I get on with the new validation cases, we've already got some covered:

  • should have a required text input for fullName, maxLength 100, and label 'Full name'
  • should have a required text input for phoneNumber, maxLength 50, and label 'Phone number'
  • should have a required text input for emailAddress, maxLength 320, and label 'Email address'
  • should have a required password input for password, maxLength 255, and label 'Password'
  • should have a required workshopsToAttend multiple-select box, with label 'Workshops to attend'
  • should list the workshop options fetched from the back-end
  • should have a button to submit the registration
  • should leave the submit button disabled until the form is filled
  • should disable the form and indicate data is processing when the form is submitted
  • should send the form values to WorkshopService.saveWorkshopRegistration when the form is submitted
  • should display the registration summary 'template' after the registration has been submitted
  • should display the summary values in the registration summary

We're at least validating requiredness and (max) string-length. And we're part way there with "should leave the submit button disabled until the form is filled", if we just update that to be suffixed with "… with valid data".

Now that I look at the form, I can only see one validation case we need to address: password strength: "it has a password that is at least 8 characters long, has at least one each of lowercase letter, uppercase letter, digit, and one other character not in those subsets (eg: punctuation etc)". One might think we need to validate that the email address is indeed an email address, but trying to get that 100% right is a mug's game, and anyhow there's no way in form field validation to check whether it's their email address - ie: they can receive email at it - anyhow. And we're not asking for an RFC-5322-compliant email address, we're asking for their email address. We need to trust that if they want to engage with us, they'll allow us to communicate with them. That said, I am at least going to change the input type to email for that field; first having updated the relevant test to expect it. This is a matter of semantics, not validation though. They can still type-in anything they like there.

For the password validation, I am going to test by example:

describe("Password-strength validation tests", () => {
    const examples = [
        {case: "cannot have fewer than 8 characters", password: "Aa1!567", valid: false},
        {case: "can have exactly 8 characters", password: "Aa1!5678", valid: true},
        {case: "can have more than 8 characters", password: "Aa1!56789", valid: true},
        {case: "must have at least one lowercase letter", password: "A_1!56789", valid: false},
        {case: "must have at least one uppercase letter", password: "_a1!56789", valid: false},
        {case: "must have at least one digit", password: "Aa_!efghi", valid: false},
        {case: "must have at least one non-alphanumeric character", password: "Aa1x56789", valid: false}
    ];

    examples.forEach((testCase) => {
        it(testCase.case, async () => {
            await populateForm(testCase.password);
            await flushPromises();

            let buttonDisabledAttribute = component.find("form.workshopRegistration button").attributes("disabled");
            let failureMessage = `${testCase.password} should be ${testCase.valid ? "valid" : "invalid"}`;

            testCase.valid
                ? expect(buttonDisabledAttribute, failureMessage).to.not.exist
                : expect(buttonDisabledAttribute, failureMessage).to.exist;
        });
    });
});

This:

  • specifies some examples (both valid and invalid examples);
  • loops over them;
  • populates the form with otherwise known-good values (so, all things being equal, the form can be submitted);
  • sets the password to be the test password;
  • tests whether the form can be submitted (which it only should be able to be if the password is valid).

I've just thought of another case: we better explain to the user why the form is only submittable when their password follows our rules. We'll deal with that once we get the functionality operational.

I've also refactored the populateForm function slightly:

let populateForm = async (password = VALID_PASSWORD) => {
    let form = component.find("form.workshopRegistration");
    form.find("input[name='fullName").setValue(TEST_INPUT_VALUE +"_fullName");
    form.find("input[name='phoneNumber").setValue(TEST_INPUT_VALUE +"_phoneNumber");
    form.find("input[name='emailAddress").setValue(TEST_INPUT_VALUE +"_emailAddress");
    form.find("input[name='password").setValue(password);
    form.find("select").setValue(TEST_SELECT_VALUE);
    await flushPromises();
};

Previously it was like this:

let populateForm = async () => {
    let form = component.find("form.workshopRegistration")
    form.findAll("input").forEach((input) => {
        let name = input.attributes("name");
        input.setValue(TEST_INPUT_VALUE + name);
    });
    form.find("select").setValue(TEST_SELECT_VALUE);

    await flushPromises();
};

I was being lazy and looping over the input fields setting dummy values, but now I wanted to set the password with a different sort of value I could not just set all the input elements; it was only the text and email ones. I could make my element selector more clever, or… I could just make the whole thing more explicit. Part of the reason for simplification was that I messed up the refactor of this three times trying to get the loop to hit only the correct inputs, so I decided just to keep it simple. Conveniently, this tweak also meant that the other tests would not start failing due to not being able to submit the form due to not having a valid password :-)

Cool so when I run my tests now, all the ones expecting the invalid passwords to prevent the form from being submitted now fail. The two cases with valid passwords do not fail, because they meet the current validation requirements already: they have length, and that's all we're validating. I'm currently thinking whether it's OK that we have passing test cases here already. I'm not sure. As long as they keep passing once we have the correct validation in, I guess that's all right.

The implementation here is easy, I just add a function that checks the password follows the rules, and then use that in the isFormUnready function instead of the length check:

computed : {
    isFormUnready: function () {
        let unready = this.formValues.fullName.length === 0
            || this.formValues.phoneNumber.length === 0
            || this.formValues.workshopsToAttend.length === 0
            || this.formValues.emailAddress.length === 0
            || this.formValues.password.length === 0;
            || !this.isPasswordValid;

        return unready;
    },
    // ...
    isPasswordValid: function () {
        const validPasswordPattern = new RegExp("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*\\W)(?:.){8,}$");
        return validPasswordPattern.test(this.formValues.password);
    }
}

Actually I had a sudden thought how I could feel more happy about those cases that are currently passing. I updated isPasswordValid to just return false for a moment, and verified those cases now failed. It seems pedantic and obvious, but it does demonstrate they're using the correct logic flow, and the behaviour is working. I could also have pushed the validation rule out to only accept say 10-character passwords, and note those cases failing, or something like that too.

Anyway, that implementation worked, and the tests all pass, and the UI behaviour also seems correct.

Next we need to address this other case I spotted: "it shows the user a message if the password value is not valid after they have typed one in". Although as I was writing the test, it occurred to me there are three cases to address here:

  • it does not show the user a message if the password value is valid after they have typed one in
  • it shows the user a message if the password value is not valid after they have typed one in
  • hides the previously displayed bad password message if the password is updated to be valid

The message will be implemented as an <aside> element, and the current password will be evaluated for validity when the keyup event fires. So it won't display until the user starts typing in the box; and will re-evaluate every keystroke. The code below shows the final rendition of these tests. Initially I had all the code inline for each of them, but each of them followed a similar sequence of the same two steps, so I extracted those:


it("does not show the user a message if the password value is valid after they have typed one in", async () => {
    confirmMessageVisibility(false);

    await enterAPassword(VALID_PASSWORD);
    confirmMessageVisibility(false);
});

it("shows the user a message if the password value is not valid after they have typed one in", async () => {
    confirmMessageVisibility(false);

    await enterAPassword(INVALID_PASSWORD);
    confirmMessageVisibility(true);
});

it("hides the previously displayed bad password message if the password is updated to be valid", async () => {
    confirmMessageVisibility(false);

    await enterAPassword(INVALID_PASSWORD);
    confirmMessageVisibility(true);

    await enterAPassword(VALID_PASSWORD);
    confirmMessageVisibility(false);
});

let enterAPassword = async function (password) {
    let passwordField = component.find("form.workshopRegistration input[type='password']");
    await passwordField.setValue(password);
    await passwordField.trigger("keyup");

    await flushPromises();
};

let confirmMessageVisibility = function (visible) {
    let messageField = component.find("form.workshopRegistration aside.passwordMessage");
    visible
        ? expect(messageField.exists(), "message should be visible").to.be.true
        : expect(messageField.exists(), "message should not be visible").to.be.false;
};
  • Each test confirms the message is not displayed to start with.
  • I mimic keying in a password by setting the value, then triggering a keyup event;
  • and once that's done, I check whether I can now see the message.
  • The last test checks first an invalid, then a valid password.

The implementation is really easy. Here's the template changes. I just added an event listener, and the <aside/>, which is set to be visible based on showPasswordMessage:

<input @keyup="checkPassword" type="password" name="password" required="required" maxlength="255" id="password" v-model="formValues.password" autocomplete="new-password">

<aside class="passwordMessage" v-if="showPasswordMessage">
    Password be at least eight characters long
    and must comprise at least one uppercase letter, one lowercase letter, one digit, and one other non-alphanumeric character
</aside>

And in the code part, we just check if the password is legit, and compute that variable accordingly:

checkPassword() {
    this.showPasswordMessage = !this.isPasswordValid;
}

That seems way too easy, but it all works. The tests pass, and it works on-screen too:

 

Haha. You know what? I wondered if I should test the text of the message as well, and went "nah, screw that". And now that I have that image in there, I see the typo in it. Ahem.

That will do for that lot. I quite enjoyed working out how to test the password changes there (how I trigger the keyup event. And doing stuff in Vue.js is pretty easy.

Tomorrow I'll go back to th web service end of things and do the back-end validation, which will need to be a lot more comprehensive than this. But it'll be easier to test as it's all PHP which I know better, and it's just value checking, not messing around with a UI.

Oh. The code for the two files I was working in is here, on Github:

Righto.

--
Adam