<- all articles

How writing component tests improved my component cleanliness

Jaimy Schatteman

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.

So, you started dusting and mopping your 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:

  • Meaningful names;
  • Small functions (Components?);
  • Functions should do one thing (Components?);
  • DRY (Don't Repeat Yourself);
  • Explain yourself in code;
  • Separation of concerns.

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.

Why even bother testing components?

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.

But how did your components become cleaner by testing them?

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:

  • Small functions (Components?): If you can explain what your component does in one sentence, you have done an excellent job;
  • DRY (Don't Repeat Yourself): Extracting TodosList did the job here;
  • Explain yourself in code: Splitting up components gives more information to the reader, and so does extracting all logic into the script tag;
  • Separation of concerns: <template> now only holds information regarding displaying content, <script> collects and manipulates data and passes it to <template>

Writing the test

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.

Conclusion

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?

Related articles