UP | HOME
Diego Rodriguez Mancini

Diego Rodriguez Mancini

Software Engineer

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:

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.

interactor-class-diagram.png

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.

store-class-diagram.png

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.