Typesafe mockable globals in Vue3
peerhenry
Posted on April 29, 2022
Typically in large apps there are plenty of things that you need global access to throughout the codebase; things like locale, environment, feature flags, settings etc. Then there are also functions that are useful to be globally accessible, like notifications, console actions or formatters. Then - assuming you are working with typescript - it's nice to have all of them properly typed. And finally - assuming you are writing tests (for example using jest or vitest) - it's nice if all of this can be properly controlled (mocked) in automated tests.
How do we achieve this?
Let's say my application is called 'Peer'. I will begin by defining an interface that will contain some useful globals; specifically a string that we can use for date formatting and some console actions1 :
PeerGlobals.ts
export interface PeerGlobals {
log: (m: string) => void
logError: (m: string) => void
defaultDateFormat: string
}
Then I will implement and provide it in a plugin:
PeerPlugin.ts
import { App, Plugin } from 'vue'
import { PeerGlobals } from 'PeerGlobals'
export const PeerPlugin: Plugin {
install(app: App) {
const globals: PeerGlobals = {
log: console.log,
logError: console.error,
defaultDateFormat: 'yyyy-MM-dd',
}
app.provide('globals', globals)
}
}
main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { PeerPlugin } from './PeerPlugin'
const app = createApp(App)
// use any other plugin here like Router or Pinia
app.use(PeerPlugin)
app.mount('#app')
Now in any component we can do this:
MyComponent.vue
<script lang="ts" setup>
import type { PeerGlobals } from '@/PeerGlobals'
const globals = inject('globals') as PeerGlobals
</script>
As for testing, I will make a file mockPeerGlobals.ts
which I can then use in any test that mount any components that depend on these globals:
mockPeerGlobals.ts
import type { PeerGlobals } from '@/PeerGlobals'
export const mockPeerGlobals: PeerGlobals = {
log: () => {},
logError: () => {},
defaultDateFormat: 'yyyy-MM-dd',
}
MyComponent.spec.ts
import { mount } from '@vue/test-utils'
import { mockPeerGlobals } from 'mockPeerGlobals'
import MyComponent from '@/components/MyComponent.vue'
function mountMyComponent() {
return mount(MyComponent, {
global: {
provide: {
globals: mockPeerGlobals
}
}
})
}
// ...tests
Assertions on global functions
in mockPeerGlobals.ts
the log functions are empty stubs, but typically you will want to replace them with mock functions so you can assert that they have been called as expected - (for example using jest.fn()
in jest or vi.fn()
in vitest). Just be sure to properly reset all mocks before running a test.
Using window
and document
Sometimes we need access to window
and document
, which is typically not available within a test environment. Therefore it is useful to also put these behind our global interface. However these objects contain a huge amount of properties, so mocking those will be way too much work. Instead we can use some typescript magic called mapped types to make all properties optional:
PeerGlobals.ts
type MockWindow = {
[k in keyof Window]?: Window[k]
}
type MockDocument = {
[k in keyof Document]?: Document[k]
}
export interface PeerGlobals {
window: (Window & typeof globalThis) | MockWindow
document: Document | MockDocument
// ...other globals
}
Now in our mock globals we only need to implement the functions that are relevant for our tests. Supposing querySelectorAll
is the only one we are using:
mockPeerGlobals.ts
import type { PeerGlobals } from '@/PeerGlobals'
export const mockPeerGlobals: PeerGlobals = {
window: {},
document: {
querySelectorAll: () => []
},
// ...other globals
}
What if we want mock implementations on a per test basis?
Exporting a mock object as we did in mockPeerGlobals.ts
is somewhat restrictive: All tests are forced to use the same globals object. But sometimes we need test-specific mock implementations. Let's change mockPeerGlobals.ts
to support this, where we will use a helper function from the Ramda library; mergeDeepRight
:
mockPeerGlobals.ts
import { mergeDeepRight } from 'ramda'
import type { PeerGlobals } from '@/PeerGlobals'
// ...define default globals
export function getMockPeerGlobals(overrides?: Partial<PeerGlobals>): PeerGlobals {
return mergeDeepRight(mockPeerGlobals, (overrides as any) || {})
}
Now in a test we can override any property on any level of nesting, without affecting the rest of the globals:
MyComponent.spec.ts
import { mount } from '@vue/test-utils'
import { mockPeerGlobals } from 'mockPeerGlobals'
import MyComponent from '@/components/MyComponent.vue'
function mountMyComponent() {
return mount(MyComponent, {
global: {
provide: {
globals: getMockPeerGlobals({
document: {
querySelectorAll: () => []
}
// the rest of globals remain unaffected
})
}
}
})
}
// ...tests
-
Putting console actions behind an interface is useful for preventing logs being printed in our test output. ↩
Posted on April 29, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.