I have a service, which handles the API communication (and should be the only file to do actual API calls). I have some issues to use this service in my store.
My current logic looks like this:
To swap the service out for better testability, the service is introduced to the Vue component via a prop. My issue is, that i could not find a good way to tell the store to use this service, so i created a composable useService
to 'store' the service, which can then be imported and used in the store.
// component.vue
const props = defineProps<{
service: ServiceInterface
}>()
// Get the composable and set the service
const actualService = useService()
actualService.setService(props.service)
// Use the store
const store = useStore()
// useService.ts
const service = ref<ServiceInterface>()
export const useService = () => {
const setService = (newService: ServiceInterface) => {
service.value = newService
}
return {
service,
setService
}
}
// useStore.ts
// import composable
const { service } = useService()
export const useStore = defineStore('storeName', {
actions: {
async fetchData(id: string) {
// Ensure service is set
if (!service.value) {
throw new Error('No service')
}
// Use service
service.value.getData(id)
}
}
})
This setup does work, however i need to double check in every method that the service is available and i feel like i overcomplicated the whole setup. Has somebody any idea to improve this flow?
I guess i am looking for something like this (not working):
export const useStore = (service: ServiceInterface) => {
return defineStore('storeName', {
actions: {
async fetchData(id: string) {
service.getData(id)
}
}
})
}
I don't see any reason why you should make the service reactive. You want to be able to replace it with a mock in testing, but you don't need the ability to replace any of its methods on the fly once it has been instantiated (which is the only reason you'd want it to be reactive)
I'd go with (service.ts
):
export const someCall = (...args) => {
// make some call here, using optional args
},
export const someOtherCall = (...args) => {
// make some other call here, using optional args
}
Anywhere else:
import { someCall, someOtherCall } from '@/path/to/service'
// use them directly
Or:
import * as service from '@/path/to/service'
// use service.someCall(args)
Note: replace ...args
above with the actual arguments needed for each call.
You can replace service.ts
in your tests with ease:
jest.mock('@/path/to/service')
Or mock a specific method only:
const mockSomeOtherCall = jest.fn()
jest.mock('@/path/to/service', () => {
...jest.requireActual('@/path/to/service'),
someOtherCall: mockSomeOtherCall
})
it('calls someOtherCall', () => {
mockSomeOtherCall.mockReset()
// mount component here, then:
expect(mockSomeOtherCall).not.toHaveBeenCalled()
// trigger something that should call `someOtherCall` then:
expect(mockSomeOtherCall)
.toHaveBeeCalledWith(expectedArgumentValues);
})
Note: in the above code @/path/to/service
is generic. If, for example, you place service.ts
(or .js
) inside src/services
, the path would actually be @/services/service
. In a test suite, by using jest.mock('@/services/service')
(or any of its more specific variants), you're effectively replacing the actual contents of that module with mocks, while running the tests in that test suite.