DEV Community

peerhenry
peerhenry

Posted on

Typesafe mockable globals in Vue3

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
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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) || {})
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

  1. Putting console actions behind an interface is useful for preventing logs being printed in our test output. 

Discussion (0)