Create a Task Manager with Test Driven Development Part 5- Frontend Beginnings
For the frontend of this project, I’m going to use VueJS. I just went through a quick guide on how to use VueJS recently so this feels like a good opportunity to try to practice what I learned. Unfortunately, I have no experience using tests with Vue so we’ll see how this goes.
I already have vue-cli installed on my machine so I’ll create a new project using vue create task-manager-app. I was going to set up testing env manually, but I had some problems getting in configured so let’s just use the built in options with vue-cli.
With everything built let’s test out the unit testing. In the terminal, run npm run test:unit
So far so good. If you notice, by default the file endings for the tests have to be spec.js and not test.js. Just for fun, let’s change the example test to example.test.js, and in the jest config file change the following line from
testMatch: [
"**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"
],
to
testMatch: [
"**/tests/unit/**/*.test.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"
],
Okay, now we can use test.js files sinces that’s what we’ve been doing in the past. Before getting started, let’s just take a quick look at the App.vue file
<template>
<div id="app">
<img alt="Vue logo" src="./assets/logo.png" />
<HelloWorld msg="Welcome to Your Vue.js App" />
</div>
</template><script>
import HelloWorld from "./components/HelloWorld.vue";export default {
name: "app",
components: {
HelloWorld
}
};
</script><style>
#app {
font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
And then the test file
import { shallowMount } from "@vue/test-utils";
import HelloWorld from "@/components/HelloWorld.vue";describe("HelloWorld.vue", () => {
it("renders props.msg when passed", () => {
const msg = "new message";
const wrapper = shallowMount(HelloWorld, {
propsData: { msg }
});
expect(wrapper.text()).toMatch(msg);
});
});
Here we can get a sense of how unit testing works. We import the HelloWorld component and get a wrapper for it by using shallowMount. HelloWorld expects a prop of msg so we have to pass that along too. Then using the wrapper, we expect it to match the message we just sent. Pretty cool.
Okay, so now let’s get to work. I’m going to just comment out this old test for reference later and do some cleaning up of things we don’t need.
In the src folder, I’ll keep only the main.js and App.vue file and delete everything else. Let’s also just start with the following in App.vue
<template>
<div>
Hello World
</div>
</template><script>
export default {
name: 'App'
};
</script><style>
</style>
Before getting started, let’s take a step back and think through what kind of tests I should create. I have a backend API that handles CRUD operations for users and tasks, on the frontend I want to be able to access all of those operations. I don’t need to test if things are saved to the database or anything, the frontend shouldn’t care about how the backend does it’s thing as long as it returns the correct information. So what I’ll focus on three things right now.
- Components render what they are supposed to
- Vue handles user interactions correctly (ex: button fires a method)
- Changes to data are handled (ex: add task actually shows another task)
Let’s get started on the component that will allow me to create users. This should be a form that has a name, email, and password field with a submit button. This submit button should fire a request to the backend and if successful should probably render some kind of success message (later we’ll have this log in the user).
So how do we write these tests? First let’s create a basic createUser component. So in src/components/CreateUser.vue
<template>
<div>
Create User Component
</div>
</template><script>
export default {
name: "CreateUser"
};
</script><style>
</style>
Okay so in a user.test.js file let’s do the following:
import { shallowMount } from '@vue/test-utils';
import CreateUser from '../../src/components/CreateUser';describe('CreateUser.vue', () => {
it('Renders a form with name, email, and passworld fields', () => {
const wrapper = shallowMount(CreateUser);
expect(wrapper.find('[data-name]').exists()).toBe(true);
expect(wrapper.find('[data-email').exists()).toBe(true);
expect(wrapper.find('[data-password').exists()).toBe(true);
});
});
Basically in my CreateUser component, I’m expecting to find elements with attributes of data-name, data-email, and data-password. I could be more specific and make them input fields, but I think this is fine. Let’s see if I can get these tests passing. Back in the component:
<template>
<div>
Create User Component
<form>
<input type="text" data-name placeholder="Name" />
<input type="text" data-email placeholder="Email" />
<input type="text" data-password placeholder="password" />
<button type="submit">Create User</button>
</form>
</div>
</template>...
I created a simple form with the three things I need. I could also have checked for a button, but that seems like overkill. Basically, I just want to make sure the fields that I need are there.
It’s super ugly right now, so let’s do some basic styling using semantic ui. We’ll use semantic ui vue to do this.
npm i semantic-ui-vue semantic-ui-css
In the main js file
import SuiVue from 'semantic-ui-vue';
import 'semantic-ui-css/semantic.min.css';Vue.use(SuiVue);
In App.vue
<template>
<sui-container>
<create-user></create-user>
</sui-container>
</template>
In CreateUser.vue
<template>
<div>
<h2>Create User Component</h2>
<sui-form>
<sui-form-field>
<label>Name <input type="text" data-name placeholder="Name"/></label>
</sui-form-field>
<sui-form-field>
<label
>Email <input type="email" data-email placeholder="Email" />
</label>
</sui-form-field>
<sui-form-field>
<label>
Password
<input type="password" data-password placeholder="password" />
</label>
</sui-form-field>
<sui-button primary type="submit">Create User</sui-button>
</sui-form>
</div>
</template>
And now our app looks like
After using semantic vue though, I kept getting these annoying warnings about unregistered custom components. To fix, we create a local Vue instance and have it use semantic vue. The changes made are in bold.
import { shallowMount, createLocalVue } from '@vue/test-utils';
import CreateUser from '../../src/components/CreateUser';
import SuiVue from 'semantic-ui-vue';
const localVue = createLocalVue();
localVue.use(SuiVue);describe('CreateUser.vue', () => {
it('Renders a form with name, email, and passworld fields', () => {
const wrapper = shallowMount(CreateUser, { localVue });
expect(wrapper.find('[data-name]').exists()).toBe(true);
expect(wrapper.find('[data-email]').exists()).toBe(true);
expect(wrapper.find('[data-password]').exists()).toBe(true);
});
});
Next let’s get the submit button to display some feedback to the user.
it('reveals a notification when submitted', () => {
const wrapper = shallowMount(CreateUser, { localVue });wrapper.find('[data-name]').setValue('Peter');
wrapper.find('[data-email]').setValue('peter@example.com');
wrapper.find('[data-password]').setValue('12345678');
wrapper.find('form').trigger('submit.prevent');expect(wrapper.find('.message').text()).toBe('The user Peter was created.');
});
In CreateUser.vue, I added a message item under the sui-form and also added a submit handler method.
<sui-form @submit.prevent="handleSubmit">
...
</sui-form>
<sui-message v-if="submitted">
<sui-message-header>Success</sui-message-header>
<p success-message>User has been created</p>
</sui-message>
My script tag now looks like
<script>
export default {
name: 'CreateUser',
data() {
return {
submitted: false
};
},
methods: {
handleSubmit(e) {
const name = e.target[0].value;
const email = e.target[1].value;
const password = e.target[2].value;
console.log(name, email, password);
this.submitted = true;
}
}
};
</script>
In my form, I’m actually not modeling my data in the vue component. It didn’t seem like it was necessary, but what that means is I don’t really have easy access to the user name to put in the success message. So I need to fix my test a bit.
— TWO HOURS LATER —
So I thought I could just update my test and things would work, but I played around for 2 hours and couldn’t get my test to pass. I had two particular problems, one: my form wasn’t being triggered and two: the handleSubmit method wasn’t able to pull data from the form. Ultimately, I had to switch my form to use v-model and keep the data stored on the data property. And with the form, it turned out that it couldn’t find the form if I used “sui-form” or “form”. I just decided to add a form attribute on it to make it easier to select.
My test reads
it('reveals a notification when submitted', () => {
const wrapper = mount(CreateUser, { localVue }); wrapper.find('[data-name]').setValue('Peter');
wrapper.find('[data-email]').setValue('peter@example.com');
wrapper.find('[data-password]').setValue('12345678');
wrapper.find('[form]').trigger('submit.prevent');expect(wrapper.find('[message]').text()).toBe('User has been created');
});
And finally! Got it working. Now I need to figure out how to test if I can actually send off a request to my server. Let’s try something like:
it('adds a user', async () => {
const wrapper = mount(CreateUser, { localVue }); wrapper.find('[data-name]').setValue('Peter');
wrapper.find('[data-email]').setValue('peter@example.com');
wrapper.find('[data-password]').setValue('12345678'); const response = await wrapper.vm.handleSubmit();
expect(response.body).not.toBeNull();
expect(response.body.name).toBe('Peter');
});
And let’s run npm i axios and npm i env-cmd — save-dev. In config.env in the root we’ll add a link to the backend
TASK_API_ROOT_URL=localhost:3000
and in package.json
"scripts": {
"serve": "env-cmd -f ./config.env vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"test:unit": "env-cmd -f ./config.env vue-cli-service test:unit"
},
Now we can try to use process.env.TASK_API_ROOT_URL to reference our backend project.
I’m going to start mongodb and start the task manager api in another terminal.
First let’s change my handleSubmit method
async handleSubmit() {
console.log(this.name, this.email, this.password);
const user = await axios.post('http://127.0.0.1:3000/user', {
name: this.name,
email: this.email,
password: this.password
});
console.log(user);
this.submitted = true;
return user;
}
When I run my code now, I get a CORS error. Back in my task manager api project, I’m going to install cors using npm i cors.
In app.js file
const cors = require('cors');app.use(cors());
Now for all of my routes, access from any origin is allowed. This isn’t very secure, but for not it’ll be fine. I’ll eventually only allow my task manager app once I get it pushed to heroku. And I can successfully create a user.
Checking mongo I can see that they’re in the database too. Now I wanted to use environment variables, but for some reason it changes my url to match the current local host (localhost:8080) rather than 3000. So instead, I’ll use props to pass the url from the App component.
Template
...
<create-user :backend_url="BACKEND_URL"></create-user>...export default {
name: 'App',
data() {
return {
BACKEND_URL: 'http://127.0.0.1:3000'
};
},
components: {
CreateUser
}
};
And in CreateUser.vue
props: ['backend_url'],
methods: {
async handleSubmit() {
const user = await axios.post(this.backend_url + '/user', {
name: this.name,
email: this.email,
password: this.password
});
this.submitted = true;
}
}
Right now my second test fails because I made handleSubmit async. Now my assertion runs before things actually complete.
it('reveals a notification when submitted', done => {
const wrapper = mount(CreateUser, { localVue }); wrapper.find('[data-name]').setValue('Peter');
wrapper.find('[data-email]').setValue('peter@example.com');
wrapper.find('[data-password]').setValue('12345678');
wrapper.find('[form]').trigger('submit.prevent'); wrapper.vm.$nextTick(() => {
expect(wrapper.find('[message]').text()).toBe('User has been created');
done();
});
});
Let’s fix it by referencing the done parameter. When we use done, it let’s jest know this is an async test and for it to wait for done() to be called. Using $nextTick, you defer the callback function (in bold) to the next update cycle. My form submission should set the submitted value to true which then updates the DOM. When this update happpens, then the callback in nextTick will run. This is how I can delay my assertion until after something async happens.
The next problem is with axios. If I don’t mock the api calls in the tests, I get these errors about connections being refused. Near the top of the file, I put the following
jest.mock('axios');
No more errors, but since I’m not actually calling axios in my test, I can’t really be sure that the user was created. I’m not really sure what to do about that. All docs regarding testing says we should mock our calls, so maybe it’s enough to just know that handleSubmit was called. And I can infer that by the second test of showing a notification message. But to be really clear, let’s make sure that the wrapper doesn’t contain the message before submitting.
it('reveals a notification when submitted', done => {
const wrapper = mount(CreateUser, { localVue }); expect(wrapper.contains('[message]')).toBe(false); wrapper.find('[data-name]').setValue('Peter');
wrapper.find('[data-email]').setValue('peter@example.com');
wrapper.find('[data-password]').setValue('12345678');
wrapper.find('[form]').trigger('submit.prevent'); wrapper.vm.$nextTick(() => {
expect(wrapper.find('[message]').text()).toBe('User has been created');
done();
});
This vue testing is a little frustrating right now. I’m confused about what I should be testing and how to really go about it. I think I’ll take a break from my own project a bit and try to do some more studying about how to do vue testing better.
BREAK OVER
Okay, after a few more hours of struggling, I started to realize a few mistakes I’ve been making. One, I’ve been using shallowMount instead of mount. shallowMount will not render subcomponents, instead they will use placeholders. So in some guides I was seeing, they used shallowMount to get form data and what not, the problem is I’m using semantics ui and these are considered components not elements. So using <sui-form> instead of <form>, meant I couldn’t trigger the prevent.default. I didn’t realize when I made the switch in my code to shallowMount, so then I tried mocking some of the async requests, and I was getting a bunch of different errors. But now I’m getting a little more familiar with vue testing, so let’s get back to the project.
I made quite a number of changes, so let’s just start back with my current code (this is designated by “end of part 5” commit -https://github.com/iampeternguyen/task-manager-app).
In CreateUser.vue
methods: {
async handleSubmit() {
try {
await this.createUser({
name: this.name,
email: this.email,
password: this.password
});
this.submitted = true;
this.error = false;
} catch (error) {
this.error = error.response.data.message;
this.submitted = false;
}
},
createUser(user) {
return axios.post(this.backend_url + '/user', user);
}
I separated out the axios call to another method. This makes it easier to mock the call and create dummy data.
In test/fixtures/userfixture.js
export const createUserSuccess = {
_id: '5cf7a5f56cd00f661a7da466',
name: 'Peter',
email: 'peter@example.com',
createdAt: '2019-06-05T11:22:29.393Z',
updatedAt: '2019-06-05T11:22:29.393Z',
__v: 0
};export const createUserError = {
data: {
errors: {
email: {
message: 'Please use valid email',
name: 'ValidatorError',
properties: {
message: 'Please use valid email',
type: 'user defined',
path: 'email',
value: 'peter@ljfkfla',
reason: {}
},
kind: 'user defined',
path: 'email',
value: 'peter@ljfkfla',
reason: {}
}
},
_message: 'User validation failed',
message: 'User validation failed: email: Please use valid email',
name: 'ValidationError'
}
};
These are actual response.body’s that get sent back from the create user post request. This will be useful in mocking the data.
it('reveals error message when create user request fails', async () => {
const createUserMock = jest.fn(() => {
let error = new Error('error');
error['response'] = createUserError;
throw error;
});
wrapper.setMethods({
createUser: createUserMock
});
wrapper.find('[form]').trigger('submit.prevent');
await flushPromises();
expect(wrapper.vm.submitted).toBe(false);
expect(wrapper.vm.error).toBe(createUserError.response.data.message);
});
In my test, I’m using flushPromises so that I can use async/await rather than using the done calls.
Let’s do a few more tests.
it('Makes api request on form submit', async () => {
const createUserMock = jest.fn();
wrapper.setMethods({
createUser: createUserMock
}); wrapper.find('[form]').trigger('submit.prevent');
await flushPromises();
expect(createUserMock).toHaveBeenCalled();
});it('Makes api request with user data', async () => {
const createUserMock = jest.fn();
wrapper.setMethods({
createUser: createUserMock
});
wrapper.find('[data-name]').setValue('Peter');
wrapper.find('[data-email]').setValue('peter@example.com');
wrapper.find('[data-password]').setValue('12345678');
wrapper.find('[form]').trigger('submit.prevent');
await flushPromises();
expect(createUserMock).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Peter',
email: 'peter@example.com',
password: '12345678'
})
);
});
Here I’m making sure that my form submit will call the createUser method and that the createUser method will be called with the form data.
I think this is a good place to stop for now. Thanks for following along!