Testing en componentes de Vue.js

0
7429

Índice de contenidos

1. Introducción

En este tutorial aplicaremos los principios de TDD al desarrollo de componentes en Vue.js, asumiendo un conocimiento intermedio de Vue.js y un entendimiento básico de tests unitarios.

Vamos a usar las librerías de testing Vue-Test-Utils y Jest, y Typescript para el tipado estático.

Vue Test Utils es la librería de testing oficial para Vue.js. Nos da mucha facilidad a la hora de montar componentes, simular eventos de usuario, renderizado superficial, modificar el estado y los props de componentes y más.

Jest es el motor de tests mantenido y usado por Facebook. La mayor ventaja de Jest sobre otros frameworks de tests es la velocidad y que no es necesario configurar casi nada para usarlo.

Typescript es un superset de Javascript de Microsoft que introduce muchas características nuevas al lenguaje. Puede que la más notable sea el fuerte tipado estático.

TDD o Test-driven development es un proceso de desarrollo de software que sigue un ciclo muy corto:

  1. Se definen los requerimientos del software.
  2. Se crean tests para cubrir esos requerimientos.
  3. Se implementa el software para pasar los tests.

Si se necesita ampliar el software, se repite el mismo proceso. No se puede crear software que no tenga tests cubriéndolo.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: MacBook Pro 15’ (2,5 GHz Intel Core i7, 16GB DDR3)
  • Sistema operativo: macOS Sierra 10.12.6
  • Entorno de desarrollo: Visual Studio Code
  • Versión de Vue: 2.5.17

3. Creación del proyecto.

Vamos a usar Vue CLI 3, una herramienta de terminal que nos ayuda a la hora de
crear la estructura de carpetas del proyecto.

Para instalarlo de forma global es tan sencillo como:

<pre class="">npm install -g @vue/cli
# o
yarn global add @vue/cli

Ahora que tenemos el CLI instalado, abrimos el terminal en nuestra carpeta de proyectos y lo ejecutamos:

<pre class="">vue create [nombre de proyecto]

O si no queremos instalar el CLI globalmente:

<pre class="lang:sh decode:true">npx @vue/cli create [nombre de proyecto]

El CLI nos preguntará si queremos crear un proyecto predeterminado o queremos personalizarlo. Nosotros vamos a elegir manualmente.

Estas son las opciones que he elegido para este proyecto:

El CLI nos pedirá algunos detalles más sobre la configuración que hemos elegido:

Lo más importante a destacar en esta parte es que vamos a usar Jest.

4. Definición de los requerimientos.

Antes de ponernos a picar código como locos, debemos definir unos requerimientos claros.

A riesgo de ser cliché, vamos a desarrollar una aplicación de tareas, o la infame Todo List. Estos son los requerimientos de la aplicación.

  • Debe tener una lista de items. Cada item tendrá:
    • Un título
    • Un botón con texto ‘complete’. Cuando sea clicado:
      • El título debe ser tachado.
      • El botón debe ser deshabilitado y su texto cambiar a ‘completed’.
  • Debe tener un campo de entrada de texto.
  • Debe tener un botón.
    • Si la entrada de texto está vacía, el botón debe estar deshabilitado.
    • Si la entrada de texto no está vacía, cuando sea clicado:
      • Debe añadir un nuevo item con el título de la entrada de texto.
      • Debe vaciar la entrada de texto.

Personalmente, en esta etapa me suele ayudar mucho dibujar la aplicación en papel o en una herramienta de prototipado de interfaces, para ver claramente los componentes que voy a necesitar.

De la especificación podemos deducir que vamos a necesitar dos componentes:

  • TodoList: un contenedor que guardará y modificará el estado de los hijos.
  • TodoItem: un componente que recibirá por props lo que debe renderizar.

5. Las bases de Testing en componentes.

A la hora de testear componentes, no necesitamos hacer testing de características de Vue, asumimos que estas va a funcionar normalmente.

Nuestro objetivo es asegurar que la lógica que nosotros introducimos sigue el comportamiento especificado. Debemos testear:

  • Lifecycle hooks: comprobar que una función es llamada cuando el componente se monta, se destruye…
  • Métodos: comprobar que el retorno es el esperado o que se ha cambiado el estado correctamente.
  • Watchers: cuando se modifica un prop o método, asegurar que el watcher es invocado.
  • Propiedades computadas: comprobar que el retorno es el esperado.

Snapshots

Los snapshots son ‘fotos’ de nuestro componente renderizado. Cada vez que pasemos los tests de un componente, una nueva ‘foto’ es sacada.

El motor de tests nos avisará si la nueva foto coincide con la anterior. Si no es así, nos avisará.

Esto nos sirve para asegurarnos de que los cambios que hacemos que hacemos «por debajo» de un componente no afectan a la forma en la que este se renderiza. Son «gratis» (muy sencillos de implementar) y está bien tenerlos en todos los componentes que tengan algo de HTML a renderizar.

