DEV Community

Cover image for Part 4 (c): Unit Testing: How to Build a To-do App with Vue.Js
Makanju Oluwafemi
Makanju Oluwafemi

Posted on

Part 4 (c): Unit Testing: How to Build a To-do App with Vue.Js

Welcome back! In our previous article, we explored the core features of our app and introduced three essential utility components: BaseButton,BaseModal, and BaseInput. In this section, we'll dive into the world of unit testing and learn how to ensure that our utility components work as expected.

We'll continue enhancing our To-Do App by adding comprehensive unit tests for the utility components built in the previous section. Our goal is to build a robust application while ensuring that critical parts of its functionality are thoroughly tested. Let's dive into the test coverage.

If you've followed this series from the beginning, you won't have any problem adding tests to your project. However, if you are new, kindly check here for more information on how to get started.

Writing Tests for BaseButton

Understanding the BaseButton component is important if we are going to write an effective test that covers all scenarios that are expected. Let's briefly recap the key features of the BaseButton component.

  • It's a reusable button component.
  • It accepts props for label, variant, and disabled.
  • The button's appearance and behavior depend on these props.
  • It emits a custom 'clk' event when clicked.

src/components/utility/BaseButton.vue

<template>
   <button
      :class="['p-3 text-center w-full rounded-4xl', variantClass ]"
      :disabled="disabled"
      :aria-disabled="disabled"
      @click="handleClick"
      type="submit"
   >
    {{ label }}
  </button>
</template>

<script>
   export default {
      name:'BaseButton',
      props: {
         label: String,
         variant: {
            type: String,
            default: 'primary',
         },
         disabled: Boolean,
      },
      computed: {
         variantClass(){
            if(this.disabled){
               return  `variant-${this.variant}-disabled`;
            }
            return `variant-${this.variant}`;
         }
      },
      methods: {
         handleClick() {
            this.$emit('clk');
         },
      }
   }
</script>

<style>
   /* variant style */
   .variant-primary {
      background-color: #414066;
      color: #fff;
   }
   .variant-primary-disabled {
      background-color: #414066c3;
      cursor: not-allowed;
   }
   .variant-secondary {
      background-color: #4C6640;
      color: #fff;
   }
   .variant-secondary-disabled {
      background-color: #4c6640b4;
      cursor: not-allowed;
   }
</style>
Enter fullscreen mode Exit fullscreen mode

In this component, the element's appearance and behavior are dynamically controlled by the variant prop. The variantClass computed property generates a CSS class based on the prop's value. If the button is disabled, it appends the -disabled suffix to the variant prop's value, creating a specific class like variant-primary-disabled or variant-secondary-disabled. These classes are then applied to the button, altering its visual style and behavior. For instance, if the variant prop is set to 'primary' and disabled is true, the button will have the variant-primary-disabled class, giving it a disabled appearance with a different background color and a 'not-allowed' cursor. This dynamic class assignment ensures flexible and reusable button styling based on the variant prop, making it easy to create different button styles throughout the application.

src/components/__test__/__utils/BaseButton.spec.ts

import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import AppButton from '../../utility/BaseButton.vue';

describe('AppButton', () => {
  it('renders primary button properly', () => {
    const wrapper = mount(AppButton, {
      props: {
        label: 'Click me',
        variant: 'primary',
        disabled: false,
      },
    });

    // Check if the button has the correct label
    expect(wrapper.text()).toContain('Click me');
    // Check if the button has the correct classes for primary variant
    expect(wrapper.classes()).toContain('variant-primary');
    expect(wrapper.classes()).not.toContain('variant-primary-disabled');
  });

  it('renders disabled primary button properly', () => {
    const wrapper = mount(AppButton, {
      props: {
        label: 'Click me',
        variant: 'primary',
        disabled: true,
      },
    });

    // Check if the button has the correct classes for disabled primary variant
    expect(wrapper.classes()).toContain('variant-primary-disabled');
    expect(wrapper.classes()).not.toContain('variant-primary');    
    // Check if the 'disabled' attribute is set correctly
    expect(wrapper.attributes('aria-disabled')).toBe('true');
  });
});
Enter fullscreen mode Exit fullscreen mode

In the provided unit tests for the BaseButton component, there are several assertions made to ensure that the component behaves correctly. Assertions are statements that check whether a certain condition or expectation holds true.

