Showing posts with label Unit Testing. Show all posts
Showing posts with label Unit Testing. Show all posts

Tuesday 6 April 2021

TDD is not a testing strategy

TDD is not a testing strategy

TDD is not a testing strategy

TDD is not a testing strategy

TDD is not a testing strategy

TDD is not a testing strategy

TDD. Is. Not. A. Testing. Strategy.

Just a passing thought. Apropros to absolutely nothing. 'Onest guv.(*)

Dunno if it occurred to you, but that TDD thing? It's not a testing strategy. It's a design strategy.

Let's look at the name. In the name test-driven is a compound adjective: it just modifies the subject. The subject is development. It's about development. It's not about testing.

It's probably better described by BDD, although to me that's a documentation strategy, rather than a development (or testing) one. BDD is about arriving at the test cases (with the client), TDD is about implementing those cases.

The purpose of TDD is to define a process that leads you - as a developer - to pay more attention to the design of your code. It achieves this by forcing you to address the requirement as a set of needs (or cases), eg "it needs to return the product of the two operands". Then you demonstrate your understanding of the case by demonstrating what it is for the case to "work" (a test that when you pass 2 and 3 to the function it returns 6), and then you implement the code to address that case. Then you refine the case, refactor the implementation so it's "nicer", or move on to the next case, and cycle through that one. Rinse and repeat.

But all along the object of the exercise is to think about what needs to be done, break it into small pieces, and code just what's needed to implement the customer need. It also provides a firm foundation to be able to safely refactor the code once it's working. You know: the bit that you do to make your code actually good; rather than just settling for "doesn't break", which is a very low bar to set yourself.

That you end up with repeatable tests along the way is a by-product of TDD. Not the reason you're doing it. Although obviously it's key to offering that stability and confidence for the refactor phase.

Too many people I interact with when they're explaining why it's OK they don't do TDD [because reasons], fall back to the validity / longevity of the tests. It's… not about the tests. It's about how you design your solutions.

Lines of code are not a measure of productivity

Tangential to this, we all know that LOC are not a measure of productivity. There's not a uniform relationship between one line of code and another adjacent line of code. Or ten lines of code in one logic block that represent the implementation of a method are likely to represent less productivity burden than a single line of code nested 14-levels deep in some flow-control-logic monstrousity. We all know this. Not all lines of code are created equal. More is definitely not better. But fewer is also not intrinsically better. It's just an invalid metric.

So why is it that so many people are prepared to count the lines of code a test adds to the codebase as a rationalisation (note: not a justification, because it's invalid) as to why they don't have time to write that test? Or that the test represents an undue burden in the codebase. Why are they back to measuring productivity with LOC? Why won't they let it occur to them that - especially when employing TDD - the investment in the LOC for the test code reduces the investment in the LOC for the production code? And note I am not meaning this as a benefit that one only realises over time having amortised it over a long code lifespan. I mean on the first iteration of code<->test<->release, because the bouncing back and forth between each step there will be reduced. Even for code which this might (although probably won't) be the only iteration the production code sees.

It's just "measure twice, cut once" for code. Of course one doesn't have the limitation in code that one can only cut once; the realisation here needs to be that "measuring" takes really a lot less time than "cutting" when it comes to code.

In closing, if you are rationalising to me (or, in reality: to yourself) why you don't do TDD, and that rationalisation involves lines of code or how often code will be revisited, then you are not talking about TDD. You are pretty much just setting up a strawman to tilt at to make yourself feel better. Hopefully that tactic will seem a little more transparent to you now.

Design your code. Measure your code.

Righto.

--
Adam

(*) that's a lie. It's obviously a retaliation to a coupla comments I've been getting on recent articles I've written about TDD.

Sunday 4 April 2021

Unit testing: tests are not much bloody use if they always pass

G'day:

I started back on the next article of my VueJS / Symfony / etc series this morning. And now it's 18:24 and I've made zero progress. Well I've written a different blog article in the middle of that ("TDD & professionalism: a brief follow-up to Thoughts on Working Code podcast's Testing episode"), but that was basically just a procrastinary exercise, avoiding getting down to the other work.

I'm currently laughing (more "nervous giggling") at my mental juxtaposition of "TDD & professionalism" from that earlier article and the title of this one. I'm not feeling very professional round about now.

OK so I sat down to get cracking on this new article, and the first thing I did was re-run my tests to make sure they were all still working. This is largely due to some issues I had with the Vue Test Utils library about a week ago, which I will discuss in that next article. Anyhow, everything was green. All good.

Next I opened my Vue component file, and remembered a slight tweak I wanted to make to my code. I have this (in WorkshopRegistrationForm.vue):

submitButtonLabel: function() {
    return this.registrationState === REGISTRATION_STATE_FORM ? "Register" : "Processing&hellip;";
},

I'm not in love with those hard-coded strings there; I want to extract them and use named constants instead (same as with the form-state constants I already have there).

The first thing I did was to locate the test for when the button switches to "Processing…", and update it to be broken so I can expect the change. Basically I figured I change the label to be something different, see the test fail, update the code to use a constant with the "different" value, see the tests pass, and then change the test back to expect the "Processing…" value, and then the const value in the code. Sometimes that's all a test change that's needed. And in hindsight I'm glad I did it.

The test method is thus (from test/unit/workshopRegistration.spec):

it("should disable the form and indicate data is processing when the form is submitted", () => {
    component.vm.$watch("workshops", async () => {
        await flushPromises();
        let lastLabel;
        component.vm.$watch("submitButtonLabel", (newValue) => {
            lastLabel = newValue;
        });

        let lastFormState;
        component.vm.$watch("isFormDisabled", (newValue) => {
            lastFormState = newValue;
        });

        await submitPopulatedForm();

        expect(lastLabel).to.equal("Processing&hellip;");
        expect(lastFormState).to.be.true;
    });
});

The line in question is that second to last expectation, and I just changed it to be:

expect(lastLabel).to.equal("Processing&hellip;TEST_WILL_FAIL");

And I ran the test:

 DONE  Compiled successfully in 3230ms

  [=========================] 100% (completed)

 WEBPACK  Compiled successfully in 3230ms

 MOCHA  Testing...



  Testing WorkshopRegistrationForm component
    Testing form submission
       should disable the form and indicate data is processing when the form is submitted


  1 passing (52ms)

 MOCHA  Tests completed successfully

root@9b1e15054be3:/usr/share/fullstackExercise#

Umm… hello?

Note: in the real situation I ran all the tests. It's not just a case of me running the wrong test or something completely daft. Although bear with me, there's def some daftness to come.

I did some fossicking around and putting some console.log entries about the place, and narrowed it down to how I had "fixed" these tests the last time I had issues with them. Previously the tests were running too quickly, and the Workshop listing had not been returned from the remote call in time for the test to try to submit the form, and any tests that relied on filling-out the form went splat cos there were not (yet) and workshops to select. OKOK, hang on this is what I'm talking about:

Those come from a remote call, so the data arrives asynchronously.

My fix was this bit in that test:

it("should disable the form and indicate data is processing when the form is submitted", () => {
    component.vm.$watch("workshops", async () => {
        await flushPromises();
        //...
    });
});

I was being "clever" and watching for when the workshops data finally arrived, waited for the options to populate, then we're good to run the test code. A whole bunch of the tests needed this. Now I hasten to add that I did thoroughly test this strategy when I updated all the tests. I made them all fail one of their expectations, watched the tests fail, then fixed the assertions and watched them pass. It's not like I made this change and just went "yeah that (will) work OK on my machine".

So what was the problem? Can you guess? Looking now, the tests do make a certain assumption.

Well. So my original issue was the code I was testing was running slow, so I changed the tests to wait for a change, and then run. And last week I tweaked my Docker settings to speed up all my containers. Now the code isn't slow. So now the workshops data is already loaded before the test code gets to that watch. So… there's nothing to watch. I started watching too late. I proved this to myself by slowing the remote call down again, and suddenly the tests started working again (ie: that test started to fail like I wanted it to).

It occurred to me then I had solved the original issue the wrong way. I was thinking synchronously about an asynchronous problem. I can't know if the data will arrive before or after my test runs. Just that at some time it is promised to arrive. Aha!

The data was already coming back in a promise (from WorkshopDAO.js):

selectAll() {
    return this.client.get(this.config.workshopsUrl)
        .then((response) => {
            return response.data;
        });
}

The problem is that by the time it bubbles back through DAO › Repository › Service › Component, I'd ditched the promise and just waited for the value (WorkshopRegistrationForm.vue):

async mounted() {
    this.workshops = await this.workshopService.getWorkshops();
},

And I needed that this.workshops to just be the eventual array of objects, becauseI have a v-for looping over it. And v-for ain't clever enough to take the promise of an array, it needs the actual array (this is from the same file, just further up at line 82):

<option v-for="workshop in workshops" :value="workshop.id" :key="workshop.id">{{workshop.name}}</option>

I knew what I needed to do in the test. Instead of the watch, I just needed to append another then handler to the promise. Then whether or not the data has arrived back yet, the handler would run either straight away or once the data got there. But how do I get hold of that promise?

In the end I cheated: (again, same file, but a new version of it):

data() {
    return {
        //...
        promisedWorkshops: null,
        workshops: [],
        //...
    };
},
//...
async mounted() {
    this.promisedWorkshops = this.workshopService.getWorkshops();
    this.workshops = await this.promisedWorkshops;
},

I put the promise into the component's data as well as the values :-)

And the test becomes(from test/unit/workshopRegistration.spec again):

