Implementing a Clean Architecture Modular Application in Nuxt/Vue Typescript Part 3: Vuex Store
Published on Feb 26, 2021 by Diego Rodriguez Mancini.
Introduction
This is the third post in a series of posts about creating a modular application using Nuxt typescript, implementing the Clean Architecture design pattern. Here you can find the first and second posts.
In part 2, we implemented a TaskService that fetched tasks from an API. We used dependency injection to create an HttpService and tested this services using a mocked axios instance.
Now, we are going to implement an interactor class, for the use case of fetching multiple tasks. This interactor will use the previously created service, and be called by the Vuex store using the executor or command pattern.
Stuff we will see in this post:
- Vuex class modules using Nuxt Property Decorator
- Unit testing Vuex modules
- Dependency injection using Inversify-Props
- Command design pattern
Interactor class
The first thing we need to do before implementing the store, is to create the use case interactor GetMany
. This is a class that will make use of the TaskService to fetch the tasks through an API, and the interactor itself will be used by the Vuex store that we will create afterwards.
If we look at the following class diagram, we see that the interactor class contains an instance of the TaskService (the implementation of the TaskService was covered in the previous post), and it contains a single method execute
.
Figure 1: Interactor class diagram
Unit test for the interactor class
The interactor’s execute
method should call the service and execute a callback with the results from the service. In the specification file we should make a test case for each callback: success, client error, server error and parse error.
import { cid, container, mockTransient, resetContainer } from "inversify-props" import { containerBuilder } from "@@/src/ui/plugins/inversify" import { ParseError } from "@@/src/app/shared/error/ParseError" import { HttpError } from "@@/src/app/shared/http" import { TestActions } from "@@/src/app/shared/http/HttpService.mock" import { ITaskRepository } from "../../domain" import { mockTaskData } from "../../domain/__tests__/Task.mock" import { MockTaskService } from "../../infrastructure/__tests__/TaskService.mock" import { GetMany } from "../GetMany" beforeEach(() => { resetContainer() containerBuilder() mockTransient<ITaskRepository>(cid.TaskService, MockTaskService) }) describe('GetMany', () => { it('should call success callback', async () => { container.bind<TestActions>('ActionType').toConstantValue('ok') const getMany = new GetMany() const mockCallbacks = { respondWithSuccess: jest.fn(), respondWithClientError: jest.fn(), respondWithParseError: jest.fn(), respondWithServerError: jest.fn() } await getMany.execute(null, mockCallbacks) expect(mockCallbacks.respondWithSuccess).toHaveBeenCalledWith(mockTaskData()) }) it('should call client error callback', async () => { container.bind<TestActions>('ActionType').toConstantValue('clientError') const getMany = new GetMany() const mockCallbacks = { respondWithSuccess: jest.fn(), respondWithClientError: jest.fn(), respondWithParseError: jest.fn(), respondWithServerError: jest.fn() } await getMany.execute(null, mockCallbacks) expect(mockCallbacks.respondWithClientError.mock.calls[0][0]).toBeInstanceOf(HttpError) }) it('should call server error callback', async () => { container.bind<TestActions>('ActionType').toConstantValue('serverError') const getMany = new GetMany() const mockCallbacks = { respondWithSuccess: jest.fn(), respondWithClientError: jest.fn(), respondWithParseError: jest.fn(), respondWithServerError: jest.fn() } await getMany.execute(null, mockCallbacks) expect(mockCallbacks.respondWithServerError.mock.calls[0][0]).toBeInstanceOf(HttpError) }) it('should call parse error callback', async () => { container.bind<TestActions>('ActionType').toConstantValue('parseError') const getMany = new GetMany() const mockCallbacks = { respondWithSuccess: jest.fn(), respondWithClientError: jest.fn(), respondWithParseError: jest.fn(), respondWithServerError: jest.fn() } await getMany.execute(null, mockCallbacks) expect(mockCallbacks.respondWithParseError.mock.calls[0][0]).toBeInstanceOf(ParseError) }) })
Notice that we are mocking the TaskService, but instead we could have used the api mock that we created in the previous post. For this test let’s create a MockTaskService class that returns mock data, or error depending on the ActionType that we are injecting. The data returned on success is from the domain layer we created in the first post.
import { ParseError } from "@@/src/app/shared/error/ParseError"; import { HttpError } from "@@/src/app/shared/http"; import { TestActions } from "@@/src/app/shared/http/HttpService.mock"; import { inject } from "inversify-props"; import { err, ok, Result } from "neverthrow"; import { ITaskData, ITaskRepository } from "../../domain"; import { mockTaskData } from "../../domain/__tests__/Task.mock"; export class MockTaskService implements ITaskRepository { actionType: TestActions constructor(@inject('ActionType') actionType: TestActions) { this.actionType = actionType } create(): Promise<Result<ITaskData, ParseError | HttpError>> { throw new Error("Method not implemented."); } remove(): Promise<Result<ITaskData, ParseError | HttpError>> { throw new Error("Method not implemented."); } edit(): Promise<Result<ITaskData, ParseError | HttpError>> { throw new Error("Method not implemented."); } getMany(): Promise<Result<ITaskData[], ParseError | HttpError>> { if (this.actionType === 'ok') { return new Promise((resolve) => { resolve(ok(mockTaskData())) }) } else if (this.actionType === 'clientError') { return new Promise((reject) => { reject(err(new HttpError(404))) }) } else if (this.actionType === 'serverError') { return new Promise((reject) => { reject(err(new HttpError(500))) }) } else { return new Promise((reject) => { reject(err(new ParseError('Error parsing data'))) }) } } getOne(): Promise<Result<ITaskData, ParseError | HttpError>> { throw new Error("Method not implemented."); } }
The code for the interactor class
The interactor class follows the execute pattern. The single execute method should receive an object with callbacks for different effects. If the result of the service call is successful, a callback is executed, if it is an error a callback for each error type is executed.
We are defining a type
for the Callbacks of the use case interactor and an interface with the execute method.
import { ParseError } from '@@/src/app/shared/error/ParseError' import { HttpError, isHttpError } from '@@/src/app/shared/http' import { inject } from 'inversify-props' import { ITaskData, ITaskRepository } from '../domain' export type Callbacks = { respondWithSuccess(data: ITaskData[]): void respondWithClientError(error: HttpError): void respondWithServerError(error: HttpError): void respondWithParseError(error: ParseError): void } export interface IUseCase { execute(params: any, callbacks: Callbacks): void } export class GetMany implements IUseCase { @inject() taskService!: ITaskRepository async execute( _params: any, { respondWithSuccess, respondWithClientError, respondWithServerError, respondWithParseError, }: Callbacks ): Promise<void> { const result = await this.taskService.getMany() result.map((tasks) => { respondWithSuccess(tasks) }).mapErr((error) => { if (isHttpError(error)) { if (error.isClientError()) { respondWithClientError(error) return } respondWithServerError(error) } respondWithParseError(error) }) } }
We can now run the tests and everything should pass.
Vuex Store
We have finished the application layer, now we need to start working with the store and user interface. Nuxt has a particular way for working with Vuex, it is integrated in Nuxt to facilitate the creation of Vuex modules. Each file in the Store subdirectory is treated as a module and the actual initialization of the Vuex store is done under the hood as soon as we have a file in said subdirectory.
This application is said to be modular, but tying up the store creation to the Nuxt application is not really modular and prevents us from testing the store. So, to fix that, we are going to inject the store modules to the components and initialize the store manually as a Nuxt plugin.
The first step is to create a store factory class to initialize the store with its modules, and then a provider class that will be injected to the Vue components.
NOTE: This provider class is needed because the store classes can’t be initialized as any other class and instead need to be accessed with getModule
. For more information on this, see the documentation of Vuex Module Decorators.
Figure 2: Store and storage provider class diagram
Store creator and Storage
import Vuex, { Store } from 'vuex' import { inject } from 'inversify-props' import { getModule } from 'nuxt-property-decorator' import { ITaskState, TaskStore } from '../../modules/task/storage' export interface IRootState { tasks: ITaskState } export interface IStoreCreator { makeStore(): Store<IRootState> } export class StoreCreator implements IStoreCreator { private store: Store<IRootState> | null = null makeStore(): Store<IRootState> { if (!this.store) { this.store = new Vuex.Store<IRootState>({ modules: { tasks: TaskStore, }, }) } return this.store } } export interface IStorage { getStores(): { taskStore: TaskStore } } export class Storage implements IStorage { private taskStore: TaskStore constructor(@inject() storeCreator: IStoreCreator) { const store = storeCreator.makeStore() this.taskStore = getModule(TaskStore, store) } getStores() { return { taskStore: this.taskStore } } }
The first class is the store creator. It has a makeStore
method that initializes the Vuex.Store. As you can see, the StoreCreator is a singleton, it will always return the same store instance. The second class, Storage, is a provider of all our store modules, it makes use of the StoreCreator to access the store module using getModule
method. The getStores
method is used to retreive an object containing all “registered” store modules. We are only using a single module, but eventually this Storage class will contain more and more modules.
Remember to update the container with these classes as well. The inversify.ts
plugin now looks like this:
import 'reflect-metadata' import { cid, container } from 'inversify-props' import { AxiosCreator, HttpService, IAxiosCreator, IHttpService } from '@@/src/app/shared/http' import { ITaskRepository } from '@@/src/app/modules/task/domain' import { TaskService } from '@@/src/app/modules/task/infrastructure' import { GetMany, IUseCase } from '@@/src/app/modules/task/usecase/GetMany' import { IStorage, IStoreCreator, StoreCreator, Storage } from '@@/src/app/shared/storage/Storage' export function containerBuilder() { container.addTransient<IAxiosCreator>(AxiosCreator) container.addTransient<IHttpService>(HttpService) container.addTransient<ITaskRepository>(TaskService) container.addTransient<IUseCase>(GetMany) container.addSingleton<IStoreCreator>(StoreCreator) container.addSingleton<IStorage>(Storage) } containerBuilder()
TaskStore
Tests first
The tests for the store module are very similar to the tests of the TaskService and GetMany interactor tests, we want to make a test case for a success service call, an http error case and finally a parse error case.
Testing Vuex
For testing Vuex we need to make a local vue instance, provided by vue-test-utils, and add Vuex to that local instance.
import { createLocalVue } from '@vue/test-utils' import Vuex from 'vuex' const Vue = createLocalVue() Vue.use(Vuex)
Now we can procede with tests as usual. Since we are using inversify-props, we make a beforeEach
helper function that builds our container and adds the MockTaskService to it.
import { cid, container, mockTransient, resetContainer } from "inversify-props" import { createLocalVue } from '@vue/test-utils' import { containerBuilder } from "@@/src/ui/plugins/inversify" import { TestActions } from "@@/src/app/shared/http/HttpService.mock" import { IStorage } from "@@/src/app/shared/storage/Storage" import { HttpError } from "@@/src/app/shared/http" import { ParseError } from "@@/src/app/shared/error/ParseError" import Vuex from 'vuex' import { ITaskRepository } from "../../domain" import { MockTaskService } from "../../infrastructure/__tests__/TaskService.mock" import { mockTaskData } from "../../domain/__tests__/Task.mock" const Vue = createLocalVue() Vue.use(Vuex) beforeEach(() => { resetContainer() containerBuilder() mockTransient<ITaskRepository>(cid.TaskService, MockTaskService) }) describe('TaskStore', () => { describe('Actions', () => { describe('fetchTasks', () => { it('should respond with success', async () => { container.bind<TestActions>('ActionType').toConstantValue('ok') const storage = container.get<IStorage>(cid.Storage) const store = storage.getStores().taskStore await store.fetchTasks() expect(store.taskList).toStrictEqual(mockTaskData()) expect(store.error).toBeNull() expect(store.loading).toBeFalsy() }) it('should respond with client error', async () => { container.bind<TestActions>('ActionType').toConstantValue('clientError') const storage = container.get<IStorage>(cid.Storage) const store = storage.getStores().taskStore await store.fetchTasks() expect(store.taskList).toStrictEqual([]) expect(store.error).toBeInstanceOf(HttpError) expect(store.loading).toBeFalsy() }) it('should respond with server error', async () => { container.bind<TestActions>('ActionType').toConstantValue('serverError') const storage = container.get<IStorage>(cid.Storage) const store = storage.getStores().taskStore await store.fetchTasks() expect(store.taskList).toStrictEqual([]) expect(store.error).toBeInstanceOf(HttpError) expect(store.loading).toBeFalsy() }) it('should respond with parse error', async () => { container.bind<TestActions>('ActionType').toConstantValue('parseError') const storage = container.get<IStorage>(cid.Storage) const store = storage.getStores().taskStore await store.fetchTasks() expect(store.taskList).toStrictEqual([]) expect(store.error).toBeInstanceOf(ParseError) expect(store.loading).toBeFalsy() }) }) }) })
The test cases are similar, they bind an ActionType value used for the service mock,then use the storage from the container to retreive the store module. Finally, after calling the fetchTasks
action that we are testing here, we verify that the state was updated accordingly. That is, checking that the taskList
is either an empty array or a valid data array, but we are checking the error and loading state as well.
TaskStore code
The TaskStore class uses Nuxt-Property-Decorator or more specifically Vuex-Module-Decorators. It is pretty much straight foward if you have seen how these decorators work. Class properties are treated as state, methods can be decorated with @VuexMutation
or @VuexAction
to make mutations and actions. Vuex getters are class getters.
The important part of this code relies in the use case interactor that we are injecting. The fetchTask
method calls the interactor’s execute method, and passes mutations as callbacks. Well, we are actually passing a function that calls the mutations, but the idea is that we are telling the interactor to execute these mutations on every different result scenario, but the interactor has no idea of the store implementation.
import { inject } from 'inversify-props' import { Module, VuexModule, VuexMutation, VuexAction } from 'nuxt-property-decorator' import { ITaskData } from '../domain' import { GetMany } from '../usecase/GetMany' export interface ITaskState { taskList: ITaskData[] error: Error | null loading: boolean } @Module({ namespaced: true, name: 'tasks', stateFactory: true }) export class TaskStore extends VuexModule implements ITaskState { @inject() getMany!: GetMany taskList: ITaskData[] = [] error: Error | null = null loading: boolean = false @VuexMutation setTaskList(tList: ITaskData[]) { this.taskList = tList } @VuexMutation setError(error: Error | null) { this.error = error } @VuexMutation setLoading(loading: boolean) { this.loading = loading } @VuexAction({ rawError: true }) async fetchTasks() { this.setLoading(true) this.getMany.execute(null, { respondWithSuccess: (data: ITaskData[]) => { this.setTaskList(data) this.setError(null) this.setLoading(false) }, respondWithClientError: (error: Error) => { this.setError(error) this.setTaskList([]) this.setLoading(false) }, respondWithServerError: (error: Error) => { this.setError(error) this.setTaskList([]) this.setLoading(false) }, respondWithParseError: (error: Error) => { this.setError(error) this.setTaskList([]) this.setLoading(false) } }) } }
If we execute the tests now they should pass.
Vuex plugin
The last part is to allow our Store creator available to the application in the served environment. As I said earlier, we are going to manually create the Vuex store instead of using Nuxt’s automatic initialization. Let’s create a vuex.ts
file in src/ui/plugins
that calls the store creator and injects the store to the Vue instance.
import { cid, container } from "inversify-props" import Vuex, { Store } from 'vuex' import Vue from 'vue' import { StoreCreator } from "@@/src/app/shared/storage/Storage" Vue.use(Vuex) const storeCreator = container.get<StoreCreator>(cid.StoreCreator) declare module 'vue/types/vue' { interface Vue { $store: Store<any> } } Vue.prototype.$store = storeCreator.makeStore()
Conclusion
In this part of the series we finished the application layer, implementing the GetMany use case interactor following the executor
or command
design pattern. We later injected this interactor to the TaskStore module, and to make it testable, we are using a store factory and a store provider.
The factory initializes the Vuex store and it has two purposes:
- Initialize the store for the whole application inside a Nuxt plugin
- Provide a singleton store instance to use by the provider
The provider is used for two purposes as well:
- When injected to a component, it provides store modules instances.
- We can create custom or mocked store instances for testing components.