Test Case 1 - Renders Primary Button Properly:

  • In this test, we mount the AppButton component with specific props: label is set to 'Click me', variant is set to 'primary', and disabled is false.
  • We then check if the button correctly displays the label 'Click me' using expect(wrapper.text()).toContain('Click me').
  • Next, we ensure that the button has the class variant-primary, indicating it's a primary button, with expect(wrapper.classes()).toContain('variant-primary'). Additionally, we confirm that it does not have the variant-primary-disabled class, which is expected for disabled primary buttons.

Test Case 2 - Renders Disabled Primary Button Properly:

  • In this test, we mount the AppButton component with props: label set to 'Click me', variant set to 'primary', and disabled set to true.
  • We validate that the button now has the variant-primary-disabled class, indicating it's a disabled primary button, using expect(wrapper.classes()).toContain('variant-primary-disabled').
  • To ensure it's not mistakenly identified as a standard primary button, we confirm that it does not have the variant-primary class with expect(wrapper.classes()).not.toContain('variant-primary').
  • Finally, we check if the aria-disabled attribute is set to 'true' using expect(wrapper.attributes('aria-disabled')).toBe('true').

Writing Tests for BaseInput

The "BaseInput" component is a versatile input field that supports dynamic styling on focus, error handling, and two-way data binding. It features an input element, a label, and optional error message display. This component is highly customizable and allows for easy integration of input forms into Vue.js applications, providing a user-friendly and accessible experience.

src/components/utility/BaseInput.vue

<template>
   <div class="div">
      <label 
      class="size-2xl"
      :for="id">{{ label }}</label>
      <input 
         :id="id"
         :class="[isFocused && 'focused']"
         class="p-2 border-2 border-[#eee] w-full rounded-xl"
         :type="text"
         :placeholder="placeHolder"
         @focus="isFocused = true"
         @blur="isFocused = false"
         @input="handleText"
         :value="value"
         :aria-label="label"
         :aria-describedby="`${id}-description`"
      >
      <div 
         v-if="error" 
         class="h-[10px] mt-1">
         <span class="text-[red]">{{ msg }}</span>
      </div>
   </div>
</template>

<script>
   export default {
      name:'BaseInput',
      props: {
         id: String,
         label:String,
         text: String,
         placeHolder: String,
         value: [String , Number],
         error: {
            type: Boolean,
            default: false,
         },
         msg: {
            type: String,
            msg: '',
         }
      },
      data(){
         return {
           isFocused: false,
         }
      },
      methods: {
         handleText(e){
           this.$emit('update:modelValue', e.target.value )
         }
      }
   }
</script>

<style scoped>
.focused{
   outline: 2px solid #414066;
   box-shadow: 0px 2px 5px rgba(0, 0, 0, .3);
}
</style>
Enter fullscreen mode Exit fullscreen mode

src/components/__test__/__utils/BaseInput.spec.ts

import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import AppInput from '../../utility/BaseInput.vue';

describe('AppInput', () => {
  it('renders input and label properly', () => {
    const wrapper = mount(AppInput, {
      props: {
        id: 'input-id',
        label: 'Username',
        placeHolder: 'Enter your username',
        value: '',
      },
    });

    const label = wrapper.find('label');
    const input = wrapper.find('input');

    // Check if the label and input are rendered correctly
    expect(label.exists()).toBe(true);
    expect(input.exists()).toBe(true);

    // Check if the label text and input placeholder are set correctly
    expect(label.text()).toBe('Username');
    expect(input.attributes('placeholder')).toBe('Enter your username');
  });

  it('applies focus styles on input when focused', async () => {
    const wrapper = mount(AppInput, {
      props: {
        id: 'input-id',
        label: 'Username',
        placeHolder: 'Enter your username',
        value: '',
      },
    });

    const input = wrapper.find('input');

    // Check if focus styles are not applied initially
    expect(input.classes()).not.toContain('focused');

    // Simulate focusing on the input
    await input.trigger('focus');

    // Check if focus styles are applied
    expect(input.classes()).toContain('focused');

    // Simulate blurring the input
    await input.trigger('blur');

    // Check if focus styles are removed after blurring
    expect(input.classes()).not.toContain('focused');
  });

  it('emits input event when input value changes', async () => {
   const wrapper = mount(AppInput, {
      props: {
        id: 'input-id',
        label: 'Username',
        placeHolder: 'Enter your username',
        value: '',
      },
   });

   const input = wrapper.find('input');

   // Simulate typing into the input
   await input.setValue('john_doe');

   // Check if the input event is emitted with the new value
   expect(wrapper.emitted('update:modelValue')).toBeTruthy();
   expect(wrapper.emitted('update:modelValue')[0]).toEqual(['john_doe']);
 });
});
Enter fullscreen mode Exit fullscreen mode

