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:
✔ 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