Appearance
Chapter 5 - Intro to Unit Testing While Refactoring a Bit
We will now delve into writing unit tests for our project. Unit tests serve as a critical aspect of ensuring the stability and reliability of our code. In this book, we will cover two main categories of unit tests:
- Unit tests for models, classes, structures, and interfaces (such as the API client and helpers)
- Unit tests for Vue components
Note: It's worth mentioning that there is a third category of tests, known as end-to-end (e2e) tests, but we will not be covering those in this book.
Our first step will be to write unit tests for our Vue components. We will start with the ItemsList component and while doing so, we will make some refactors to improve its implementation. The unit tests will validate the changes we make, ensuring that our code remains functional and free of bugs.
ItemComponent
Remember how in our ItemsList component we have a loop that creates <li> elements, one for each item in our items property? Let's extract the code for the <li> element and create a child component just for that. Let's start by adding a new file called Item.component.vue under the src/components/items/children directory:
Note: So far I have used the "script setup" feature to create components. For the Item.component.vue I am going to show you how to use Vue's defineComponent and Composition API. You are welcome to keep using "script setup" if you prefer. There is no particular recommended way, both ways are fine, but there seem to be a trend in our industry where developers want to write less code and that is what "script setup" tries to accomplish.
Paste the following code in the file:
vue
// file: src/components/items/children/Item.component.vue
<template>
<li :data-testid="testid" :class="cssClass" @click="handleClick">
<div class="selected-indicator">*</div>
<div class="name">{{model.name}} [{{model.selected}}]</div>
</li>
</template>
<script lang="ts">
// import reference to Vue's defineComponent
import { defineComponent, computed, PropType } from 'vue'
// import a reference to our ItemInterace
import type { ItemInterface } from '../../../models/items/Item.interface'
// create our component with defineComponent
export default defineComponent({
name: 'ItemComponent',
emits: ['selectItem'],
props: {
// add testid prop here as well
testid: {
default: 'not-set'
},
// expose a property called model of type ItemInterface
model: {
type: Object as PropType<ItemInterface>,
default: () => {
return {}
}
}
},
setup(props, { emit }) {
// a computed property to return a different css class based on the selected value
const cssClass = computed(() => {
let css = 'item'
if (props.model.selected) {
css += ' selected'
}
return css.trim()
})
// on click event handler that will emit a @select custom event
const handleClick = () => {
emit('selectItem', props.model.id)
}
return {
cssClass,
handleClick
}
}
})
</script>
<style>
li.item {
padding: 5px;
outline: solid 1px #eee;
display: flex;
align-items: center;
height: 30px;
cursor: pointer;
transition: background-color 0.3s ease;
}
li.item .name {
margin-left: 6px;
}
li.item .selected-indicator {
font-size: 2em;
line-height: 0.5em;
margin: 10px 8px 0 8px;
color: lightgray;
}
li.item.selected .selected-indicator {
color: skyblue;
}
li.item:hover {
background-color: #eee;
}
</style>
We just created a template for a single <li> element. We also enhanced this a bit by replacing the rendering of the name with the binding to name with two child <div> elements:
- one to display the Item name
- one that will show a star icon (we are just using a char here, but in the next chapters we'll be replacing this with real icons)
Then we added a computed property called cssClass that will return the string "item" or "item selected". We then bind this to the <li> class attribute, based on whether the model.selected property is true or false: <li :class="cssClass" @click="handleClick">
This will have the effect to render the <li> element in two possible ways:
- <li class="item"> (when not selected)
- <li class="item selected"> (when selected)
We also bind to the click event with @click binding and in the local handleClick handler we just invoke the parent handler by emitting the custom event "selectItem" with Vue's emit[1] and passing the item id as the event argument (props.model.id). We will then handle this in the parent component (ItemsList component).
Additionally, we have starting to add a property called testid that will bind to html attribute data-testid. This is an industry standard that allow us to more easily retrieve a particual DOM element through automation and we will leverage this in our Vitest unit tests to more easily select the rendered elements.
Note that we also added a <style> section with some css to render our <li> element a little better. The css above is just a quick-and-dirty bit of styling so we can make our list look a bit prettier for now. In later chapters we'll introduce TailwindCSS and keep working with that instead of writing our own css.
ItemsList.component.vue updates
Now let's modify the ItemsList component so we can import and consume the newly created Item component:
vue
// file: src/components/items/ItemsList.component.vue
<script setup lang="ts">
...
// import a reference to the Item component:
import ItemComponent from './children/Item.component.vue'
...
</script>
Modify the template section as follows:
vue
// file: src/components/items/ItemsList.component.vue
...
<template>
<div>
<h3>Items:</h3>
<ul>
<ItemComponent v-for="(item, index) in items"
:key="item.id"
:model="item"
@selectItem="handleClick(item)" />
</ul>
</div>
</template>
Let's also add some css at the end of our ItemsList.component.vue:
html
// file: src/components/items/ItemsList.component.vue
...
<style>
ul {
padding-inline-start: 0;
margin-block-start: 0;
margin-block-end: 0;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 0px;
}
</style>
And add some css at the end of our App.vue:
html
// file: src/App.vue
...
<style>
.home {
padding: 20px;
}
</style>
Note: we are not consuming our new Item.component.vue anywhere yet. Let's proceed first to create a unit test against it and validate that it renders and behaves as we expect.
Add unit tests support to our project
We need to configure our project to be able to run unit tests. We need to add dependencies on a few npm packages and some configuration in order to be able to add unit tests for our components.
Dependencies
Vitest[2] has become popular very quicly recently as makes unit-testing in a Vite app much easier than Jest. Please read more about it on the official website to learn more about its features.
Let's start installing our npm dependencies first.
Install the packages @vitest + @testing-library/vue + @testing-library/user-event + @jsdom:
npm install --save-dev vitest @testing-library/vue @testing-library/user-event jsdom
test-utils code
Add directory src/test-utils and within it create file index.ts. Here we are going to export all we need in our unit tests from one place, and export also a wrapper around testing-library render (customRender) that will avoid having to repeat code in our unit tests:
typescript
// file:
/* eslint-disable import/export */
import { render } from '@testing-library/vue'
const customRender = <T>(ui: T, options = {}) =>
render(ui, {
...options
})
export * from '@testing-library/vue'
export { default as userEvent } from '@testing-library/user-event'
// override render export
export { customRender as render }
Configuration
Now we need to configure a few things to be able to run unit tests.
package.json
Within the package.json file, add the following command shortcuts within the script section:
javascript
...
"scripts": {
...
"test": "vitest run",
"test-watch": "vitest watch",
"test-coverage": "vitest run --coverage"
}
...
ItemComponent Unit Tests
Add our first two unit tests again our newly created component ItemComponent.
Within the same directory where our Item.component.vue is located, add two new files:
- one called Item.rendering.test.ts
- one called Item.behavior.test.ts
Your directory structure will look now ike this:
Item.rendering.test.ts
Open the file Item.rendering.test.ts and paste the following code in it:
typescript
// file: src/components/items/children/Item.rendering.test.ts
// directive to instruct vitest to use the jsdom environment:
// @vitest-environment jsdom
// import references to what we need from our test-utils:
import { render, screen } from '../../../test-utils'
import { describe, it, expect } from 'vitest'
// import reference to our interface
import { ItemInterface } from '../../..//models/items/Item.interface'
// import reference to your Item component:
import ItemComponent from './Item.component.vue'
describe('Item.component: rendering', () => {
it('renders an Item text correctly', () => {
const model: ItemInterface = {
id: 1,
name: 'Unit test item 1',
selected: false
}
const testid = 'unit-test-appearance-1'
// render component
render<typeof ItemComponent>(ItemComponent, {
props: {
testid,
model
}
})
// get element reference by testid
const liElement = screen.getByTestId(testid)
// test
expect(liElement).not.toBeNull()
expect(liElement.innerHTML).toContain('Unit test item 1')
})
})
Here we test that the component renders the data model properties as expected. For now, we are checking if the entire html rendered by the component contains the item.name. This is not very precise as our component later might render additional labels and our test might match these instead resulting in possible false positives.
Note: These example are just to get you started. Later you can look at more precise ways to test what our component has rendered or even trigger events on them.
Run our unit tests from the terminal with this command:
npm run test
It should run the unit tests and print the results on the terminal, similar to this:
...
> my-vue-project@0.0.0 test
> vitest run
RUN v0.10.2 /Users/damianofusco/Documents/OutOfTheCage/private-my-vue3-project-2nd/my-vue-project
√ src/components/items/children/Item.rendering.test.ts (1)
Test Files 1 passed (1)
Tests 1 passed (1)
Time 784ms (in thread 14ms, 5601.05%)
...
Let's add two more tests within the same file to check that the component has the expected CSS classes. Test to check that it has the class "selected" when item.selected is true, and that does NOT have the css class "selected" when item.selected is false:
typescript
// file: src/components/items/children/Item.rendering.test.ts
...
describe('Item.component: rendering', () => {
...
it('has expected css class when selected is true', () => {
const model: ItemInterface = {
id: 1,
name: 'Unit test item 2',
selected: true /* note this is true */
}
const testid = 'unit-test-appearance-2'
// render component
render<typeof ItemComponent>(ItemComponent, {
props: {
testid,
model
}
})
// get element reference by testid
const liElement = screen.getByTestId(testid)
// test
expect(liElement).not.toBeNull()
// check that the element className attribute has the expected value
expect(liElement.className).toContain('selected')
})
it('has expected css class when selected is false', () => {
const model: ItemInterface = {
id: 1,
name: 'Unit test item 3',
selected: false /* note this is false */
}
const testid = 'unit-test-appearance-3'
// render component
render<typeof ItemComponent>(ItemComponent, {
props: {
testid,
model
}
})
// get element reference by testid
const liElement = screen.getByTestId(testid)
// test
expect(liElement).not.toBeNull()
// check that the element className attribute has the expected value
expect(liElement.className).not.toContain('selected')
})
})
Item.behavior.test.ts
We can also test the behavior of our component by programmatifcally triggering the click event. Open the file Item.behavior.test.ts and paste the following code in it:
typescript
// file: src/components/items/children/Item.behavior.test.ts
// directive to instruct vitest to use the jsdom environment:
// @vitest-environment jsdom
// import references to what we need from our test-utils:
import { render, screen, fireEvent } from '../../../test-utils'
import { describe, it, expect } from 'vitest'
// import reference to our interface
import { ItemInterface } from '../../../models/items'
// import reference to your Item component:
import ItemComponent from './Item.component.vue'
describe('Item.component: behavior', () => {
// test our component click event
it('click event invokes selectItem handler as expected', () => {
const model: ItemInterface = {
id: 1,
name: 'Unit test item 1',
selected: false
}
const testid = 'unit-test-behavior-1'
// render component
const { emitted } = render<typeof ItemComponent>(ItemComponent, {
props: {
testid,
model
}
})
// get element reference by testid
const liElement = screen.getByTestId(testid)
// fire click
fireEvent.click(liElement)
// check that it emitted the "selected" event and passed the expected param value (the model of type ItemInterface)
// this will be an object like {"selectItem":[[1]],"click":[[{"isTrusted":false}]]}
const result = emitted() as { selectItem: any[][] }
//console.log('emitted result', JSON.stringify(result))
expect(result.selectItem).not.toBeUndefined
const expectedParam = result.selectItem[0][0]
expect(expectedParam).not.toBeUndefined
expect(expectedParam).toEqual(model.id)
})
})
Save and check the test results and make sure all pass (if you had stopped it, run npm run test
again).
ItemsList component updates
Now we can finally modify our ItemsList.component.vue to consume our newly created Item component. Import a reference to ItemComponent, then replace the <li> element within the loop with our <ItemComponent>:
html
// file: src/components/items/ItemsList.component.vue
<script setup lang="ts">
// import a reference to our ItemInterace
import type { ItemInterface } from '../../models/items'
// import a reference to the Item component:
import ItemComponent from './children/Item.component.vue' // <-- add this line
// expose a property called items with a default value of a blank array
defineProps<{ items: ItemInterface[] }>()
// define emits for custom events
const emit = defineEmits<{
(e: 'selectItem', id: number): any
}>()
// selectItem handler
const onSelectItem = (id: number) => { // <-- update this block
emit('selectItem', id)
}
</script>
<template>
<div>
<h3>Items:</h3>
<ul>
<!-- replace the <li> withit his block: -->
<ItemComponent v-for="(item, index) in items"
:key="item.id"
:model="item"
@selectItem="onSelectItem" />
</ul>
</div>
</template>
Note how we are handling the selectItem
event using the @selectItem="onSelectItem"
binding and just re-emit to the parent view (App.vue).
If you are not already running the app, run it. In the web browser, the list should now render similar to this (here we are showing it after we clicked on the 2nd item element and is now selected)
Additional Configuration
You probably noticed a little annoying thing where we have to use relatives paths when importing references, i.e. import X from '../../../test-utils'
.
Before we finally conclude this chapter, let's do s a bit more configuration so we can start using a shortcut like @ and avoid relative paths, i.e. import X from '@/test-utils'
.
First, install @types/node (needed for TypeScript type references that will use within vite.config.ts file):
npm install -D @types/node
vite.config.ts
Let's add type references to vitest and vite/client and then configure the test section so that we won't have to import things like describe/it/expectfrom vitest in each unit test file. Let's also add envDir and resolve sections in order to use the @ shortcut. Finally, add also a server section eith the open option so that it automatically opens the web browser to our app url when we run the app:
typescript
// file: vite.config.ts
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'url'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
open: 'http://localhost:3000'
},
envDir: './src/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
test: {
globals: true,
environment: 'jsdom',
exclude: [
'node_modules'
]
}
})
tsconfig.json updates
Add these at the end of the compilerOptions section:
json
// file: tsconfig.json
{
"compilerOptions": {
...
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
"types": [
"vue",
"vite/client",
"vitest/globals"
]
}
...
}
You can now go back and update reltive paths imports like '../../../test-utils' to just '@/test-utils', or '../../../models' to '@/models' etc. Furthermore, you can remove the line import { describe, it, expect } from 'vitest'
from both Item.behavior.tests.ts
and Item.rendering.test.ts
Chapter 5 Recap
What We Learned
- How to write unit tests against a component
- How to test that components render specific DOM elements, or have specific text, or attributes like CSS classes, etc.
- How to test events on our components by programmatically triggering them with fireEvent (from Vue Testing Library [2:1])
- How to re-factor parts of a component to create a child component and use unit tests to validate our changes
Observations
- We did not test our ItemsList.component.vue or more advanced behaviors
Based on these observations, there are a few improvements that you could make:
Improvements
- Add additional unit tests for ItemsList.component.vue as well