Mount y ShallowMount

mount() y shallowMount() son las funciones que nos permiten montar nuestro componente dentro de los tests.

Mount nos montará el componente y todos los componentes hijos mientras que shallowMount solo montará el componente en cuestión.

Por lo general es recomendable usar shallowMount antes que mount porque mantiene los tests aislados y unitarios y se tarda menos en ejecutar el test.

Usa mount cuando quieras probar la integración entre distintos componentes.

Triggers

Para simular la interacción con el usuario, podemos disparar eventos de muchos tipos con trigger().

6. Desarrollo de un componente. TodoItem.

Testing.

Este es un componente muy tonto, no tiene lógica de negocio, solo de vista.

Vamos a comprobar:

  • El componente renderiza el mismo ‘snapshot’ que la última vez.
  • Si ‘isCompleted’ es ‘false’, el texto del botón es ‘complete’.
  • Si ‘isCompleted’ es ‘true’, el texto del botón es ‘completed’.
  • Si ‘isCompleted’ es ‘true’, el botón debería deshabilitarse.

Creamos el fichero TodoItem.spec.ts en la carpeta de tests y nos ponemos a ello.

<pre class="lang:javascript">  
  import { shallowMount, Wrapper } from "@vue/test-utils";
  import TodoItem from "@/components/TodoItem.vue";

  describe("TodoItem.vue", () => {
    let id: number;
    let title: string;
    let isCompleted: boolean;
    let onClick;
    let wrapper: Wrapper<TodoItem>;

    // Montamos el componente con los props necesarios antes de cada test.
    beforeEach(() => {
      id = 1;
      title = "Test Title";
      isCompleted = false;
      onClick = () => {};

      wrapper = shallowMount(TodoItem, {
        propsData: {
          id,
          title,
          isCompleted,
          onClick
        }
      });
    });

    // Nos aseguramos que nadie ha modificado el componente sin modificar los tests.
    it("should match snapshot", () => {
      expect(wrapper).toMatchSnapshot();
    });

    // Si 'isCompleted' es 'false', el texto del botón es 'complete'.
    it("should change button text to 'complete' when 'isCompleted' is false", () => {
      // Cuando 'isCompleted' es falso.
      wrapper.setProps({ isCompleted: false });

      // Nos aseguramos de que el texto del botón cambia a 'complete'.
      expect(wrapper.find("button").text()).toBe("complete");
    });

    // Si 'isCompleted' es 'true', el texto del botón es 'completed'.
    it("should change button text to 'completed' when 'isCompleted' is true", () => {
      // Cuando 'isCompleted' es verdadero.
      wrapper.setProps({ isCompleted: true });

      // Nos aseguramos de que el texto del botón cambia a 'completed'.
      expect(wrapper.find("button").text()).toBe("completed");
    });

    // Si 'isCompleted' es 'true', el botón debería deshabilitarse.
    it("should disable button when 'isCompleted' is true", () => {
      // Cuando 'isCompleted' es verdadero.
      wrapper.setProps({ isCompleted: true });

      // Nos aseguramos que el botón es deshabilitado.
      expect(wrapper.find("button").attributes("disabled")).toMatch("disabled");
    });
  });

Implementación.

Ahora que nuestros tests cubren todo el comportamiento del componente, podemos empezar la implementación.

<pre class="lang:javascript decode:true "><template>
  <div class="todo-item">
    <span :class="{ completed: isCompleted }">{{ title }}</span>
    <button @click="onClick(id)" :disabled="isCompleted">{{ buttonText }}</button>
  </div>
</template>

<script lang="ts">
import Vue from "vue";

export default Vue.extend({
  name: "TodoItem",
  props: {
    id: {
      type: Number,
      required: true
    },
    title: {
      type: String,
      required: true
    },
    isCompleted: {
      type: Boolean,
      required: true
    },
    onClick: {
      type: Function,
      required: true
    }
  },
  computed: {
    buttonText(): String {
      return this.isCompleted ? "completed" : "complete";
    }
  }
});
</script>

<style scoped>
.completed {
  text-decoration: line-through;
}

.todo-item {
  margin: 10px;
}
</style>

Para probar que nuestra implementación pasa los tests, usamos:

<pre class="">npm run test:unit
# o
yarn test:unit

7. Desarrollo de un contenedor. TodoList.

Testing.

Este es un contenedor con algo de lógica y que contiene todos los TodoItems a renderizar.

Vamos a comprobar:

  • El componente renderiza el mismo ‘snapshot’ que la última vez.
  • Si la entrada de texto está vacía, el botón debe estar deshabilitado.
  • Si la entrada de texto no está vacía, cuando el botón sea clicado:
    • Debe añadir un nuevo TodoItem con el título de la entrada de texto.
    • Debe vaciar la entrada de texto.

