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:
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:
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.
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 😊!
Trending now