Implementing a Clean Architecture Modular Application in Nuxt/Vue Typescript Part 4: UI Components
Published on Feb 28, 2021 by Diego Rodriguez Mancini.
Introduction
This is the last post on this series of creating a nuxt clean architecture demo application. In the previous part, we implemented the use case interactor and the Vuex store. This time we are going to create a home page view and a component and inject the vuex module to this home page.
Stuff we will se in this post:
- Class components with Nuxt-Property-Decorator
- Dependency injection with Inversify-Props
- Unit testing a Vue + Vuetify component
For the complete example visit the code at this repository and also the other parts of the series: Part 1, Part 2 and Part 3.
The Home Page
Since this is just an example application, I’m going to build a very simple UI. The Home page will have a list of TaskCard components. The Home page will be fetching tasks at mount and passing the task data to the TaskCard component as props.
Figure 1: Component and store class diagram
If we could represent Vue components in UML it would look something like the above diagram. The HomePage uses the storage to access TaskStore and passes task data as a prop to the TaskCard component. It is a very simple setup, but if the app gets more complex, we could have many components using the TaskStore for example. If that is the case, we could inject the Storage to every component using Inversify-Props, or we could use provide and inject from Vue, inversify-props injecting the Storage to the HomePage, and the HomePage providing the store to required components.
Tests first
Testing Vue components, and in particular, Vuetify components we need to take some considerations: We need to use a local vue instance provided by Vue-Test-Utils and a Vuetify instance that we are going to pass to the mount function.
import { createLocalVue, mount } from "@vue/test-utils" import { cid, container, mockTransient, resetContainer } from "inversify-props" import Vuex from 'vuex' import Vue from 'vue' import Vuetify from 'vuetify' import { containerBuilder } from "../../plugins/inversify" //@ts-ignore import HomePage from '@ui/pages/index.vue' import { ITaskRepository } from "@@/src/app/modules/task/domain" import { MockTaskService } from "@@/src/app/modules/task/infrastructure/__tests__/TaskService.mock" import { TestActions } from "@@/src/app/shared/http/HttpService.mock" import { IStorage } from "@@/src/app/shared/storage/Storage" import { mockTaskData } from "@@/src/app/modules/task/domain/__tests__/Task.mock" import flushPromises from 'flush-promises' describe('HomePage', () => { const localVue = createLocalVue() localVue.use(Vuex) Vue.use(Vuetify) let vuetify: Vuetify beforeEach(() => { resetContainer() containerBuilder() mockTransient<ITaskRepository>(cid.TaskService, MockTaskService) vuetify = new Vuetify() }) it('mounts and renders tasks', async () => { container.bind<TestActions>('ActionType').toConstantValue('ok') const wrapper = mount(HomePage, { localVue, vuetify, }) await flushPromises() const titles = wrapper.findAll('.v-card__title') expect(titles.length).toBe(mockTaskData().length) const storage = container.get<IStorage>(cid.Storage) const store = storage.getStores().taskStore expect(store.taskList).toStrictEqual(mockTaskData()) })
One important thing here is the flushPromises
function. It comes from the flush-promises package and it is used to wait for all promises to finish before executing the test assertions. Vue does a lot of things asynchronously, for example in this test case, mounting the component and fetching data from the mock API take several promises to finish. Using nextTick
here didn’t work, so as suggested by the Vuetify docs, I decided to go with flush-promises.
The rest of the test is similar to all the tests we have done using inversify-props container in previous posts. One particular assertion, checks that the number of cards is equal to the number of tasks, so we know that the component is rendering the correct data. The next assertion checks that the store has the right data too, although, this is not really needed since the rendered data is coming from the store, if the rendered data is fine, then the store should be fine too.
We can now add test cases for errors. For this we are expecting that errors will be shown in a v-alert
component.
it('mounts and renders server error', async () => { container.bind<TestActions>('ActionType').toConstantValue('serverError') const wrapper = mount(HomePage, { localVue, vuetify, }) await flushPromises() expect(wrapper.find('.v-alert__content').text()).toContain('Server Error') const storage = container.get<IStorage>(cid.Storage) const store = storage.getStores().taskStore expect(store.taskList).toStrictEqual([]) expect(store.error).toBeInstanceOf(HttpError) }) it('mounts and renders client error', async () => { container.bind<TestActions>('ActionType').toConstantValue('clientError') const wrapper = mount(HomePage, { localVue, vuetify, }) await flushPromises() expect(wrapper.find('.v-alert__content').text()).toContain('Client Error') const storage = container.get<IStorage>(cid.Storage) const store = storage.getStores().taskStore expect(store.taskList).toStrictEqual([]) expect(store.error).toBeInstanceOf(HttpError) }) it('mounts and renders parse error', async () => { container.bind<TestActions>('ActionType').toConstantValue('parseError') const wrapper = mount(HomePage, { localVue, vuetify, }) await flushPromises() expect(wrapper.find('.v-alert__content').text()).toContain('Parse Error') const storage = container.get<IStorage>(cid.Storage) const store = storage.getStores().taskStore expect(store.taskList).toStrictEqual([]) expect(store.error).toBeInstanceOf(ParseError) })
The HomePage code
As described before, we are injecting the Storage and getting the TaskStore instance from it to fetch tasks at mount. With Nuxt-Property-Decorator, getters are computed properties, so we are using 4 getters to obtain data from the store, one for tasks, for serverError, clientError and parseError.
<template> <v-row justify="center" align="center"> <v-col v-if="serverError"> <v-alert>Server Error: {{ serverError }}</v-alert> </v-col> <v-col v-if="clientError"> <v-alert>Client Error: {{ clientError }}</v-alert> </v-col> <v-col v-if="parseError"> <v-alert>Parse Error: {{ parseError }}</v-alert> </v-col> <v-col v-for="task in tasks" :key="task.title" cols="12" sm="8" md="6"> <task-card :task="task" /> </v-col> </v-row> </template> <script lang="ts"> import { inject } from 'inversify-props' import { Vue, Component } from 'nuxt-property-decorator' import { Storage } from '@@/src/app/shared/storage/Storage' import TaskCard from '@@/src/app/modules/task/ui' import { isHttpError } from '@@/src/app/shared/http' import { ParseError } from '@@/src/app/shared/error/ParseError' @Component({ components: { TaskCard }, }) export default class Home extends Vue { @inject() private storage!: Storage async mounted() { await this.storage.getStores().taskStore.fetchTasks() } get tasks() { return this.storage.getStores().taskStore.taskList } get serverError() { const error = this.storage.getStores().taskStore.error if (!error) return null if (isHttpError(error)) { if (error.isServerError()) return error.message } return null } get clientError() { const error = this.storage.getStores().taskStore.error if (!error) return null if (isHttpError(error)) { if (error.isClientError()) return error.message } return null } get parseError() { const error = this.storage.getStores().taskStore.error if (!error) return null if (isHttpError(error)) return null if (error instanceof ParseError) { return error.message } return null } } </script>
For the template we are using conditional rendering to change between error and task data. The tasks are shown in TaskCard components that are created in the v-for
sentence.
The TaskCard component
This is a very simple component. It only gets task data as props and displays the title and description of the task. Feel free to add buttons to change state and more functionalities.
<template> <v-card> <v-card-title> {{ task.title }} </v-card-title> <v-card-text> {{ task.description }} </v-card-text> </v-card> </template> <script lang="ts"> import { Vue, Component } from 'nuxt-property-decorator' const TaskProps = Vue.extend({ props: { task: Object, }, }) @Component export default class TaskCard extends TaskProps {} </script>
Back to the HomePage tests
Now the tests are passing. If we run all tests we get the following output.
PASS src/app/modules/task/domain/__tests__/Task.spec.ts PASS src/app/modules/task/infrastructure/__tests__/TaskService.spec.ts PASS src/app/modules/task/usecase/__tests__/GetMany.spec.ts PASS src/app/modules/task/storage/__tests__/TaskStore.spec.ts PASS src/ui/pages/__tests__/Home.spec.ts ---------------------------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ---------------------------------|---------|----------|---------|---------|------------------- All files | 93.44 | 82 | 86 | 93.16 | app/modules/task/domain | 93.33 | 87.5 | 100 | 93.33 | Task.ts | 92.31 | 87.5 | 100 | 92.31 | 23 index.ts | 100 | 100 | 100 | 100 | app/modules/task/infrastructure | 72.73 | 37.5 | 44.44 | 70.97 | TaskParser.ts | 61.54 | 37.5 | 50 | 61.54 | 18-32 TaskService.ts | 76.47 | 100 | 42.86 | 73.33 | 19-28 index.ts | 100 | 100 | 100 | 100 | app/modules/task/storage | 100 | 100 | 100 | 100 | TaskStore.ts | 100 | 100 | 100 | 100 | index.ts | 100 | 100 | 100 | 100 | app/modules/task/ui | 100 | 100 | 100 | 100 | index.ts | 100 | 100 | 100 | 100 | app/modules/task/usecase | 100 | 100 | 100 | 100 | GetMany.ts | 100 | 100 | 100 | 100 | app/shared/error | 100 | 100 | 100 | 100 | ParseError.ts | 100 | 100 | 100 | 100 | app/shared/http | 95.37 | 90 | 87.5 | 95.24 | HttpError.ts | 92.31 | 100 | 80 | 91.67 | 32 HttpService.ts | 86.67 | 50 | 90 | 85.71 | 37,78-79,103 HttpStatusCode.ts | 100 | 100 | 100 | 100 | index.ts | 100 | 100 | 100 | 100 | app/shared/storage | 100 | 50 | 100 | 100 | Storage.ts | 100 | 50 | 100 | 100 | 16 ui/pages | 96.15 | 94.44 | 100 | 96 | index.vue | 96.15 | 94.44 | 100 | 96 | 64 ---------------------------------|---------|----------|---------|---------|------------------- Test Suites: 5 passed, 5 total Tests: 19 passed, 19 total Snapshots: 0 total Time: 4.393 s Ran all test suites.
Conclusion
We finished a demo application with a simple but complete use case. In this last part we implemented and tested Vue components created with Nuxt-Property-Decorator and Vuetify.
I hope this was helpful to you, and remember, this should not be treated as a guide that you can follow to build any application, treat it more like an explained example.
If you want to keep playing with the code or add more functionalities, the repository is available at https://gitlab.com/dirodriguezm/nuxt-clean-architecture. For questions, commentaries or corrections you can contact me at diegorodriguezmancini@gmail.com