How to use Typescript with Vitest

This tutorial will explore testing Typescript code with Vitest and learn how to get proper mock types for mocked functions and classes.

Using Typescript in unit tests comes down to providing generic types for proper type inference using Vitest type helpers and generics.

Let’s develop a small example and explore multiple ways of having the appropriate types in our tests.

You can find the code for this tutorial on GitHub.

Example App

I created a new Vite project and adjusted the configuration in vite.config.js to allow Vitest to use JSDom. Similar to what we did in an earlier tutorial:

import { defineConfig } from 'vite'

export default defineConfig({
  test: {
    environment: 'jsdom',
  },
})

Let’s develop a small analytics service to track events by sending a Beacon to an endpoint.

The service exports a single function that accepts a page argument. The page will resemble the page on which the event occurred.

export const trackPageVisibilityChange = (page: string) => {
  navigator.sendBeacon(
    'https://fake-analytics.com',
    JSON.stringify({
      page,
      event: 'visibility-change',
    }),
  )
}

We can start by testing this example, evolving it later, and adjusting our tests as needed.

Mocking Functions with Typescript

A basic test for this service would be to import the function, mock the sendBeacon method and assert that it has been called with the appropriate params when trackPageVisibilityChange is called.

First, create a basic test suite:

import { describe } from 'vitest'
import { trackPageVisibilityChange } from './analytics.service'

describe('Analytics Service', () => {
  describe('trackPageVisibilityChange', () => {
    it.todo('sends a beacon with page name and visibility-change event to the appropriate endpoint')
  })
})

Next, mock the sendBeacon method by assigning it to vi.fn, and then restoring the mocks inside an afterEach hook:

import { afterEach, describe, expect, it, vi } from 'vitest'
import { trackPageVisibilityChange } from './analytics.service'

describe('Analytics Service', () => {
  window.navigator.sendBeacon = vi.fn()

  afterEach(() => {
    vi.mocked(window.navigator.sendBeacon).mockReset()
  })

  describe('trackPageVisibilityChange', () => {
    it('sends a beacon with page name and visibility-change event to the appropriate endpoint', () => {
      vi.mocked(window.navigator.sendBeacon).mockReturnValue(true)
    })
  })
})

Notice that we wrapped window.navigator.sendBeacon in a vi.mocked. Since Typescript doesn’t know that sendBeacon is a mock function, we have to use the mocked type helper to have the right type inferred and be able to use mock functions.

Finally, call trackPageVisibilityChange and do the assertion:

import { afterEach, describe, expect, it, vi } from 'vitest'
import { trackPageVisibilityChange } from './analytics.service'

describe('Analytics Service', () => {
  window.navigator.sendBeacon = vi.fn()

  afterEach(() => {
    vi.mocked(window.navigator.sendBeacon).mockReset()
  })

  describe('trackPageVisibilityChange', () => {
    it('sends a beacon with page name and visibility-change event to the appropriate endpoint', () => {
      vi.mocked(window.navigator.sendBeacon).mockReturnValue(true)

      trackPageVisibilityChange('homepage')

      expect(navigator.sendBeacon).toHaveBeenCalledWith(
        'https://fake-analytics.com',
        JSON.stringify({
          page: 'homepage',
          event: 'visibility-change',
        }),
      )
    })
  })
})

Run the test, and it should pass:

vitest typescript vi.mocked

Using Generics for Type Inference

Due to new requirements, we have to track scroll events, not just visibility change events.

Instead of creating a new function trackPageScroll, let’s make the current function accept an event param:

export type PageEvent = 'visibility-change' | 'scroll'

export const trackPageEvent = (page: string, event: PageEvent) => {
  navigator.sendBeacon(
    'https://fake-analytics.com',
    JSON.stringify({
      page,
      event,
    }),
  )
}

Our current test should still work if we update the function name and pass an event param:

//...code
import { trackPageEvent } from './analytics.service'