Creamos el fichero TodoItem.spec.ts en la carpeta de tests y nos ponemos a ello.

<pre class="lang:js decode:true">import { Wrapper, shallowMount, mount } from "@vue/test-utils";
import TodoList from "@/containers/TodoList.vue";

describe("TodoList.vue", () => {
  let wrapper: Wrapper;

  beforeEach(() => {
    wrapper = mount(TodoList);
  });

  // Nos aseguramos de que la imagen del componente es la misma.
  it("should match snapshot", () => {
    expect(wrapper).toMatchSnapshot();
  });

  // Si la entrada de texto está vacía, el botón debe estar deshabilitado.
  it("should disable the button if input text is empty", () => {
    // Cuando el input text esté vacío.
    wrapper.vm.$data.newTodo = "";

    // Aseguramos que el botón está deshabilitado.
    expect(wrapper.find("button").attributes("disabled")).toMatch("disabled");
  });

  // Clicar el botón añadirá un nuevo TodoItem a la lista, con el título adecuado.
  it("should add a new todo to the array and update the view when the button is clicked and the text input is not empty", () => {
    // Le damos valor al input text.
    wrapper.vm.$data.newTodo = "new todo title test";
    // Simulamos un click del usuario
    wrapper.find("button").trigger("click");

    // Aseguramos que el nuevo 'TodoItem' tiene como título el que nosotros le hemos dado.
    expect(wrapper.find(".todo-item").text()).toMatch("new todo title test");
  });

  // Clicar el botón vaciará el campo de texto si la entrada de texto no está vacía.
  it("should empty the text input when the button is clicked and the text input is not empty", () => {
    // Le damos valor al input text.
    wrapper.vm.$data.newTodo = "new todo title test";
    // Simulamos un click del usuario
    wrapper.find("button").trigger("click");

    // Aseguramos que el campo de texto está vacío.
    expect(wrapper.vm.$data.newTodo).toBe("");
  });

  // Clicar en el botón de un 'TodoItem', le dará true al valor 'isCompleted'.
  it("should update 'TodoItem' 'isCompleted' to true when its button is clicked", () => {
    wrapper.vm.$data.todos.push({ title: "title test", isCompleted: false });
    wrapper.find(".todo-item button").trigger("click");
    expect(wrapper.vm.$data.todos[0].isCompleted).toBe(true);
  });

  // La propiedad computada 'isButtonEnabled' deberá funcionar como previsto.
  it("should return a valid 'isButtonEnabled' computed property", () => {
    wrapper.vm.$data.newTodo = "";
    expect(wrapper.vm.isButtonEnabled).toBe(false);
    wrapper.vm.$data.newTodo = "test";
    expect(wrapper.vm.isButtonEnabled).toBe(true);
  });
});

Implementación.

Ahora que los tests cubren todo el comportamiento del contenedor, podemos empezar la implementación.

<pre class="lang:js decode:true ">  <template>
<div class="todo-list">
  <input type="text" placeholder="New task..." v-model="newTodo"/>
  <button @click="addNewTodo" :disabled="!isButtonEnabled">+</button>
  <TodoItem v-for="(todo, key) in todos"
  :id="key"
  :key="key"
  :title="todo.title" 
  :isCompleted="todo.isCompleted" 
  :onClick="setTodoCompleted">
  </TodoItem>
</div>
</template>

<script lang="ts">
import Vue from "vue";
import TodoItem from "@/components/TodoItem.vue";

interface ITodo {
title: string;
isCompleted: boolean;
}

export default Vue.extend({
name: "TodoList",
components: {
  TodoItem
},
data: (): {
  todos: ITodo[];
  newTodo: string;
} => ({
  todos: [],
  newTodo: ""
}),
computed: {
  isButtonEnabled(): boolean {
    return this.newTodo !== "";
  }
},
methods: {
  addNewTodo() {
    this.todos.push({ title: this.newTodo, isCompleted: false });
    this.newTodo = "";
  },
  setTodoCompleted(index: number) {
    this.todos[index].isCompleted = true;
  }
}
});
</script>
 

Para probar que nuestra implementación pasa los tests, usamos de nuevo:

<pre class="lang:shell">npm run test:unit
# o
yarn test:unit

8. Conclusiones.

Vue Test Utils nos permite testear la funcionalidad de una aplicación Vue de principio a fin.

En este tutorial nos hemos centrado en el testing de componentes, pero también se puede testear otras partes de Vue como Vue-Router o Vuex.

Échale un vistazo!

El testeo del software es tan importante en el backend como en el frontend, no dejes mal a tus colegas ‘fronteros’ y testea como el que más!

Puedes clonar y probar el proyecto con:

git clone https://github.com/ArturoRodriguezRomero/Vue-Component-Testing-Example

cd Vue-Component-Testing-Example

yarn install

yarn serve

O descargarlo desde Github:

Proyecto en Github

9. Referencias.

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad