At my current assignment, we recently started converting e2e tests to component tests to reduce the duration of our CI/CD pipeline. While performing this task, I noticed how my components became cleaner and more robust. In this blog, I will share how writing component tests increases the cleanliness of our components.
Well... No. When talking about the cleanliness of code, I, of course, refer to the renowned book Clean Code by Robert C. Martin. It is well-known among coders and a must-read when writing code in a team. While it mainly covers backend code, most principles and rhetoric can also be mapped onto frontend code. For example, I look at a component as functions (because they are just that). They take in and manipulate data and return a result (DOM elements).
Here are some key concepts mentioned in the book:
If you need to become more familiar with these concepts and why they matter, make sure you read Robert's book or Google it because that lies beyond the scope of this blog.
Because no one is perfect! Even though you trust your own competence, mistakes are easily made. Writing tests reduces the chance of bugs slipping through the net. When writing code, one must protect themselves from themselves (and others). Chances are the component is refactored in a later stadium, breaking some vital functionalities without even knowing about it. This is mitigated by writing tests.
That is a good question, indeed. So let's look at an example of some dirty code (written in Vue 2.7 Composition API). I'll explain why my code becomes cleaner while writing the test. Of course, I tried making it as dirty as possible 😏 to prove a point.
Side note: I intentionally left out styling and edge cases because I wanted the focus to lie solely on the template and script while keeping it short-ish.
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue';
import { sendGetAllTodosRequest, sendUpdateTodoRequest } from '@/services/todos';
import { Todo } from './todo.types';
export default defineComponent({
name: 'TodoPage',
setup() {
const todos = ref<Todo[]>([]);
onMounted(async (): Promise<void> => {
todos.value = await sendGetAllTodosRequest();
});
const toggleTodoCompletion = async (id: string, previousCompleted: boolean): Promise<void> => {
await sendUpdateTodoRequest(id, { completed: !previousCompleted });
todos.value = await sendGetAllTodosRequest();
};
return { todos, newTodoDescription, toggleTodoCompletion };
}
});
</script>
<template>
<main>
<section v-if="todos.length > 0">
<div v-if="todos.filter(({ completed }) => !completed).length > 0">
<h2>Todo</h2>
<ul>
<li v-for="{ id, description, completed } in todos.filter(({ completed }) => !completed)" :key="id">
<input :checked="completed" type="checkbox" @change="toggleTodoCompletion(id, completed)" />
<p>
{{ description.charAt(0).toLocaleUpperCase() + description.slice(1) }}
</p>
</li>
</ul>
</div>
<div v-if="todos.filter(({ completed }) => completed).length > 0">
<h2>Done</h2>
<ul>
<li v-for="{ id, description, completed } in todos.filter(({ completed }) => completed)" :key="id">
<input :checked="completed" type="checkbox" @change="toggleTodoCompletion(id, completed)" />
<p>
{{ description.charAt(0).toLocaleUpperCase() + description.slice(1) }}
</p>
</li>
</ul>
</div>
</section>
<section v-else>
<p>You haven't created a todo yet</p>
</section>
</main>
</template>
So, how do I go about testing this component? Depending on the props and state, the component will take different 'paths' to its destination. When testing it, you want to think about all the possible 'paths' it can take to end up there. Then it would be best to find a way to assert whether it arrived there correctly.
I usually scan through a component's template to see all those paths. But looking at this component makes me dizzy. Like any task, it can be split into multiple smaller tasks. In the context of a component, it means splitting up into smaller subcomponents. So let's do that.
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue';
import TodoList from './todos/TodoList.vue';
import { sendGetAllTodosRequest } from '@/services/todos';
import { Todo } from '../todo.types';
export default defineComponent({
name: 'TodoPage',
components: { TodoList },
setup() {
const todos = ref<Todo[]>([]);
const getTodos = async (): Promise<void> => {
todos.value = await sendGetAllTodosRequest();
};
onMounted(async (): Promise<void> => {
await getTodos();
});
return { todos, getTodos };
}
});
</script>
<template>
<main>
<section v-if="todos.length > 0">
<todo-list
heading="Todo"
:todos="todos.filter(({ completed }) => !completed)"
@refresh-todos="getTodos"
/>
<todo-list
heading="Done"
:todos="todos.filter(({ completed }) => completed)"
@refresh-todos="getTodos"
/>
</section>
<section v-else>
<p>You haven't created a todo yet</p>
</section>
</main>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import TodoListItem from './TodoListItem.vue';
import { Todo } from '../todo.types';
interface TodoListProps {
heading: string;
todos: Todo[];
}
export default defineComponent<TodoList>({
name: 'TodoList',
components: { TodoListItem },
emits: ['refresh-todos'],
props: {
heading: {
type: String,
required: true
},
todos: {
type: Array,
required: true
}
}
});
</script>
<template>
<div v-if="todos.length > 0">
<h2>{{ heading }}</h2>
<ul>
<todo-list-item
v-for="{ id, description, completed } in todos"
:key="id"
:todo-id="id"
:description="description"
:completed="completed"
v-on="$listeners"
/>
</ul>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { sendUpdateTodoRequest } from '@/services/todos';
interface TodoListItemProps {
todoId: string;
description: string;
completed: boolean;
}
export default defineComponent<TodoListItemProps>({
name: 'TodoListItem',
props: {
todoId: {
type: String,
required: true
},
description: {
type: String,
required: true
},
completed: {
type: Boolean,
required: true
}
},
emits: ['refresh-todos'],
setup(props, { emit }) {
const toggleTodoCompletion = async (): Promise<void> => {
await sendUpdateTodoRequest(props.todoId, { completed: !props.completed });
emit('refresh-todos');
};
return { toggleTodoCompletion };
}
});
</script>
<template>
<li>
<input :checked="completed" type="checkbox" @change="toggleTodoCompletion" />
<p>
{{ description.charAt(0).toLocaleUpperCase() + description.slice(1) }}
</p>
</li>
</template>
While it has become a lot more readable. We could still take it further, increasing the visibility of all 'paths'. Moving all logic away from the template and into the setup() function.
<script lang="ts">
import { computed, defineComponent, onMounted, ref } from 'vue';
import TodoList from './todos/TodoList.vue';
import { sendGetAllTodosRequest } from '@/services/todos';
import { Todo } from '../todo.types';
export default defineComponent({
name: 'TodoPage',
components: { TodoList },
setup() {
const todos = ref<Todo[]>([]);
const getTodos = async (): Promise<void> => {
todos.value = await sendGetAllTodosRequest();
};
onMounted(async (): Promise<void> => {
await getTodos();
});
const hasTodos = computed<boolean>(() => todos.value.length > 0);
const uncompletedTodos = computed<Todo[]>(() => {
return todos.value.filter(({ completed }) => !completed);
});
const completedTodos = computed<Todo[]>(() => {
return todos.value.filter(({ completed }) => completed);
});
return { hasTodos, uncompletedTodos, completedTodos, getTodos };
}
});
</script>
<template>
<main>
<section v-if="hasTodos">
<todo-list heading="Todo" :todos="uncompletedTodos" @refresh-todos="getTodos" />
<todo-list heading="Done" :todos="completedTodos" @refresh-todos="getTodos" />
</section>
<section v-else>
<p>You haven't created a todo yet</p>
</section>
</main>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import TodoListItem from './TodoListItem.vue';
import { Todo } from '../todo.types';
interface TodoListProps {
heading: string;
todos: Todo[];
}
export default defineComponent<TodoListProps>({
name: 'TodosListt',
components: { TodoListItem },
props: {
heading: {
type: String,
required: true
},
todos: {
type: Array,
required: true
}
},
emits: ['refresh-todos'],
setup(props) {
const hasTodos = computed<boolean>(() => {
return !!props.todos && props.todos.length > 0;
});
return {
hasTodos
};
}
});
</script>
<template>
<div v-if="hasTodos">
<h2>{{ heading }}</h2>
<ul>
<todo-list-item
v-for="{ id, description, completed } in todos"
:key="id"
:todo-id="id"
:description="description"
:completed="completed"
v-on="$listeners"
/>
</ul>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import { sendUpdateTodoRequest } from '@/services/todos';
export default defineComponent({
name: 'TodoListItem',
props: {
todoId: {
type: String,
required: true
},
description: {
type: String,
required: true
},
completed: {
type: Boolean,
required: true
}
},
emits: ['refresh-todos'],
setup(props, { emit }) {
const toggleTodoCompletion = async (): Promise<void> => {
await sendUpdateTodoRequest(props.todoId, { completed: !props.completed });
emit('refresh-todos');
};
const sentenceCasedDescription = computed<string>(() => {
return props.description.charAt(0).toLocaleUpperCase() + props.description.slice(1);
});
return { toggleTodoCompletion, sentenceCasedDescription };
}
});
</script>
<template>
<li>
<input :checked="completed" type="checkbox" @change="toggleTodoCompletion" />
<p>{{ sentenceCasedDescription }}</p>
</li>
</template>
Because of these changes, we checked off multiple of the clean code criteria:
This is how I would write the test for TodoPage:
it('should have called sendGetAllTodosRequest on mount', () = { ... });
it('should render todo-lists when user has todos', () = { ... });
it('should only pass uncompleted todos to first todos-list', () = { ... });
it('should only pass completed todos to second todos-list', () = { ... });
it('should call getTodos when todos-list emits refresh-todos-event', () = { ... })
it('should render no-todos text when user has 0 todos', () = { ... });
These statements easily flow out of the way the code is written. And because the components are small, the tests are too. And a clear overview remains.
While everything works in the first and second iterations, I still prefer the third by a long shot. Your team members could interpret all logic in the first template, but it doesn't mean they should.
Written by
Jaimy Schatteman
Want to know more?