describe('Analytics Service', () => {
  //...code

  describe('trackPageEvent', () => {
    it('sends a beacon with page name and visibility-change event to the appropriate endpoint', () => {
      vi.mocked(window.navigator.sendBeacon).mockReturnValue(true)

      trackPageEvent('homepage', 'visibility-change')

      expect(navigator.sendBeacon).toHaveBeenCalledWith(
        'https://fake-analytics.com',
        JSON.stringify({
          page: 'homepage',
          event: 'visibility-change',
        }),
      )
    })
  })
})

However, it doesn’t cover all the page events we support(it’s missing scroll). We can add a new unit, a copy of the current one with the scroll event. Or, a better way is to use it.each:

describe('trackPageEvent', () => {
  it.each([
    'visibility-change',
    'scroll'
  ])('sends a beacon with page name and %s event to the appropriate endpoint', (event) => {
    vi.mocked(window.navigator.sendBeacon).mockReturnValue(true)

    trackPageEvent('homepage', event)

    expect(navigator.sendBeacon).toHaveBeenCalledWith(
      'https://fake-analytics.com',
      JSON.stringify({
        page: 'homepage',
        event,
      }),
    )
  })
})

This way, we don’t have to duplicate the test:

Vitest typescript it.each generics

However, Typescript will complain that the event param we pass to trackPageEvent, which is a string doesn’t match the expected PageEvent type the function accepts.

To fix this, either cast the type to a PageEvent:

trackPageEvent('homepage', event as PageEvent)

Or provide PageEvent as the generic type for it.each:

it.each<PageEvent>(['visibility-change', 'scroll'])(
  'sends a beacon with page name and %s event to the appropriate endpoint',
  (event) => {
    //...code
  },
)

If PageEvent was an enum instead:

export enum PageEvent {
  VISIBILITY_CHANGE = 'visibility-change',
  SCROLL = 'scroll',
}

Spread it inside the cases array without providing a generic type since it will be inferred automatically:

it.each(Object.values(PageEvent))(
  'sends a beacon with page name and %s event to the appropriate endpoint',
  (event) => {
    //... code
})

Mocking Classes with Typescript

Let’s convert our analytics functions into a class for demonstration purposes. This should be the final form:

export enum PageEvent {
  VISIBILITY_CHANGE = 'visibility-change',
  SCROLL = 'scroll',
}

export class AnalyticsService {
  trackPageEvent(page: string, event: PageEvent) {
    navigator.sendBeacon(
      'https://fake-analytics.com',
      JSON.stringify({
        page,
        event,
      }),
    )
  }
}

We will import and use this class in a different module to demonstrate mocking it. Let’s imagine we have an event handler:

import { PageEvent, AnalyticsService } from './services/analytics.service'

export function handleScroll() {
  const analyticsService = new AnalyticsService()

  analyticsService.trackPageEvent('homepage', PageEvent.SCROLL)
}

In the unit test for this piece of code, we have to mock the whole analytics.service file since it’s a direct dependency and then spy on the trackPageEvent method from the prototype:

import { describe, expect, it, vi } from 'vitest'
import { PageEvent, AnalyticsService } from './services/analytics.service'
import { handleScroll } from './main'

vi.mock('./services/analytics.service')

describe('Main', () => {
  describe('handleScroll', () => {
    it('calls AnalyticsService.trackPageEvent with appropriate params', () => {
      const trackPageEventSpy = vi.spyOn(AnalyticsService.prototype, 'trackPageEvent')

      handleScroll()

      expect(trackPageEventSpy).toHaveBeenCalledTimes(1)
      expect(trackPageEventSpy).toHaveBeenCalledWith('homepage', PageEvent.SCROLL)
    })
  })
})

We don’t have to do anything extra. PageEvent will not be mocked since it’s a type.

Vitest Typescript mock class

Conclusion

We have explored multiple ways to test our Typescript code; sometimes, we have to give it an extra nudge by wrapping a function in a type helper to infer the correct types. Using generics when we need types in our tests is also very useful.

Let me know in a comment if you have a different case where Typescript is causing issues, and I may incorporate it into the article. Thanks for reading 😊!

Amenallah Hsoumi
Amenallah Hsoumi

Senior Software Engineer, Indie Hacker, building cool stuff on the web!

Articles: 19