Test Case 1 - Renders input and label properly: This test ensures that the "AppInput" component correctly renders both an input field and a label. It checks the following:

  • Verifies that a element exists in the component's rendered output. Verifies that an element exists in the component's rendered output.
  • Checks if the label text is set to "Username" as expected. Verifies that the input element's placeholder attribute is set to "Enter your username."

Test Case 2 - Applies focus styles on input when focused: This test checks whether the component correctly applies focus styles to the input field when it receives focus and removes them when it loses focus. It does the following:

  • Initially checks that the input element does not have the "focused" class.
  • Simulates focusing on the input element.
  • Checks if the "focused" class is added to the input element.
  • Simulates blurring the input element.
  • Checks if the "focused" class is removed from the input element.

Test Case 3 - Emits input event when input value changes: - - This test ensures that the component emits an "input" event when the input field's value changes. It verifies the following:

  • Simulates typing the text "john_doe" into the input field.
  • Checks if the "update:modelValue" event is emitted.
  • Verifies that the emitted event contains the expected value, which is "john_doe."

These test cases collectively ensure that the "AppInput" component works as expected, rendering elements correctly, applying styles appropriately.

Writing Tests for BaseModal

src/components/utility/BaseModal.vue

<template>
   <div class="flex items-center justify-center h-[100%] bg-[#242222b7] w-full">   
      <div class="bg-[#fff] w-1/5 h-2/5 flex flex-col drop-shadow-sm rounded-lg">
         <span class="flex items-center justify-end  p-4 right-0">
            <font-awesome-icon 
            @click="handleClose"
            class="mb-5 text-2xl c-tst cursor-pointer" :icon="['fa', 'times']" /> 
         </span>
         <div class="flex flex-col items-center justify-center w-full">    
            <font-awesome-icon class="text-7xl mb-5 text-[green]" :icon="['fa', 'circle-check']" />
            <h1 class="text-4xl font-popins font-bold">Success</h1>
         </div>
      </div>
   </div>
</template>

<script>
   export default {
      name:'AppModal',
      methods: {
         handleClose(){
            this.$emit('close')
         }
      }
   }
</script>
Enter fullscreen mode Exit fullscreen mode

This component, named "AppModal," represents a simple modal dialog for displaying success messages or notifications. It features a centered design with a semi-transparent dark background, a white content container with a drop shadow, and a close button (represented by a "times" icon) at the upper right corner. When the close button is clicked, it emits a 'close' event, allowing parent components to control the modal's closure behavior. The modal's content includes a large green checkmark icon and a bold "Success" text, making it suitable for conveying positive messages or feedback to users in a clean and visually appealing manner.

src/components/__test__/__utils/BaseModal.spec.ts

import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import BaseModal from '../../utility/BaseModal.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; 
import { library } from '@fortawesome/fontawesome-svg-core';
import { faTimes, faCircleCheck } from '@fortawesome/free-solid-svg-icons';
library.add(faTimes, faCircleCheck);


describe('BaseModal' , () => {
   it('emits close event when close button is clicked', async() => {
      const wrapper = mount(BaseModal , {
         global: {
            components: {
               FontAwesomeIcon,
            }
         }
      })

      // Lets assume this selector is correct
      const closeButton = wrapper.find('.c-tst'); 
      // Simulate clicking the close button
      await closeButton.trigger('click');
      // Check if the close event is emitted
      expect(wrapper.emitted('close')).toBeTruthy();
   })
})
Enter fullscreen mode Exit fullscreen mode

This code checks if the component emits a 'close' event when a close button is clicked.it mounts the BaseModal component, simulates a click on the close button, and verifies if the 'close' event is emitted. It also imports Font Awesome icons for rendering.

Test Case 1 - Emits 'close' Event on Close Button Click:

  • it('emits close event when close button is clicked', async() => { ... });: This is the description of the first test case.

  • const wrapper = mount(BaseModal , { ... });: It mounts (renders) the BaseModal component for testing.

  • const closeButton = wrapper.find('.c-tst');: It selects the close button within the BaseModal component. The .c-tst class selector is assumed to be correct.

  • await closeButton.trigger('click');: It simulates a click on the close button using the trigger method. The await keyword is used because this operation is asynchronous.

  • expect(wrapper.emitted('close')).toBeTruthy();: It verifies that the 'close' event is emitted by the BaseModal component. If the event is emitted, the test passes; otherwise, it fails.

Run the test by typing the command npm run test on your terminal. The result below shows that the test passed for all three components.
Image description

Top comments (0)