How to unit test a Vue composable with TypeScript

Blurred image of the testComposable function mentioned in the post

Introduction

Have you ever tried to test a Vue composable function ("use" function) and hit this error?

SyntaxError: Must be called at the top of a `setup` function

I sure have. I added in-source tests for composable function in a project I'm working on that does currency formatting, but once I updated that composable to use Vue I18n number formatting, I hit that syntax error.

Why would adding I18n to the function change how it needs to be tested? The Vue documentation explains that certain composable functions can only be used in a setup function:

A composable depends on a host component instance when it uses the following APIs:

  • Lifecycle hooks
  • Provide / Inject

https://vuejs.org/guide/scaling-up/testing.html#testing-composables

Lo' and behold, Vue I18n uses provide/inject by default: https://vue-i18n.intlify.dev/guide/advanced/composition.html#implicit-with-injected-properties-and-functions. That means to test a similar composable, we need our test to include a setup function.

So what is the best way to unit test a Vue composable function? Let's find out.

First steps

Let's not reinvent the wheel.

Vue provides an example of how to test a composable, but the example uses vanilla JavaScript and out-of-the box it isn't type-safe. Let's check it out:

import { createApp } from 'vue'

export function withSetup(composable) {
  let result
  const app = createApp({
    setup() {
      result = composable()
      // suppress missing template warning
      return () => {}
    }
  })
  app.mount(document.createElement('div'))
  // return the result and the app instance
  // for testing provide/unmount
  return [result, app]
}

I'm a big TypeScript user, so let's add types to that example (see the T):

import { createApp } from 'vue'

export function withSetup<T>(composable: () => T) {
  let result: T
  const app = createApp({
    setup() {
      result = composable()
      // suppress missing template warning
      return () => {}
    }
  })
  app.mount(document.createElement('div'))
  // return the result and the app instance
  // for testing provide/unmount
  return [result, app]
}

This is looking pretty good! TypeScript knows that the return type of withSetup is the same as the return type of the composable function.

But there's a wrinkle. Using this code in a test causes the following type error:

Variable 'result' is used before being assigned. ts(2454)

A limitation of using this method to test a composable is that it isn't fully type-safe because result can be undefined. Because result is only set in the setup() function of createApp() but is returned right away, TypeScript rightly lets us know of a race condition where we're using result while it still might not be set.

Hack Solution 1: definite assignment

Because of how Vue works, result likely will be set after app.mount(), though, so we can let TypeScript know this with a definite assignment assertion, which is specified with a ! after a variable name.

import { createApp } from 'vue'

export function withSetup(composable) {
  let result
  const app = createApp({
    setup() {
      result = composable()
      // suppress missing template warning
      return () => {}
    }
  })
  app.mount(document.createElement('div'))
  // return the result and the app instance
  // for testing provide/unmount
  return [result!, app] // <-- JF: TypeScript hack so result is defined
}

Using definite assignment assertions like this can paper over runtime issues in your code, like race conditions. I try to avoid them as a matter of course.

A more type-safe way to do this would be to throw an error if result is undefined:

if (result === undefined) throw new Error('result is undefined')
return [result, app] // <-- no more '!'

Solution 2: Promise

This is the type-safe solution I came up with to test a Vue composable.

This method side-steps the race condition of whether result is defined by using a Promise, which guarantees that result is defined when it is used in a test.

// @/utils/testing.ts
import { createApp } from 'vue'

/**
 * @param getComposableResult Function that returns the value of a composable for testing
 */
export async function testComposable<T>(getComposableResult: () => T): Promise<T> {
  return new Promise((resolve) => {
    const app = createApp({
      setup() {
        resolve(getComposableResult())
        // suppress missing template warning
        return () => {}
      },
    })
    // Install global plugins here with app.use()
    app.mount(document.createElement('div'))
  })
}

If your tests rely on Vue plugins, like for i18n, you can install them in your testComposable function so they're available to use in your tests.

Here's how we can use our testComposable it in a test for a composable named useFormatCurrency:

import { describe, expect, it } from 'vitest'

import { useFormatCurrency } from '@/composables/currency'
import { testComposable } from '@/utils/testing'

describe('useFormatCurrency', () => {
  it('prepends the correct currency symbol', async () => {
    const result = await testComposable(() => {
      const formatCurrency = useFormatCurrency()
      return formatCurrency(0.20000001)
    })
    expect(result).toEqual('$0.20')
  })
})

The tests are written with Vitest, an excellent test runner for TypeScript and JavaScript projects.

Vitest logo

Wrap-up

That's it! With this quick Promise-based utility, it's easy to write type-safe unit tests for Vue composables.

This method is great for unit testing composables outside of components. For a component-centric solution where you're looking to test how a composable works with component props, check out this example from the Vue Test Utils docs: https://test-utils.vuejs.org/guide/advanced/reusability-composition.html#Testing-composables.

Happy testing!

Mastodon