it("should disable the form and indicate data is processing when the form is submitted", async () => {
    component.vm.$watch("workshops", async () => {
    await component.vm.promisedWorkshops.then(async () => {
        await component.vm.$nextTick();

As I said about I just slap all the code in a then handler instead of a watch callback. The rest of the code is the same. I need to wait that tick because the options don't render until the next Vue-tick after the data arrives.

That's a much more semantically-appropriate (and less hacky) way of addressing this issue. I'm reasonably pleased with that as a solution. For now.

Having learned my lesson I went back and retested everything in both a broken and working state, with an instant response time, and a very delayed response time on the remote call. The tests seem stable now.

Until I find the next thing wrong with them, anyhow.

OK that's enough staring at code on the screen for the day. I'm gonna stare at a game on the screen instead now.

Righto.

--
Adam

TDD & professionalism: a brief follow-up to Thoughts on Working Code podcast's Testing episode

G'day:

Yer gonna need to go back and read the comments on Thoughts on Working Code podcast's Testing episode for context here. Especially as I quote a couple of them. I kinda left the comments run themselves there a bit and didn't reply to everyone as I didn't want to dominate the conversation. But one earlier comment that made me itchy, and now one comment that came in in the last week or so, have made me decide to - briefly - follow-up one point that I think warrants drawing attention to and building on.

Briefly, Working Code Pod did an episode on testing, and I got all surly about some of the things that were said, and wrote them up in the article I link to above. BTW Ben's reaction to my feedback in their follow-up episode ("Listener Questions #1) was the source of my current strapline quote: "the tone... it sounds very heated and abrasive". That should frame things nicely.

Right so in the comments to that previous article, we have these discussion fragments:

  • Sean Corfield - Heck, there's still a sizable portion that still doesn't use version control or has some whacked-out manual approach to "source code control".
  • Ben replied to that with - Yikes, I have trouble believing that there are developers _anywhere_ that don't use source-control in this day-and-age. That seems like "table stakes" for development. Period.
  • [off-screen, Adam bites his tongue]
  • Then Sean Hogge replied to the article rather than that particular comment thread. I'm gonna include a chunk of what he said here:

    18 months ago, I was 100% Ben-shaped. Testing simply held little ROI. I have a dev server that's a perfect replica of production, with SSL and everything. I can just log in, open the dashboard, delete the cache and check things with a few clicks.

    But as I started developing features that interact with other features, or that use the same API call in different ways, or present the same data with a partial or module with options, I started seeing an increase in production errors. I could feel myself scrambling more and more. When I stepped back and assessed objectively, tests were the only efficient answer.

    After about 3 weeks of annoying, frustrating, angry work learning to write tests, every word of Adam C's blog post resonates with me. I am not good at it (yet), I am not fast at it (yet), but it is paying off exactly as he and those he references promised it would.

    I recommend reading his entire comment, because it's bloody good.

  • Finally last week I listened to a YouTube video "Jim Coplien and Bob Martin Debate TDD", from which I extracted these two quotes from Martin that drew me back to this discussion:
    • (@ 43sec) My thesis is that it has become infeasible […] for a software developer to consider himself professional if [(s)he] does not practice test-driven development.
    • (@ 14min 42sec) Nowadays it is […] irresponsible for a developer to ship a line of code that [(s)he] has not executed any unit test [upon].. It's important to note that "nowadays" being 2012 in this case: that's when the video was from.
    And, yes, OK the two quotes say much the same thing. I just wanted to emphasise the words "professional" and "irresponsible".

This I think is what Ben is missing. He shows incredulity that someone in 2021 would not use source control. People's reaction is going to be the same to his suggestion he doesn't put much focus on test-automatic, or practise TDD as a matter of course when he's designing his code. And Sean (Hogge) nails it for me.

(And not just Ben. I'm not ragging on him here, he's just the one providing the quote for me to start from).

TDD is not something to be framed in a context alongside other peripheral things one might pick up like this week's kewl JS framework, or Docker or some other piece of utility one might optionally use when solving a client's problem. It's lower level than that, so it's false equivalence to bracket it like that conceptually. Especially as a rationalisation for not addressing your shortcomings in this area.

Testing yer code and using TDD is as fundamental to your professional responsibilities as using source control. That's how one ought to contextualise this. Now I know plenty of people who I'd consider professional and strong pillars of my dev community who aren't as good as they could be with testing/TDD. So I think Martin's first quote is a bit strong. However I think his second quote nails it. If you're not doing TDD you are eroding your professionalism, and you are being professionalbly irresponsible by not addressing this.

In closing: thanks to everyone for putting the effort you did into commenting on that previous article. I really appreciate the conversation even if I didn't say thanks etc to everyone participating.

Righto.

--
Adam

Wednesday 10 March 2021

TDDing the reading of data from a web service to populate elements of a Vue JS component

G'day:

This article leads on from "Symfony & TDD: adding endpoints to provide data for front-end workshop / registration requirements", which itself continues a saga I started back with "Creating a web site with Vue.js, Nginx, Symfony on PHP8 & MariaDB running in Docker containers - Part 1: Intro & Nginx" (that's a 12-parter), and a coupla other intermediary articles also related to this body of work. What I'm doing here specifically leads on from "Vue.js: using TDD to develop a data-entry form". In that article I built a front-end Vue.js component for a "workshop registration" form, submission, and summary:


Currently the front-end transitions work, but it's not connected to the back-end at all. It needs to read that list of "Workshops to attend" from the DB, and it needs to save all the data between form and summary.

In the immediately previous article I set up the endpoint the front end needs to call to fetch that list of workshops, and today I'm gonna remove the mocked data the form is using, and get it to use reallyreally data from the DB, via that end point.

To recap, this is what we currently have:

In the <template/> section of WorkshopRegistrationForm.vue:

<label for="workshopsToAttend" class="required">Workshops to attend:</label>

<select name="workshopsToAttend[]" multiple="true" required="required" id="workshopsToAttend" v-model="formValues.workshopsToAttend">
    <option v-for="workshop in workshops" :value="workshop.value" :key="workshop.value">{{workshop.text}}</option>
</select>

And in the code part of it:

mounted() {
      this.workshops = this.workshopService.getWorkshops();
},

And WorkshopService just has some mocked data:

getWorkshops() {
    return [
        {value: 2, text:"Workshop 1"},
        {value: 3, text:"Workshop 2"},
        {value: 5, text:"Workshop 3"},
        {value: 7, text:"Workshop 4"}
    ];
}

In this exercise I will be taking very small increments in my test / code / test / code cycle here. It's slightly arbitrary, but it's just because I like to keep separate the "refactoring" parts from the "new development parts", and I'll be doing both. The small increments help surface problems quickly, and also assist my focus so that I'm less likely to stray off-plan and start implementing stuff that is not part of the increment's requirements. This might, however, make for tedious reading. Shrug.

We're gonna refactor things a bit to start with, to push the mocked data closer to the boundary between the application and the DB. We're gonna follow a similar tiered application approach as the back-end work, in that we're going to have these components:

WorkshopRegistrationForm.vue
The Vue component providing the view part of the workshop registration form.
WorkshopService
This is the boundary between WorkshopRegistrationForm and the application code. The component calls it to get / process data. In this exercise, all it needs to do is to return a WorkshopCollection.
WorkshopCollection
This is the model for the collection of Workshops we display. It loads its data via WorkshopRepository
Workshop
This is the model for a single workshop. Just an ID and name.
WorkshopsRepository
This deals with fetching data from storage, and modelling it for the application to use. It knows about the application domain, and it knows about the DB schema. And translates between the two.
WorkshopsDAO
Because the repository has logic in it that we will be testing, our code that actually makes the calls to the DB connector has been extracted into this DAO class, so that when testing the repository it can be mocked-out.
Client
This is not out code. We're using Axios to make calls to the API, and the DAO is a thin layer around this.

As a first step we are going to push that mocked data back to the repository. We can do this without having to change our tests or writing any data manipulation logic.

Here's the current test run:

  Tests of WorkshopRegistrationForm component
     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 have to keep that green, and all we can do is move code around. No new code for now (other than a wee bit of scaffolding to let the app know about the new classes). Here are all the first round of changes.

Currently the deepest we go in the new code is the repository, which just returns the mocked data at the moment:

class WorkshopsRepository {

    selectAll() {
        return [
            {value: 2, text:"Workshop 1"},
            {value: 3, text:"Workshop 2"},
            {value: 5, text:"Workshop 3"},
            {value: 7, text:"Workshop 4"}
        ];
    }
}

export default WorkshopsRepository;

The WorkshopCollection grabs that data and uses it to populate itself. It extends the native array class so can be used as an array by the Vue template code:

class WorkshopCollection extends Array {

    constructor(repository) {
        super();
        this.repository = repository;
    }

    loadAll() {
        let workshops = this.repository.selectAll();

        this.length = 0;
        this.push(...workshops);
    }
}

module.exports = WorkshopCollection;

And the WorkshopService has had the mocked values removed, an in their place it tells its WorkshopCollection to load itself, and it returns the populated collection:

class WorkshopService {

    constructor(workshopCollection) {
        this.workshopCollection = workshopCollection;
    }

    getWorkshops() {
        this.workshopCollection.loadAll();
        return this.workshopCollection;
    }

    // ... rest of it unchanged...
}

module.exports = WorkshopService;

WorkshopRegistrationForm.vue has not changed, as it still receives an array of data to its this.workshops property, which is still used in the same way by the template code:

<template>
    <form method="post" action="" class="workshopRegistration" v-if="registrationState !== REGISTRATION_STATE_SUMMARY">
        <fieldset :disabled="isFormDisabled">

            <!-- ... -->

            <select name="workshopsToAttend[]" multiple="true" required="required" id="workshopsToAttend" v-model="formValues.workshopsToAttend">
                <option v-for="workshop in workshops" :value="workshop.value" :key="workshop.value">{{workshop.text}}</option>
            </select>

            <!-- ... -->

        </fieldset>
    </form>

    <!-- ... -->
</template>

<script>
// ...

export default {
    // ...
    mounted() {
        this.workshops = this.workshopService.getWorkshops();
    },
    // ...
}
</script>

Before we wire this up, we need to change our test to reflect where the data is coming from now. It used to be just stubbing the WorkshopService's getWorkshops method, but now we will need to be mocking the WorkshopRepository's selectAll method instead:

workshopService = new WorkshopService();
sinon.stub(workshopService, "getWorkshops").returns(expectedOptions);
sinon.stub(repository, "selectAll").returns(expectedOptions);

The tests now break as we have not yet implemented this change. We need to update App.vue, which has a bit more work to do to initialise that WorkshopService now, with its dependencies:

<template>
    <workshop-registration-form></workshop-registration-form>
</template>

<script>
import WorkshopRegistrationForm from "./WorkshopRegistrationForm";
import WorkshopService from "./WorkshopService";
import WorkshopCollection from "./WorkshopCollection";
import WorkshopsRepository from "./WorkshopsRepository";

let workshopService = new WorkshopService(
    new WorkshopCollection(
        new WorkshopsRepository()
    )
);

export default {
    name: 'App',
    components: {
        WorkshopRegistrationForm
    },
    provide: {
        workshopService: workshopService
    }
}
</script>

Having done this, the tests are passing again.

Next we need to deal with the fact that so far the mocked data we're passing around is keyed on how it is being used in the <option> tags it's populating:

return [
    {value: 2, text:"Workshop 1"},
    {value: 3, text:"Workshop 2"},
    {value: 5, text:"Workshop 3"},
    {value: 7, text:"Workshop 4"}
]

value and text are derived from the values' use in the mark-up, whereas what we ought to be mocking is an array of Workshop objects, which have keys id and name:

class Workshop {

    constructor(id, name) {
        this.id = id;
        this.name = name;
    }
}

module.exports = Workshop;

We will need to update our tests to expect this change:

sinon.stub(workshopService, "getWorkshops").returns(expectedOptions);
sinon.stub(repository, "selectAll").returns(
    expectedOptions.map((option) => new Workshop(option.value, option.text))
);

The tests fail again, so we're ready to change the code to make the tests pass. The repo now returns objects:

class WorkshopsRepository {

    selectAll() {
        return [
            new Workshop(2, "Workshop 1"),
            new Workshop(3, "Workshop 2"),
            new Workshop(5, "Workshop 3"),
            new Workshop(7, "Workshop 4")
        ];
    }
}

And we need to make a quick change to the stubbed saveWorkshopRegistration method in WorkshopService too, so the selectedWorkshops are now filtered on id, not value:

saveWorkshopRegistration(details) {
    let allWorkshops = this.getWorkshops();
    let selectedWorkshops = allWorkshops.filter((workshop) => {
        return details.workshopsToAttend.indexOf(workshop.name) >= 0;
        return details.workshopsToAttend.indexOf(workshop.id) >= 0;
    });
    // ...

And the template now expects them:

<option v-for="workshop in workshops" :value="workshop.value" :key="workshop.value">{{workshop.text}}</option>
<option v-for="workshop in workshops" :value="workshop.id" :key="workshop.id">{{workshop.name}}</option>

I almost forgot about this, but the summary section also uses the WorkshopCollection data, and currently it's also coded to expect mocked workshops with value/text properties, rather than id/name one. So the test needs updating:

it("should display the summary values in the registration summary", async () => {
    const summaryValues = {
        registrationCode : "TEST_registrationCode",
        fullName : "TEST_fullName",
        phoneNumber : "TEST_phoneNumber",
        emailAddress : "TEST_emailAddress",
        workshopsToAttend : [{value: "TEST_workshopToAttend_VALUE", text:"TEST_workshopToAttend_TEXT"}]
        workshopsToAttend : [{id: "TEST_workshopToAttend_ID", name:"TEST_workshopToAttend_NAME"}]
    };
    
	// ...

    let ddValue = actualWorkshopValue.find("ul>li");
    expect(ddValue.exists()).to.be.true;
    expect(ddValue.text()).to.equal(expectedWorkshopValue[0].name);
    expect(ddValue.text()).to.equal(expectedWorkshopValue[0].value);

	// ...
});

And the code change in the template:

<li v-for="workshop in summaryValues.workshopsToAttend" :key="workshop.value">{{workshop.text}}</li>
<li v-for="workshop in summaryValues.workshopsToAttend" :key="workshop.id">{{workshop.name}}</li>

And the tests pass again.

Now we will introduce the WorkshopsDAO with the data that it will get back from the API call mocked. It'll return this to the WorkshopsRepository which will implement the modelling now:

class WorkshopDAO {

    selectAll() {
        return [
            {id: 2, name: "Workshop 1"},
            {id: 3, name: "Workshop 2"},
            {id: 5, name: "Workshop 3"},
            {id: 7, name: "Workshop 4"}
        ];
    }
}

export default WorkshopDAO;

We also now need to update the test initialisation to pass a DAO instance into the repository, and for now we can remove the Sinon stubbing because the DAO is appropriately stubbed already:

let repository = new WorkshopsRepository();
sinon.stub(repository, "selectAll").returns(
    expectedOptions.map((option) => new Workshop(option.value, option.text))
);

workshopService = new WorkshopService(
    new WorkshopCollection(
        repository
        new WorkshopsRepository(
            new WorkshopsDAO()
        )
    )
);

That's the only test change here (and the tests now break). We'll update the code to also pass-in a DAO when we initialise WorkshopService's dependencies, and also implement the modelling code in the WorkshopRepository class's selectAll method:

let workshopService = new WorkshopService(
    new WorkshopCollection(
        new WorkshopsRepository()
        new WorkshopsRepository(
            new WorkshopsDAO()
        )
    )
);
class WorkshopRepository {

    constructor(dao) {
        this.dao = dao;
    }

    selectAll() {
        return this.dao.selectAll().map((unmodelledWorkshop) => {
            return new Workshop(unmodelledWorkshop.id, unmodelledWorkshop.name);
        });
    }
}

We're stable again. The next bit of development is the important bit: add the code in the DAO that actually makes the DB call. We will be changing the DAO to be this:

class WorkshopDAO {

    constructor(client, config) {
        this.client = client;
        this.config = config;
    }

    selectAll() {
        return this.client.get(this.config.workshopsUrl)
            .then((response) => {
                return response.data;
            });
    }
}

export default WorkshopDAO;

And that Config class will be this:

class Config {
    static workshopsUrl = "http://fullstackexercise.backend/workshops/";
}

module.exports = Config;

Now. Because Axios's get method returns a promise, we're going to need to cater to this in the up-stream methods that need manipulate the data being promised. Plus, ultimately, WorkshopRegistration.vue's code is going to receive a promise, not just the raw data. IE, this code:

mounted() {
    this.workshops = this.workshopService.getWorkshops();
    this.workshops = this.workshopService.getWorkshops()
        .then((workshops) => {
            this.workshops = workshops;
        });
},

A bunch of the tests rely on the state of the component after the data has been received:

  • 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

In these tests we are gonna have to put the test code within a watch handler, so it waits until the promise returns the data (and accordingly workshops gets set, and the watch-handler fires) before trying to test with it, eg:

it("should list the workshop options fetched from the back-end", () => {
    component.vm.$watch("workshops", async () => {
    	await flushPromises();
        let options = component.findAll("form.workshopRegistration select[name='workshopsToAttend[]']>option");

        expect(options).to.have.length(expectedOptions.length);
        options.forEach((option, i) => {
            expect(option.attributes("value"), `option[${i}] value incorrect`).to.equal(expectedOptions[i].value.toString());
            expect(option.text(), `option[${i}] text incorrect`).to.equal(expectedOptions[i].text);
        });
    });
});

Other than that the tests shouldn't need changing. Also now that the DAO is not itself a mock as it was in the last iteration, we need to go back to mocking it again, this time to return a promise of things to come:

sinon.stub(dao, "selectAll").returns(Promise.resolve(
    expectedOptions.map((option) => ({id: option.value, name: option.text}))
));

It might seem odd that we are making changes to the DAO class but we're not unit testing those changes: the test changes here are only to accommodate the other objects' changes from being synchronous to being asynchrous. Bear in mind that these are unit tests and the DAO only exists as a thin wrapper around the Axios Client object, and its purpose is to give us some of our code that we can mock to prevent Axios from making an actual API call when we test the objects above it. We'll do a in integration test of the DAO separately after we deal with the unit testing and implementation.

After updating those tests to deal with the promises, the tests collapse in a heap, but this is to be expected. We'll make them pass again by bubbling-back the promise from returned from the DAO.

For the code revisions to the other classes I'm just going to show an image of the diff between the files from my IDE. I hate using pictures of code, but this is the clearest way of showing the diffs. All the actual code is on Github @ frontend/src/workshopRegistration

Here in the repository we need to use the values from the promise, so we need to wait for them to be resolved.

Similarly with the collection, service, and component (in two places), as per below:



It's important to note that the template code in the component did not need changing at all: it was happy to wait until the promise resolved before rendering the workshops in the select box.

Having done all this, the tests pass wonderfully, but the UI breaks. Doh! But in an "OK" way:

 


Entirely fair enough. I need to send that header back with the response.

class WorkshopControllerTest extends TestCase
{

    private WorkshopsController $controller;
    private WorkshopCollection $collection;

    protected function setUp(): void
    {
        $this->collection = $this->createMock(WorkshopCollection::class);
        $this->controller = new WorkshopsController($this->collection);
    }

    /**
     * @testdox It makes sure the CORS header returns with the origin's address
     * @covers ::doGet
     */
    public function testDoGetSetsCorsHeader()
    {
        $testOrigin = 'TEST_ORIGIN';
        $request = new Request();
        $request->headers->set('origin', $testOrigin);

        $response = $this->controller->doGet($request);

        $this->assertSame($testOrigin, $response->headers->get('Access-Control-Allow-Origin'));
    }
}
class WorkshopsController extends AbstractController
{

    // ...

    public function doGet(Request $request) : JsonResponse
    {
        $this->workshops->loadAll();

        $origin = $request->headers->get('origin');
        return new JsonResponse(
            $this->workshops,
            Response::HTTP_OK,
            [
                'Access-Control-Allow-Origin' => $origin
            ]
        );
    }
}

That sorts it out. And… we're code complete.

But before I finish, I'm gonna do an integration test of that DAO method, just to automate proof that it does what it needs do. I don't count "looking at the UI and going 'seems legit'" as testing.

import {expect} from "chai";
import Config from "../../src/workshopRegistration/Config";
import WorkshopsDAO from "../../src/workshopRegistration/WorkshopsDAO";
const client = require('axios').default;

describe("Integration tests of WorkshopsDAO", () => {

    it("returns the correct records from the API", async () => {
        let directCallObjects = await client.get(Config.workshopsUrl);

        let daoCallObjects = await new WorkshopsDAO(client, Config).selectAll();

        expect(daoCallObjects).to.eql(directCallObjects.data);
    });
});

And that passes.

OK we're done here. I learned a lot about JS promises in this exercise: I hid a lot of it from you here, but working out how to get those tests working for async stuff coming from the DAO took me an embarassing amount of time and frustration. Once I nailed it it all made sense though, and I was kinda like "duh, Cameron". So that's… erm… good…?

The next thing we need to do is to sort out how to write the data to the DB now. But before we do that, I'm feeling a bit naked without having any data validation on either the form or on the web service. Well: the web service end of things doesn't exist yet, but I'm going to start that work with putting some expected validation in. But I'll put some on the client-side first. As with most of the stuff in this current wodge of work: I do not have the slightest idea of how to do form validation with Vue.js. But tomorrow I will start to find out (see "Vue.js and TDD: adding client-side form field validation" for the results of that). Once again, I have a beer appointment to get myself too for now.

Righto.

--
Adam

Thursday 4 March 2021

Kahlan: getting it working with Symfony 5 and generating a code coverage report

G'day:

This is not the article I intended to write today. Today (well: I hoped to have it done by yesterday, actually) I had hoped to be writing about my happy times doing using TDD to implement a coupla end-points I need in my Symfony-driven web service. I got 404 (chuckle) words into that and then was blocked by trying to get Kahlan to play nice for about five hours (I did have dinner in that time too, but it was add my desk, and with a scowl on my face googling stuff). And that put me at 1am so I decided to go to bed. I resumed today an hour or so ago, and am just now in the position to get going again. But I've decided to document that lost six hours first.

I sat down to create a simple endpoint to fetch some data, and started by deciding on my first test case, which was "It needs to return a 200-OK status for GET requests on the /workshops endpoint". I knew Symfony did some odd shenanigans to be able to functionally test right from a route slug rather than having tests directly instantiating controller classes and such. I checked the docs and all this is done via an extension of PHPUnit, using WebTestCase. But I don't wanna use PHPUnit for this. Can you imagine my test case name? Something like: testItNeedsToReturnA200OKStatusForGetRequestsOnTheWorkshopsEndpoint. Or I could break PSR-12/PSR-1 and make it (worse) with test_it_needs_to_Return_a_200_OK_status_for_get_requests_on_the_workshops_endpoint (this is why I will never use phpspec). Screw that. I'm gonna work out how to do these Symfony WebTestCase tests in Kahlan.

Not so fast mocking PHPUNit there, Cameron

2021-03-06

Due to some - probably show-stopping - issues I'm seeing with Kahlan, I have been looking at PHPUnit some more. I just discovered the textdox reporting functionality it has, which makes giving test case names much clearer.

/** @testdox Tests the /workshops endpoint methods */
class workshopsEndPointTet {
    /** @testdox it needs to return a 200-OK status for GET requests */
    function testReturnStatus() {}
}

This will output in the test runs as:

Perfect. I still prefer the style of code Kahlan uses, but… this is really good to know.

First things first, I rely heavily on PHPUnit's code coverage analysis, so I wanted to check out Kahan's offering. The docs seem pretty clear ("Code Coverage"), and seems I just want to be using the lcov integration Kahlan offers, like this:

vendor/bin/kahlan --lcov="var/tmp/lcov/coverage.info"
genhtml --output-directory public/lcov/ var/tmp/lcov/coverage.info

I need to install lcov first via my Dockerfile:

RUN apt-get install lcov --yes

OK so I did all that, and had a look at the report:

Pretty terrible coverage, but it's working. Excellent. But drilling down into the report I see this:

>

This is legit reporting because I have no tests for the Kernel class, but equally that class is generated by Symfony and I don't want to cover that. How do I exclude it from code coverage analysis? I'm looking for Kahlan's equivalent of PHPUnit's @codeCoverageIgnore. There's nothing in the docs, and all I found was a passing comment against an issue in Github asking the same question I was: "Exclude a folder in code coverage #321". The answer is to do this sort of thing to my kahlan-config.php file:

use Kahlan\Filter\Filters;
use Kahlan\Reporter\Coverage;
use Kahlan\Reporter\Coverage\Driver\Xdebug;

$commandLine = $this->commandLine();
$commandLine->option('no-header', 'default', 1);

Filters::apply($this, 'coverage', function($next) {
    if (!extension_loaded('xdebug')) {
        return;
    }
    $reporters = $this->reporters();
    $coverage = new Coverage([
        'verbosity' => $this->commandLine()->get('coverage'),
        'driver'    => new Xdebug(),
        'path'      => $this->commandLine()->get('src'),
        'exclude'   => [
            'src/Kernel.php'
        ],
        'colors'    => !$this->commandLine()->get('no-colors')
    ]);
    $reporters->add('coverage', $coverage);
});

That seems a lot of messing around to do something that seems like it should be very simple to me. I will also note that Kahlan - currently - has no ability to suppress code coverage at a method or code-block level either (see "Skip individual functions in code coverage? #333"). This is not a deal breaker for me in this work, but it would be a show-stopper on any of the codebases I have worked on in the last decade, as they've all been of dubious quality, and all needed some stuff to be actively "overlooked" as they're not testable as they currently stand, and we (/) like my baseline code coverage report to have 100% coverage reported, and be entirely green. This is so if any omissions creep in, they're easy to spot (see "Yeah, you do want 100% test coverage"). Anyway, I'll make that change and omit Kernel from analysis:

root@13038aa90234:/usr/share/fullstackExercise# vendor/bin/kahlan --lcov="var/tmp/lcov/coverage.info"

.................                                                 17 / 17 (100%)



Expectations   : 51 Executed
Specifications : 0 Pending, 0 Excluded, 0 Skipped

Passed 17 of 17 PASS in 0.527 seconds (using 7MB)

Coverage Summary
----------------

Total: 33.33% (1/3)

Coverage collected in 0.001 seconds


root@13038aa90234:/usr/share/fullstackExercise# genhtml --output-directory public/lcov/ var/tmp/lcov/coverage.info
Reading data file var/tmp/lcov/coverage.info
Found 2 entries.
Found common filename prefix "/usr/share/fullstackExercise"
Writing .css and .png files.
Generating output.
Processing file src/MyClass.php
Processing file src/Controller/GreetingsController.php
Writing directory view page.
Overall coverage rate:
  lines......: 33.3% (1 of 3 lines)
  functions..: 50.0% (1 of 2 functions)
root@13038aa90234:/usr/share/fullstackExercise#

And the report now doesn't mention Kernel:

Cool.

Now to implement that test case. I need to work out how to run a Symfony request without using WebTestCase. Well I say "I need to…" I mean I need to google someone else who's already done it, and copy them. I have NFI how to do it, and I'm not prepared to dive into Symfony code to find out how. Fortunately someone has already cracked this one: "Functional Test Symfony 4 with Kahlan 4". It says "Symfony 4", but I'll check if it works on Symfony 5 too. I also happened back to the Kahlan docs, and they mention the same guy's solution ("Integration with popular frameworks › Symfony"). This one points to a library to encapsulate it (elephantly/kahlan-bundle), but that is actively version-constrained to only Symfony 4. Plus it's not seen any work since 2017, so I suspect it's abandoned.

Anyway, back to samsonasik's blog article. It looks like this is the key bit:

$this->request = Request::createFromGlobals();
$this->kernel  = new Kernel('test', false);
$request = $this->request->create('/lucky/number', 'GET');
$response = $this->kernel->handle($request);

That's how to create a Request and get Symfony's Kernel to run it. Easy. Hopefully. Let's try it.

namespace adamCameron\fullStackExercise\spec\functional\Controller;

use adamCameron\fullStackExercise\Kernel;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

describe('Tests of GreetingsController', function () {

    beforeAll(function () {
        $this->request = Request::createFromGlobals();
        $this->kernel  = new Kernel('test', false);
    });

    describe('Tests of doGet', function () {
        it('returns a JSON greeting object', function () {
            $testName = 'Zachary';

            $request = $this->request->create("/greetings/$testName", 'GET');
            $response = $this->kernel->handle($request);

            expect($response->getStatusCode())->toBe(Response::HTTP_OK);
        });
    });
});

I'm not getting too ambitious here, and it's not addressing the entire test case yet. I'm just making the request and checking its response status code.

And this just goes splat:

root@13038aa90234:/usr/share/fullstackExercise# vendor/bin/kahlan --lcov="var/tmp/lcov/coverage.info" --ff

E                                                                 18 / 18 (100%)


Tests of GreetingsController
  Tests of doGet
    ✖ it returns a JSON greeting object
      an uncaught exception has been thrown in `vendor/symfony/framework-bundle/Kernel/MicroKernelTrait.php` line 91

      message:`Kahlan\PhpErrorException` Code(0) with message "`E_WARNING` require(/tmp/kahlan/usr/share/fullstackExercise/src/config/bundles.php): Failed to open stream: No such file or directory"

        [NA] - vendor/symfony/framework-bundle/Kernel/MicroKernelTrait.php, line  to 91

Eek. I had a look into this, and the code in question is try to do this:

$contents = require $this->getProjectDir().'/config/bundles.php';

And the code in getProjectDir is thus:

<pre class="source-code"><code>public function getProjectDir()
{
    if (null === $this-&gt;projectDir) {
        $r = new \ReflectionObject($this);

        if (!is_file($dir = $r-&gt;getFileName())) {
            throw new \LogicException(sprintf('Cannot auto-detect project dir for kernel of class &quot;%s&quot;.', $r-&gt;name));
        }

        $dir = $rootDir = \dirname($dir);
        while (!is_file($dir.'/composer.json')) {
            if ($dir === \dirname($dir)) {
                return $this-&gt;projectDir = $rootDir;
            }
            $dir = \dirname($dir);
        }
        $this-&gt;projectDir = $dir;
    }

    return $this-&gt;projectDir;
}
</code></pre>

The code starts in the directory of the current file, and traverses up the directory structure until it finds the directory with composer.json.If it doesn't find that, then - somewhat enigmatically, IMO - it just says "ah now, we'll just use the directory we're in now. It'll be grand". To me if it expects to find what it's looking for by looking up the ancestor directory path and that doesn't work: throw an exception. Still. In the normal scheme of things, this would work cos the app's Kernel class - by default - seems to live in the src directory, which is one down from where composer.json is.

So why didn't it work? Look at the directory that it's trying to load the bundles from: /tmp/kahlan/usr/share/fullstackExercise/src/config/bundles.php. Where? /tmp/kahlan/usr/share/fullstackExercise/. Ain't no app code in there, pal. It's in /usr/share/fullstackExercise/. Why's it looking in there? Because the Kernel object that is initiating all this is at /tmp/kahlan/usr/share/fullstackExercise/src/Kernel.php. It's not the app's own one (at /usr/share/fullstackExercise/src/Kernel.php), it's all down to how Kahlan monkey-patches everything that's going to be called by the test code, on the off chance you want to spy on anything. It achieves this by loading the source code of the classes, patching the hell out of it, and saving it in that /tmp/kahlan. The only problem with this is that when Symfony traverses up from where the patched Kernel class is… it never finds composer.json, so it just takes a guess at where the project directory is. And it's not a well-informed guess.

I'm not sure who I blame more here, to be honest. Kahlan for patching everything and running code from a different directory from where it's supposed to be; or Symfony for its "interesting" way to know where the project directory is. I have an idea here, Symfony: you could just ask me. Or even force me tell it. Ah well. Just trying to be "helpful" I s'pose.

Anyway, I can exclude files from being patched, according to the docs:

  --exclude=<string>                  Paths to exclude from patching. (default: `[]`).

I tried sticking the path to Kernel in there: src/Kernel.php, and that didn't work. I hacked about in the code and it doesn't actually want a path, it wants the fully-qualified class name, eg: adamCameron\fullStackExercise\Kernel. I've raised a ticket for this with Kahlan, just to clarify the wording there: Bug: bad nomenclature in help: "path" != "namespace".

This does work…

root@13038aa90234:/usr/share/fullstackExercise# vendor/bin/kahlan --lcov="var/tmp/lcov/coverage.info" --ff --exclude=adamCameron\\fullStackExercise\\Kernel

E                                                                 18 / 18 (100%)


Tests of GreetingsController
  Tests of doGet
    ✖ it returns a JSON greeting object
      an uncaught exception has been thrown in `vendor/symfony/deprecation-contracts/function.php` line 25

      message:`Kahlan\PhpErrorException` Code(0) with message "`E_USER_DEPRECATED` Please install the \"intl\" PHP extension for best performance."

        [NA] - vendor/symfony/deprecation-contracts/function.php, line  to 25
        trigger_deprecation - vendor/symfony/framework-bundle/DependencyInjection/FrameworkExtension.php, line  to 253

This is not exactly what I want, but it's a different error, so Symfony is finding itself this time, and then just faceplanting again. However when I look into the code, it's this:

if (!\extension_loaded('intl') && !\defined('PHPUNIT_COMPOSER_INSTALL')) {
    trigger_deprecation('', '', 'Please install the "intl" PHP extension for best performance.');
}

// which in turn...

function trigger_deprecation(string $package, string $version, string $message, ...$args): void
{
    @trigger_error(($package || $version ? "Since $package $version: " : '').($args ? vsprintf($message, $args) : $message), \E_USER_DEPRECATED);
}

So Symfony is very quietly raising a flag that it suggests I have that extension installed. But only as a deprecation notice, and even then it's @-ed out. Somehow Kahlan is getting hold of that and going "nonono, this is worth stopping for". No it ain't. Ticket raised: "Q: should trigger_error type E_USER_DEPRECATED cause testing to halt?".

Anyway, the point is a legit one, so I'll install the intl extension. I initially thought it was just a matter of slinging this in the Dockerfile:

RUN apt-get install --yes zlib1g-dev libicu-dev g++
RUN docker-php-ext-install intl

But that didn't work, I needed a bunch of Other Stuff too:

RUN apt-get install --yes zlib1g-dev libicu-dev g++
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-configure intl
RUN docker-php-ext-install intl

(Thanks to the note in docker-php-ext-install intl fails #57 for solving that for me).

After rebuilding the container, let's see what goes wrong next:

root@58e3325d1a16:/usr/share/fullstackExercise# composer coverage
> vendor/bin/kahlan --lcov="var/tmp/lcov/coverage.info" --exclude=adamCameron\\fullStackExercise\\Kernel

..................                                                18 / 18 (100%)



Expectations   : 52 Executed
Specifications : 0 Pending, 0 Excluded, 0 Skipped

Passed 18 of 18 PASS in 0.605 seconds (using 12MB)

Coverage Summary
----------------

Total: 100.00% (3/3)

Coverage collected in 0.001 seconds


> genhtml --output-directory public/lcov/ var/tmp/lcov/coverage.info
Reading data file var/tmp/lcov/coverage.info
Found 2 entries.
Found common filename prefix "/usr/share/fullstackExercise"
Writing .css and .png files.
Generating output.
Processing file src/MyClass.php
Processing file src/Controller/GreetingsController.php
Writing directory view page.
Overall coverage rate:
  lines......: 100.0% (3 of 3 lines)
  functions..: 100.0% (2 of 2 functions)
root@58e3325d1a16:/usr/share/fullstackExercise#

(I've stuck a Composer script in for this, btw):

"coverage": [
    "vendor/bin/kahlan --lcov=\"var/tmp/lcov/coverage.info\" --exclude=adamCameron\\\\fullStackExercise\\\\Kernel",
    "genhtml --output-directory public/lcov/ var/tmp/lcov/coverage.info"
]

And most promising of all is this:

All green! I like that.

And now I'm back to where I wanted to be, yesterday, as I typed that 404th word of the article I was meant to be working on. 24h later now.

Righto.

--
Adam

Tuesday 2 March 2021

Vue.js: using TDD to develop a data-entry form

G'day:

Yesterday (well: it'll be a coupla days ago by the time I publish this; it's "yesterday" as I type this bit though), I started to do work to replicate and document a coding exercise I undertook recently. That part of the exercise was "Docker: using TDD to initialise my app's DB with some data". So I've now got database tables ready to receive some data (and to provide some stub data where necessary too). Today I'm shifting back to the front end of things. It's all well and good having a place to put the data, but I need a way to get the data from the end user first. Today's exercise is to TDD the construction of that form. I've currently got very little idea of how I'm gonna approach this, because I started typing this paragraph before I'd really thought too much about it. So this will be… ah it'll probably be a mess, but hey.

Right so the overall requirement here is (this is copy and pasted from the previous article) to construct an event registration form (personal details, a selection of workshops to register for), save the details to the DB and echo back a success page. Simple stuff. Less so for me given I'm using tooling I'm still only learning (Vue.js, Symfony, Docker, Kahlan, Mocha, MariaDB). It might seem odd that I did the storage part of the exercise first, but the form I'm creating will need to use some data to populate one of the form fields, so I decided it would make more sense to do all the DB stuff in one article, up front. Possibly I should have only done enough of the DB side of things to service today's part of the exercise but too late now :-)

(Later update: as it happens I ended-up just stubbing the data in this exercise, so it's completely stand-alone from the previous MariaDB article).

The form needs to be something along these lines:

And when I say "along these lines", I mean "now that I've written it, that's actually pretty-much the mark-up I will use". This is all basic. The only point needing clarification is that the workshops will be sourced from the DB data I created yesterday.

We can distill a bunch of obvious test cases here:

  • it has a fullName input field;
  • it has a phoneNumber input field;
  • it has a emailAddress input field;
  • it has a password input field that does not display the password when typed;
  • it has a workshopsToAttend multiple select field;
  • the workshopsToAttend field sources its data from the back-end system (stubbed for this exercise);
  • all fields are required;
  • it has a button to submit the registration details.

We'll be implementing this as a Vue component, and we'll write some tests to make sure the component implements all this. Later, when we come to put the component in a web page, we'll write a test with expectations as to how the form actually behaves when used. For now we're just testing the component. As with the preceding article dealing with the baseline DB schema and data, I am currently still mulling over the benefits of this level of testing. From a TDD perspective we're at least going through the exercise of coming up with our cases first and then writing code to fulfil the case. It makes sense to automate confirming we have fulfilled the requirements of the case.


As you might recall, I only worked out how to test Vue components a few days ago ("Part 12: unit testing Vue.js components"). I'm madly rereading that article and looking at the code to remind myself how to do. Firstly, I'll insert an initial case: "it will be a Vue component called 'WorkshopRegistration'". To remind me how this stuff works, I'm just gonna write a test to mount the component. Small steps. All these cases will be implemented in frontend/test/unit/WorkshopRegistration.spec.js, unless otherwise specified. Right then… my baseline initial sanity-test is:

import WorkshopRegistrationForm from "../../src/workshopRegistration/components/WorkshopRegistrationForm";

import { shallowMount } from "@vue/test-utils";

import {expect} from "chai";

describe("Tests of WorkshopRegistrationForm component", () => {

    it("should be mountable", () => {
        expect(()=>shallowMount(WorkshopRegistrationForm)).to.not.throw();
    });

});

I'm simply checking that mounting the component doesn't error. Of course the entire test spec will fail at the moment as src/workshopRegistration/components/WorkshopRegistrationForm doesn't exist yet:

 ERROR  Failed to compile with 1 error

This relative module was not found:

* ../../src/workshopRegistration/components/WorkshopRegistrationForm in ./test/unit/WorkshopRegistration.spec.js

But this is fine. It's inelegant, but it's a failing test, and when we fulfil its requirement it will pass, and we're good. I'll create the minimum possible frontend/src/workshopRegistration/components/WorkshopRegistrationForm.vue now:

<template>
    <form></form>
</template>

<script>
export default {
  name: 'WorkshopRegistrationForm'
}
</script>

I needed to leap ahead and put the <form/> tags in there, otherwise I got another compile error:


   1:1  error  The template requires child element vue/valid-template-root

But I also duly (and slightly pedantically, I know) updated the tests too:

let mountComponent = () => shallowMount(WorkshopRegistrationForm);

it("should be mountable", () => {
    expect(mountComponent).to.not.throw();
});

it("should contain a form", () => {
    let component = mountComponent();
    let form = component.find("form");
    expect(form.exists()).to.be.true;
});

I extracted that mountComponent function because I'll need it in every test. Whilst engaging in this pedantry, I again reminded myself that some of these tests will be very transient and short-lived. Now that I have an operational component, as soon as I start testing for form fields, I can ditch these two tests as they are implicitly replaced by other tests that would not work if the component wasn't mountable, and the form the fields are in didn't exist. Remember that - whilst I am pretty experienced with TDD - I am not experienced in either of Vue components, Vue's test utilities, and not even that familiar with Mocha (hey, even my JavaScript is pretty bloody rusty and last used in any anger 6-7yrs ago, and it was all front-end stuff. So Node is new to me too). So all these small repetitive exercises are good for me to learn with, which is half the reason I'm doing them. One cannot expect to be an expert right at the beginning, so I'm being diligent with my learning. The tight-loop red/green/refactor also helps to to focus on the task at hand and reduces the chance that I'll disappear down any rabbitholes or other side tracks. At any given moment I am only either writing a test case, or then writing the code to make that test case pass. No "Other Stuff".


Next I'm going to check for a required field for the registrant's full name:

describe.only("Tests of WorkshopRegistrationForm component", () => {

    let component;

    before("Load test WorkshopRegistrationForm component", () => {
        component = shallowMount(WorkshopRegistrationForm);
    });

    it("should have a required text input for fullName, maxLength 100, and label 'Full name'", () => {
        let fullNameField = component.find("form>input[name='fullName']");

        expect(fullNameField.exists(), "fullName field must exist").to.be.true;
        expect(fullNameField.attributes("type"), "fullName field must have a type of text").to.equal("text");
        expect(fullNameField.attributes("maxlength"), "fullname field must have a maxlength of 100").to.equal("100");
        expect(fullNameField.attributes("required"), "fullName field must be required").to.exist;

        let inputId = fullNameField.attributes("id");
        expect(inputId, "id attribute must be present").to.exist;

        let label = component.find(`form>label[for='${inputId}']`);
        expect(label, "fullName field must have a label").to.exist;
        expect(label.text(), "fullName field's label must have value 'Full name'").to.equal("Full name:");
    });
});

You can see I've already ditched those first two tests, and anticipating every test case is going to need that component to be mounted, I've put that in the before handler. I've also rolled in a coupla other requirements of the field I didn't think of before: I need to enforce the length of the field, plus it needs a label. The label thing was partly an exercise in working out how to check it, I have to admit. This is a learning exercise, remember.

I tested every part of this along the way, and I'm not gonna bore you with that. Here's the last failed case of that lot:

1) Tests of WorkshopRegistrationForm component
   should have a required text input for fullName, maxLength 100, and label 'Full name':

  AssertionError: fullName field's label must have value 'Full name': expected 'WRONG LABEL:' to equal 'Full name:'
  + expected - actual

  -WRONG LABEL:
  +Full name:

The code being tested for that was:

<template>
    <form method="post" action="" class="sample">
        <label for="fullName" class="required">WRONG LABEL:</label>
        <input type="text" name="fullName" required="required" maxlength="100" id="fullName">
    </form>
</template>

I can already see that the tests for the other inputs are gonna be the same logic, just with the specific details extracted, so I'll go and refactor that now:


let inputFieldMetadata = [
    {fieldName : "fullName", type:"text", maxLength: 100, labelText: "Full name"},
    {fieldName : "phoneNumber", type:"text", maxLength: 50, labelText: "Phone number"},
    {fieldName : "emailAddress", type:"text", maxLength: 320, labelText: "Email address"},
    {fieldName : "password", type:"password", maxLength: 255, labelText: "Password"}
];

inputFieldMetadata.forEach((caseValues) => {
    let [fieldName, type, maxLength, labelText] = Object.values(caseValues);

    it(`should have a required ${type} input for ${fieldName}, maxLength ${maxLength}, and label '${labelText}'`, () => {
        let field = component.find(`form>input[name='${fieldName}']`);
        expect(field.exists(), `${fieldName} field must exist`).to.be.true;
        expect(field.attributes("type"), `${fieldName} field must have a type of ${type}`).to.equal(type);
        expect(field.attributes("maxlength"), `${fieldName} field must have a maxlength of ${maxLength}`).to.equal(maxLength.toString());
        expect(field.attributes("required"), `${fieldName} field must be required`).to.exist;

        let inputId = field.attributes("id");
        expect(inputId, "id attribute must be present").to.exist;

        let label = component.find(`form>label[for='${inputId}']`);
        expect(label, `${fieldName} field must have a label`).to.exist;
        expect(label.text(), `${fieldName} field's label must have value '${labelText}'`).to.equal(`${labelText}:`);
    });
});

Having done that refactor, it passes for fullName (so my refactoring was correct), but errors on the other - as yet not-implemented - fields. Perfect.

  Tests of WorkshopRegistrationForm component
    ✓ should have a required text input for fullName, maxLength 100, and label 'Full name'
    1) should have a required text input for phoneNumber, maxLength 50, and label 'Phone number'
    2) should have a required text input for emailAddress, maxLength 320, and label 'Email address'
    3) should have a required password input for password, maxLength 255, and label 'Password'


  1 passing (195ms)
  3 failing

  1) Tests of WorkshopRegistrationForm component
       should have a required text input for phoneNumber, maxLength 50, and label 'Phone number':

      phoneNumber field must exist
      + expected - actual

      -false
      +true
[… etc for the other missing fields too…]

Once I copy and paste the mark-up for the form into the template section of the component, the other tests now… still fail. But this is good, and it validates why I'm doing these tests!

  1) Tests of WorkshopRegistrationForm component
       should have a required text input for phoneNumber, maxLength 50, and label 'Phone number':
     AssertionError: phoneNumber field must have a maxlength of 50: expected undefined to equal '50'

My mark-up from above doesn't have the maxlength attribute. I just caught a bug.

OK enough TDD excitement. I fix the mark-up and now the tests pass:

  Tests of WorkshopRegistrationForm component
    ✓ 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'

Now I need to scratch my head about how to test the workshopsToAttend field. The implementation is going to need to read from the database. However my test ain't gonna do that. I'm gonna mock the DB connection out and return known-values for the test. I have - as yet - no idea how to do this. Excuse me whilst I google some stuff…

[… time passes …]


Actually I'm getting ahead of myself. The first case relating to this workshopsToAttend form control is much the same as the tests for the text/password inputs. And there should be a case for that before we start testing the <option> tags present within it. This is easy, but takes a slight refactor of the existing test:

inputFieldMetadata.forEach((caseValues) => {
    let [name, type, maxLength, labelText] = Object.values(caseValues);

    it(`should have a required ${type} input for ${name}, maxLength ${maxLength}, and label '${labelText}'`, () => {
        let field = component.find(`form>input[name='${name}']`);

        expect(field.exists(), `${name} field must exist`).to.be.true;
        expect(field.attributes("required"), `${name} field must be required`).to.exist;
        expect(field.attributes("type"), `${name} field must have a type of ${type}`).to.equal(type);
        expect(field.attributes("maxlength"), `${name} field must have a maxlength of ${maxLength}`).to.equal(maxLength.toString());

        testLabel(field, labelText);
    });
});

it("should have a required workshopsToAttend multiple-select box, with label 'Workshops to attend'", () => {
    let field = component.find(`form>select[name='workshopsToAttend[]']`);

    expect(field.exists(), "workshopsToAttend field must exist").to.be.true;
    expect(field.attributes("required"), "workshopsToAttend field must be required").to.exist;
    expect(field.attributes("multiple"), "workshopsToAttend field must be a multiple-select").to.exist;

    testLabel(field, "Workshops to attend");
});

let testLabel = (field, labelText) => {
    let name = field.attributes("name");
    let inputId = field.attributes("id");
    expect(inputId, "id attribute must be present").to.exist;

    let label = component.find(`form>label[for='${inputId}']`);
    expect(label, `${name} field must have a label`).to.exist;
    expect(label.text(), `${name} field's label must have value '${labelText}'`).to.equal(`${labelText}:`);
};

Much of the test for the <select> was the same as for the text fields: that it exists, that it's required and that it has a label. However the selector is different for the field itself, and that is also its type. Plus there's no maxLength check, but there is a multiple check. I thought about trying to increase the metadata in the array I looped over for each field's tests, and then optionally testing maxLength or multiple if it was present in the metadata. But the logic to vary how to get the field, how to get its type, making the optional attributes optional was making the test code a bit impenetrably "generic", which was a flag to me that I was over-complicating things. One can take DRY too far some times: it should not result in a loss of code clarity. One thing I could lift out, lock-stock, and it actually improves the code's readability is the logic around finding and testing the form field's label. So I've done that. This leaves two fairly concise and logic-free test cases, and another function which is also fairly easy to follow, and now has its own name.

Now I can add the mark-up for just the <select> part, and the tests pass (I'll stop showing passing / failing tests unless there's something noteworthy. You get the idea.

<label for="workshopsToAttend" class="required">Workshops to attend:</label>
<select name="workshopsToAttend[]" multiple="multiple" required="required" id="workshopsToAttend">
</select>

Note the weird way PHP requires form controls that can take multiple-values to be named: as if they're an array. This is appalling, but it's what one needs to do, otherwise one only gets the first of the multiple values exposed to PHP. Pathetic.

Next I had to do more reading to work out how to test and implement the options, the data for which will be sourced by an API call. This is more complicated that the earlier stuff, so I'll treat it step by step.

Firstly, it's not the job of a UI component to know about APIs, it's just its job to present (dynamic ~) mark-up. So I will be abstracting the logic to source the data into a WorkshopService, which I will pass to the component via dependency injection. Vue seems to make this easy by implementing a provide/inject mechanism where the top level Vue application can provide dependencies to its component stack, and any given component can have its dependencies injected into it. This is documented at "Provide / inject". And how to provide the dependencies to a component we're just mounting for testing is referenced in "Mounting Options › Provide" for the Vue 2.x version of Vue Test Utils, and you have to know what you're looking for to see how this has changed for the Vue 3.x version of Vue Test Utils: "Reusability & Composition". This boils down to the mounting of the component becomes this (all the final code for the test spec is at frontend/test/unit/WorkshopRegistration.spec.js):

component = shallowMount(
    WorkshopRegistrationForm,
    {
        global : {
            provide: {
                workshopService : workshopService
            }
        }
    }
);

The difference in the implementation of this between Vue Test Utils for Vue 2.x and 3.x is this intermediary global tier of the mount options. I only found this out thanks to Issue testing provider/inject with Vue 3 composition API #1698, which makes it way more explicit than the docs, and the only reference to this I was able to find via Google.

WorkshopService is currently just a stub that I can mock (frontend/src/workshopRegistration/services/WorkshopService.js):

class WorkshopService {
    getWorkshops() {
        return [];
    }
}

module.exports = WorkshopService;

And the version of it I pass into the component is mocked with Sinon to return a canned response:

let expectedOptions = [
    {value: 2, text:"Workshop 1"},
    {value: 3, text:"Workshop 2"},
    {value: 5, text:"Workshop 3"},
    {value: 7, text:"Workshop 4"}
];

before("Load test WorkshopRegistrationForm component", () => {
    let workshopService = new WorkshopService();
    sinon.stub(workshopService, "getWorkshops").returns(expectedOptions);
    component = shallowMount(
    	// etc, as per earlier code snippet

From there the actual test is pretty simple and predictable:

it("should list the workshop options fetched from the back-end", () => {
    let options = component.findAll(`form>select[name='workshopsToAttend[]']>option`);

    expect(options).to.have.length(expectedOptions.length);
    options.forEach((option, i) => {
        expect(option.attributes("value"), `option[${i}] value incorrect`).to.equal(expectedOptions[i].value.toString());
        expect(option.text(), `option[${i}] text incorrect`).to.equal(expectedOptions[i].text);
    });
});

I just check each of the options in the form against the values I expect (and have been returned by the call to workshopService.getWorkshops. Conveniently this also tests the mocking is working: this is the first time I've used Sinon, so this is beneficial for me.

Now that I have a failing test, I can go ahead and update the component template to render the options (frontend/src/workshopRegistration/components/WorkshopRegistrationForm.vue):

<select name="workshopsToAttend[]" multiple="multiple" required="required" id="workshopsToAttend">
    <option v-for="workshop in workshops" :value="workshop.value" :key="workshop.value">{{workshop.text}}</option>
</select>

And also the template initialisation code to call the WorkshopService to get the options (same file as above, in case that's not clear):

<script>
export default {
  name: "WorkshopRegistrationForm",
    inject: ["workshopService"],
    data() {
      return {workshops: []};
    },
    mounted() {
      this.workshops = this.workshopService.getWorkshops();
    }
}
</script>

Note that because I am using the provide / inject mechanism, the component doesn't need to know where it gets workshopService from, all it needs to know is that something will inject it (so far: just my test, but once this is actually implemented on a page, the Vue application will provide it.


OK so the form is now populated. We need to test that we have a button, and when we click it we submit the form. As per earlier I am typing this before I have any idea how to do this, so I need to read some more. I guess we can at least test the button is present in the form, to start with:

it("should have a button to submit the registration", () => {
    let button = component.find(`form>button`);

    expect(button.exists(), "submit button must exist").to.be.true;
    expect(button.text(), "submit button must be labelled 'register'").to.equal("Register");
});

With that, I can at least add the button tot he component template:

<button>Register</button>

Now I just need to work out what the button needs to do, and how to test for it. And… err… how to implement that.

I've decided this component is going to represent both states of the registration: the initial form, and the resultant confirmation info once the data has been submitted and processed. When the button is pressed it's going to hide the form, and show a transition whilst the registration details are being processed. Once the processing call comes back, it's going to hide the transition and show the results. So our first test case is that when the button is clicked to submit the form, it shows the transition instead of the form.


Writing some code before writing the tests, but still doing TDD

Before I start on this next round of development, I will admit I am spiking a lot of test code here. I don't mean "code that is tests", I mean "I'm writing and discarding a bunch of code to work out and test how Vue.js does stuff". For example I'm going to monkey around with showing / hiding the <div> elements with each of the stages of the process. However once I've nailed how Vue.js works, I will discard any source code I have, get my test case written, and then implement just the source code to make the test pass. I make a point of saying this because it is just a TDD hint that it's OK to write scratch code to check stuff before one writes the tests, but once one is clear that the scratch code is actually how things need to be, discard it (even cut it out and paste it somewhere else for now), write the test for the test case. Note: I don't mean write tests for for the code you just hide away in a scratch file, but for the test case! Those are two different things! Once you have your test case written, then implement the code to pass the test. It can be inspired by your scratch code, but you should make sure to focus on inplementing just enough code to make the case pass. As development - even of scratch code - is an iterative and layered endeavour, it's likely the final state of your scratch code is ahead of where the code for the test case might be, but make sure to - to belabour the point - only implement the code for the test. Not any code for the next test. Remember TDD is about test cases leading code design, and this tight red/green cycle helps stay focused on solving the issue one case at a time. It also helps weed out any rabbit-hole code you might have in your scratch code that ends up not actually being necessary to fulfil the test case requirements.

OK, two days of reading, experimenting, and generally fart-arsing around, I have achieved two things:

  • I have learned enough about Vue.js and Vue Test Utils (and Mocha, and Sinon… and even some CSS!) to do the work I need to do;
  • I have identified the remaining test cases I need to implement to round-out the testing of the component.

Here is the state of the work so far:

  Tests of WorkshopRegistrationForm component
     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


  7 passing (48ms)
  1 pending

 MOCHA  Tests completed successfully

I've added the next test case there too. I'll implement that now:

it.only("should leave the submit button disabled until the form is filled", async () => {
    let button = component.find("form.workshopRegistration button");

    expect(button.attributes("disabled"), "button should be disabled").to.exist;

    let form = component.find("form.workshopRegistration")
    form.findAll("input").forEach((input)=>{
        input.setValue("TEST_INPUT_VALUE");
    });
    form.find("select").setValue(5);

    await flushPromises();

    expect(button.attributes("disabled"), "button should be enabled").to.not.exist;
});

Here I am doing this:

  • checking the button starts as being disabled when the form is empty;
  • sticking some values in the form;
  • checking the button is now enabled because the form is OK to submit.

Because the act of setting values is asynchronous, I need to wait for them to finish. I could await each one, but when reading about how to test this stuff I landed on this flush-promises library which does what it suggests: blocks until all pending promises are resolved. This just makes the test code simpler to follow.

Obviously this fails at the moment, because there's nothing controlling the readiness of the button. It's just this: <button>Register</button>:

  1) Tests of WorkshopRegistrationForm component
       should leave the submit button disabled until the form is filled:
     AssertionError: button should be disabled: expected undefined to exist
      at Context.<anonymous> (dist/js/webpack:/test/unit/WorkshopRegistration.spec.js:99:1)

Line 99 references the first expectation checking that the disabled attribute exists. OK so we need to write some logic in the template that will change a flag based on whether the form fields have values. I'll make this a computed property. These are basically magic and the Vue app keeps track of their values in near-enough real time:

computed : {
    isFormUnready: function () {
        return 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
    }
}

And then we can reference that in "logic" in the button:

<button :disabled="isFormUnready">Register</button>

And that's it. The test case now passes:

  Tests of WorkshopRegistrationForm component
     should leave the submit button disabled until the form is filled


  1 passing (48ms)

 MOCHA  Tests completed successfully

I'm pretty impressed with how easy that ended up being. I mean it took me ages to work out how to approach it, but the final result was simple and the code is pretty clear.

The next test is as follows:

it("should disable the form and indicate data is processing when the form is submitted", async () => {
    await submitPopulatedForm();

    let fieldset = component.find("form.workshopRegistration fieldset");
    expect(fieldset.attributes("disabled"), "fieldset should be disabled").to.exist;

    let button = component.find("form.workshopRegistration button");
    expect(button.text(), "Button should now indicate it's processing").to.equal("Processing…");
});

let submitPopulatedForm = async () => {
    await populateForm();
    await component.find("form.workshopRegistration button").trigger("click");
    await flushPromises();
};

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(5);

    await flushPromises();
};
  • Given the form-filling, processing and summary are all going to be done within the one page without reloads, we need to visually indicate processing is taking place, and also guard against the use tampering with the form once it's submitted by disabling it.
  • We have abstracted out a function to populate and submit the form. This is a bit of premature refactoring I guess, but I know all the rest of the cases are going to need to populate and submit the form, so I'll do it now. Note I've also refactored the previous test to use populateForm instead of doing this logic inline.
  • In a similar fashion to the previous test, we check that the fieldset has been disabled…;
  • and the button now will indicate that processing is taking place.

Note that I'm checking on the fieldset not the form, because the form itself can't be disabled: disablement is a function of individual form controls, or groups of them in a fieldset

The implementation of this is dead easy again (I've elided irrelevant code from this where indicated):

const REGISTRATION_STATE_FORM = "form";
const REGISTRATION_STATE_PROCESSING = "processing";

export default {
    // [...]
    data() {
        return {
            registrationState: REGISTRATION_STATE_FORM,
            // [...]
        };
    },
    created() {
        this.REGISTRATION_STATE_FORM = REGISTRATION_STATE_FORM;
        this.REGISTRATION_STATE_PROCESSING = REGISTRATION_STATE_PROCESSING;
    },
    // [...]
    methods : {
        processFormSubmission(event) {
            event.preventDefault();
            this.registrationState = REGISTRATION_STATE_PROCESSING;
        }
    },
    computed : {
        // [...]
        isFormDisabled: function() {
            return this.registrationState !== REGISTRATION_STATE_FORM;
        },
        submitButtonLabel: function() {
            return this.registrationState === REGISTRATION_STATE_FORM ? "Register" : "Processing&hellip;";
        }
    }
}
  • I've added some constants to represent the states I'll be using so I'm not reproducing strings all over the place.
  • I need to load them into the application's memory too so they're accessible in the template too (this is just the way it needs to be done in Vue).
  • I've set the starting state of the process to be form.
  • I've added an event handler for the form submission that simply kills the default form submission action, then sets the current state to be processing.
  • Similar to before, I have computed properties for the elements on the form that need to change:
    • disabling it
    • Changing the button text.

In the template, this is just a matter of acting on those two computed properties:

<template>
    <form method="post" action="" class="workshopRegistration">
        <fieldset :disabled="isFormDisabled">
            <!-- ... -->
            <button @click="processFormSubmission" :disabled="isFormUnready" v-html="submitButtonLabel"></button>
        </fieldset>
    </form>
</template>

Once again: the code to achieve the functionality is very simple. And this test case now passes too:

  Tests of WorkshopRegistrationForm component
     should disable the form and indicate data is processing when the form is submitted


  1 passing (45ms)

 MOCHA  Tests completed successfully

Now we need to make sure we save the data (or at least: ask the WorkshopService to take care of that):


it("should send the form values to WorkshopService.saveWorkshopRegistration when the form is submitted", async () => {
    sinon.spy(workshopService, "saveWorkshopRegistration");

    await submitPopulatedForm();

    expect(
        workshopService.saveWorkshopRegistration.calledOnceWith({
            fullName: TEST_INPUT_VALUE + "fullName",
            phoneNumber: TEST_INPUT_VALUE + "phoneNumber",
            workshopsToAttend: [TEST_SELECT_VALUE],
            emailAddress: TEST_INPUT_VALUE + "emailAddress",
            password: TEST_INPUT_VALUE + "password"
        }),
        "Incorrect values sent to WorkshopService.saveWorkshopRegistration"
    ).to.be.true;
});

This also requires adding a stub method to WorkshopService:

class WorkshopService {
    // ...

    saveWorkshopRegistration() {
    }
}

Here we:

  • Put a spy on that saveWorkshopRegistration method;
  • Submit the form;
  • Make sure saveWorkshopRegistration received the values from the form.
  • Oh I've also refactored the previously hard-coded test value strings into constants, because I now need to compare them to themselves, and I wanted to make sure they're always the same.

To implement the code for this test is as simple as adding the call to saveWorkshopRegistration to the form-submit-handler:

processFormSubmission(event) {
    event.preventDefault();
    this.registrationState = REGISTRATION_STATE_PROCESSING;
    this.workshopService.saveWorkshopRegistration(this.formValues);
}

And yeah sure, nothing gets saved at the moment because saveWorkshopRegistration is just stubbed. But it's not the job of this Vue component to do the saving. It's the job of WorkshopService to do that. And we'll get to that later. We're just testing the component logic works for now.

And this test case passes:

  Tests of WorkshopRegistrationForm component
     should send the form values to WorkshopService.saveWorkshopRegistration when the form is submitted


  1 passing (46ms)

 MOCHA  Tests completed successfully

Almost done. This next case just checks the static parts of the summary display is there. I'm doing this separate from testing the values because when I initially implemented it all in one, it was way too big for one test case, and there seemed to be a reasonable slice point in the code between the bit here, and the bit doing the values:

it("should display the registration summary 'template' after the registration has been submitted", async () => {
    await submitPopulatedForm();

    let summary = component.find("dl.workshopRegistration");
    expect(summary.exists(), "summary must exist").to.be.true;

    let expectedLabels = ["Registration Code", "Full name", "Phone number", "Email address", "Workshops"];
    let labels = summary.findAll("dt");

    expect(labels).to.have.length(expectedLabels.length);
    expectedLabels.forEach((label, i) => {
        expect(labels[i].text()).to.equal(`${label}:`);
    });
});

Here we are doing the following:

  • Submitting the form as per usual;
  • Verifying the summary element - a definition list (<dl>) - is there;
  • Checking that the expected labels are implemented as definition terms (<dt>).

The mark-up for the implementation is as follows:

<template>
    <form method="post" action="" class="workshopRegistration" v-if="registrationState !== REGISTRATION_STATE_SUMMARY">
        <!-- ... -->
    </form>

    <dl v-if="registrationState === REGISTRATION_STATE_SUMMARY" class="workshopRegistration">
        <dt>Registration Code:</dt>
        <dd></dd>

        <dt>Full name:</dt>
        <dd></dd>

        <dt>Phone number:</dt>
        <dd></dd>

        <dt>Email address:</dt>
        <dd></dd>

        <dt>Workshops:</dt>
        <dd></dd>
    </dl>
</template>

Just note the conditions on the form and dl to control which of the two is displayed. The condition on the <form> is !== REGISTRATION_STATE_SUMMARY as opposed to === REGISTRATION_STATE_FORM because we want the form to stay there in its disabled and processing state whilst the submission is being handled. The the component code I just add a new registration state of "summary" via a constant, and switch to that state after the data is saved:

// ...
const REGISTRATION_STATE_SUMMARY = "summary";

export default {
    // ...
    created() {
        // ...
        this.REGISTRATION_STATE_SUMMARY = REGISTRATION_STATE_SUMMARY;
    },
    methods : {
        processFormSubmission(event) {
            event.preventDefault();
            this.registrationState = REGISTRATION_STATE_PROCESSING;
            this.summaryValues = this.workshopService.saveWorkshopRegistration(this.formValues);
            this.registrationState = REGISTRATION_STATE_SUMMARY;
        }
    },
    // ...
}

This test now passes:

  Tests of WorkshopRegistrationForm component
     should display the registration summary 'template' after the registration has been submitted


  1 passing (53ms)

 MOCHA  Tests completed successfully

However we have a problem. Some of the other tests now break!

    1) should disable the form and indicate data is processing when the form is submitted
    2) should send the form values to WorkshopService.saveWorkshopRegistration when the form is submitted
    3) should display the registration summary 'template' after the registration has been submitted


  8 passing (319ms)
  3 failing

  1) Tests of WorkshopRegistrationForm component
       should disable the form and indicate data is processing when the form is submitted:
     Error: Cannot call attributes on an empty DOMWrapper.

  2) Tests of WorkshopRegistrationForm component
       should send the form values to WorkshopService.saveWorkshopRegistration when the form is submitted:
     Error: Cannot call findAll on an empty DOMWrapper.

  3) Tests of WorkshopRegistrationForm component
       should display the registration summary 'template' after the registration has been submitted:
     Error: Cannot call findAll on an empty DOMWrapper.



 MOCHA 3 failure(s)

Note that this includes the test I just created, and passes when run on its own. When running each of these tests separately, only the should disable the form and indicate data is processing when the form is submitted one was always failing now.

I twigged that if a test passed in isolation, but fairly when run along with other tests, there must be some shared code that is stateful, and ends up in the wrong state between tests. I determined that this was because I had a before handler, when I really needed to run that code beforeEach:

before("Load test WorkshopRegistrationForm component", () => {
    workshopService = new WorkshopService();
    sinon.stub(workshopService, "getWorkshops").returns(expectedOptions);

    component = shallowMount(
        // etc
    );
});

Basically I'm re-using the same component in all tests, and once a test changes the registrationState, the next test could start running with the component showing the summary mark-up, rather than the form it's expecting to see. This only started being a problem when I implemented the transition from one to the other in the previous test/dev cycle. This was easy to spot because of the really small increments I'm doing with my TDD. As well as changing the handler to be beforeEach I also had to await on the shallowMount call, as whilst before blocks until all its code has completed, beforeEach does not. So without the await, often the component was not ready before the test started to try to use it. To be clear, the change was this:

beforeEach("Load test WorkshopRegistrationForm component", async () => {
    workshopService = new WorkshopService();
    sinon.stub(workshopService, "getWorkshops").returns(expectedOptions);

    component = await shallowMount(
        // etc
    );
});

Now when I run the tests I only get the one failure:

    1) should disable the form and indicate data is processing when the form is submitted


  10 passing (239ms)
  ` failing

  1) Tests of WorkshopRegistrationForm component
       should disable the form and indicate data is processing when the form is submitted:
     Error: Cannot call attributes on an empty DOMWrapper.
 MOCHA 1 failure(s)

And the issue here is kind of the same. The code in question is this:

it("should disable the form and indicate data is processing when the form is submitted", async () => {
    await submitPopulatedForm();

    let fieldset = component.find("form.workshopRegistration fieldset");
    expect(fieldset.attributes("disabled"), "fieldset should be disabled").to.exist;

    let button = component.find("form.workshopRegistration button");
    expect(button.text(), "Button should now indicate it's processing").to.equal("Processing…");
});

The problem is that now that the registrationState changes to summary, by the time we're checking these things about the form, Vue has already hidden it, and shown the summary. On the actual UI, and once the data needs to be sent off to a remote service to save and return the summary data there'll likely be a short pause when we're in that processing state, but it'll never be long. And it's kinda instantaneous now. As a refresher, this is the code that runs when the form is submitted:

processFormSubmission(event) {
    event.preventDefault();
    this.registrationState = REGISTRATION_STATE_PROCESSING;
    this.summaryValues = this.workshopService.saveWorkshopRegistration(this.formValues);
    this.registrationState = REGISTRATION_STATE_SUMMARY;
}

There's not much time between those two transitions. Fortunately Vue lets me watch what happens to any of its data whenever I want, so I can just leverage this:

it("should disable the form and indicate data is processing when the form is submitted", async () => {
    let lastLabel;
    component.vm.$watch("submitButtonLabel", (newValue) => {
        lastLabel = newValue;
    });

    let lastFormState;
    component.vm.$watch("isFormDisabled", (newValue) => {
        lastFormState = newValue;
    });

    await submitPopulatedForm();

    expect(lastLabel).to.equal("Processing&hellip;");
    expect(lastFormState).to.be.true;
});

I track each change to submitButtonLabel and isFormDisabled, and their last state is how the form was before it was hidden completely.

Now all my tests are passing again. There one last case to do: testing the values on the summary page:

it("should display the summary values in the registration summary", async () => {
    const summaryValues = {
        registrationCode : "TEST_registrationCode",
        fullName : "TEST_fullName",
        phoneNumber : "TEST_phoneNumber",
        emailAddress : "TEST_emailAddress",
        workshopsToAttend : [{value: "TEST_workshopToAttend_VALUE", text:"TEST_workshopToAttend_TEXT"}]
    };
    sinon.stub(workshopService, "saveWorkshopRegistration").returns(summaryValues);

    await submitPopulatedForm();

    let summary = component.find("dl.workshopRegistration");
    expect(summary.exists(), "summary must exist").to.be.true;

    let expectedValues = Object.values(summaryValues);
    let values = summary.findAll("dd");
    expect(values).to.have.length(expectedValues.length);

    let expectedWorkshopValue = expectedValues.pop();
    let actualWorkshopValue = values.pop();

    let ddValue = actualWorkshopValue.find("ul>li");
    expect(ddValue.exists()).to.be.true;
    expect(ddValue.text()).to.equal(expectedWorkshopValue[0].text);

    expectedValues.forEach((expectedValue, i) => {
        expect(values[i].text()).to.equal(expectedValue);
    });
});

This looks more complicated than it is:

  • We mock saveWorkshopRegistration to return known values;
  • We verify we're on the summary page;
  • We check that there are the right number of values in the summary;
  • We take the workshopsToAttend values out, because they need to be tested differently from the rest: they're the values from the multi-select, so the mark-up is different to accommodate that;
  • We test that workshopsToAttend value is in its own <ul>;
  • We loop over the other values and make sure they're all what we expect.

This test is a wee bit fragile because it relies on workshopsToAttend to be the last key in the values object, and the last <dd> in the mark-up, but it'll do for now.

There's only mark-up to change for this one: adding the value output to the summary template:

<dl v-if="registrationState === REGISTRATION_STATE_SUMMARY" class="workshopRegistration">
    <dt>Registration Code:</dt>
    <dd>{{ summaryValues.registrationCode }}</dd>

    <dt>Full name:</dt>
    <dd>{{ summaryValues.fullName }}</dd>

    <dt>Phone number:</dt>
    <dd>{{ summaryValues.phoneNumber }}</dd>

    <dt>Email address:</dt>
    <dd>{{ summaryValues.emailAddress }}</dd>

    <dt>Workshops:</dt>
    <dd>
        <ul>
            <li v-for="workshop in summaryValues.workshopsToAttend" :key="workshop.value">{{workshop.text}}</li>
        </ul>
    </dd>
</dl>

(You can also now see why we needed to test the values for workshopsToAttend separately: the mark-up is more complicated).


Okey doke. In theory now, the thing should "work" now, as far as the component's behaviour goes. Obviously the WorkshopService doesn't do anything, so we'll only get placeholder values, but that's something at least. Let's… make one last tweak and have a look. We need to return some sample values from WorkshopService.saveWorkshopRegistration, because it's that data that populates the summary display. I'm just gonna "loopback" the values from the form, plus add a placeholder registration code (which will be a GUID, but we 'll just mask it for now):

saveWorkshopRegistration(details) {
    let allWorkshops = this.getWorkshops();
    let selectedWorkshops = allWorkshops.filter((workshop) => {
        return details.workshopsToAttend.indexOf(workshop.value) >= 0;
    });

    return {
        registrationCode : "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
        fullName : details.fullName,
        phoneNumber : details.phoneNumber,
        emailAddress : details.emailAddress,
        workshopsToAttend : selectedWorkshops
    };
}

That'll do. Oh and of course we need to actually stick the thing on a web page somewhere. I need to add a page to frontend/vue.config.js:

module.exports = {
    pages : {
        // ...
        workshopRegistration: {
            entry: "src/workshopRegistration/main.js",
            template: "public/workshopRegistration.html",
            filename: "workshopRegistration.html"
        }
    },
    // ...
}

And put that frontend/src/workshopRegistration/main.js file in place (seems to be the same for every page):

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

And lastly the parent App.vue template which has a <workshop-registration-form /> tag in its template block, as well as importing the WorkshopRegistrationForm component file, and also doing the dependency injection of WorkshopService:

<template>
    <workshop-registration-form></workshop-registration-form>
</template>

<script>
import WorkshopRegistrationForm from "./components/WorkshopRegistrationForm";
import WorkshopService from "../../src/workshopRegistration/services/WorkshopService";

export default {
    name: 'App',
    components: {
        WorkshopRegistrationForm
    },
    provide: {
        workshopService: new WorkshopService()
    }
}
</script>

Now we have a web page browseable at /workshopRegistration.html


Let's have a look:

To start with no data is populated, and the Register button is disabled. So far so good.

I've filled some data in, but the button is still disabled. Cool.

I've finished filling in the form and the Register button is now active. Nice one.

Now I'm gonna click Register and… [cringe]…

holy f*** it actually worked!

That is not feigned surprise. That is the first time I tried to do that… today anyhow. Yesterday with the scratch version of the code I messed around and got it working correctly, got the CSS all "working" somehow that looked half decent, but I hadn't checked the summary view today at all.

I was expecting to have to fix stuff up and make excuses and waste more of your time reading this, but I guess I'm done with this exercise.


The next exercise will be to implement the back-end that WorkshopService needs to talk to (see "Symfony & TDD: adding endpoints to provide data for front-end workshop / registration requirements"). This is a switch from Vue.js to PHP and Symfony. That should be interesting as my Symfony skills are pretty marginal at best. I'd say after this exercise I now know more about Vue.js than I do about Symfony, by way of comparison.

Sorry this one was so long, but it was kinda one atom of work, and whilst I could ahve cut over into a different article when I shifted from the form test cases to the summary test cases, there wasn't much to do for the summary as it turned out, so I just kept going. I do have to say I enjoyed this exercise, and I learned a lot about Vue, Vue Test Utils, Sinon and even some more about Mocha. Plus a chunk of just JS stuff I didn't know before. And it was a useful TDD exercise to put myself through as well, and I actually think it was worth it. Anyway, enough. I'm outa here.

Righto.

--
Adam