Mastering Jest with TypeScript: Advanced Typing Techniques You Need to Know

Jest and TypeScript enhance JavaScript testing. Advanced typing techniques improve robustness and type safety. Key areas include type assertions, mocking, asynchronous code, mapped types, React components, generics, custom matchers, and error testing.

Mastering Jest with TypeScript: Advanced Typing Techniques You Need to Know

Jest and TypeScript make a powerful combo for testing your JavaScript projects. But to really level up your testing game, you need to master some advanced typing techniques. Let’s dive into the nitty-gritty of how to make your tests more robust and type-safe.

First off, let’s talk about type assertions. When you’re working with Jest and TypeScript, you’ll often need to tell the compiler what type you expect a value to be. This is where the ‘as’ keyword comes in handy. For example:

const myFunction = (input: unknown): string => {
  return (input as string).toUpperCase();
};

test('myFunction converts input to uppercase', () => {
  expect(myFunction('hello')).toBe('HELLO');
});

In this case, we’re asserting that ‘input’ is a string, even though it’s typed as ‘unknown’. This can be super useful when you’re dealing with data from external sources or when you’re confident about a type but TypeScript isn’t.

But be careful! Type assertions can be a double-edged sword. If you’re not absolutely sure about the type, you might want to use a type guard instead. Type guards are functions that help narrow down the type of a value. Here’s an example:

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

const myFunction = (input: unknown): string => {
  if (isString(input)) {
    return input.toUpperCase();
  }
  throw new Error('Input must be a string');
};

test('myFunction throws error for non-string input', () => {
  expect(() => myFunction(123)).toThrow('Input must be a string');
});

This approach is safer because it checks the type at runtime, preventing potential bugs that could slip through if we just used a type assertion.

Now, let’s talk about mocking. When you’re testing complex systems, you often need to mock out dependencies. TypeScript can make this process a bit trickier, but also more robust. Here’s how you might mock a function with specific types:

interface User {
  id: number;
  name: string;
}

const fetchUser = jest.fn<Promise<User>, [number]>();

fetchUser.mockResolvedValue({ id: 1, name: 'John Doe' });

test('fetchUser returns a user', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('John Doe');
});

In this example, we’re using Jest’s ‘fn’ function with generic types to create a mock function that returns a Promise of a User when given a number. This ensures that our mock maintains the correct types, making our tests more reliable.

One thing that often trips people up when working with Jest and TypeScript is testing asynchronous code. Remember, TypeScript is all about compile-time checks, while Jest runs at runtime. This means you need to be extra careful with async tests. Here’s a pattern I’ve found useful:

interface ApiResponse {
  data: string;
}

async function fetchData(): Promise<ApiResponse> {
  // Imagine this fetches data from an API
  return { data: 'Some data' };
}

test('fetchData returns correct data', async () => {
  const result = await fetchData();
  expect(result).toEqual<ApiResponse>({ data: 'Some data' });
});

Notice how we’re using the ‘toEqual’ matcher with a type parameter. This ensures that our expected value matches not just the content, but also the shape of our ApiResponse type.

Another advanced technique that can really boost your testing game is using mapped types to create test factories. This is especially useful when you’re testing functions that work with complex object types. Here’s an example:

interface User {
  id: number;
  name: string;
  email: string;
}

type PartialUser = Partial<User>;

function createTestUser(overrides: PartialUser = {}): User {
  return {
    id: 1,
    name: 'John Doe',
    email: '[email protected]',
    ...overrides,
  };
}

test('user creation', () => {
  const user = createTestUser({ name: 'Jane Doe' });
  expect(user).toEqual({
    id: 1,
    name: 'Jane Doe',
    email: '[email protected]',
  });
});

This approach allows you to create test data easily while maintaining type safety. The ‘Partial’ mapped type makes all properties of User optional, giving you flexibility in your test data creation.

When it comes to testing React components with TypeScript, things can get a bit more complex. You’ll often need to mock context providers or deal with complex prop types. Here’s a pattern I’ve found useful:

import React from 'react';
import { render } from '@testing-library/react';

interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined);

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = React.useState<'light' | 'dark'>('light');
  const toggleTheme = () => setTheme(prev => prev === 'light' ? 'dark' : 'light');
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

function useTheme() {
  const context = React.useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

function ThemeToggleButton() {
  const { theme, toggleTheme } = useTheme();
  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}

test('ThemeToggleButton displays current theme and toggles it', () => {
  const toggleTheme = jest.fn();
  const { getByText } = render(
    <ThemeContext.Provider value={{ theme: 'light', toggleTheme }}>
      <ThemeToggleButton />
    </ThemeContext.Provider>
  );
  
  const button = getByText('Current theme: light');
  button.click();
  expect(toggleTheme).toHaveBeenCalled();
});

This example shows how to test a component that depends on context, while maintaining type safety throughout. We’re able to mock the context value and assert on the component’s behavior.

One area where TypeScript really shines in testing is when you’re dealing with generics. Let’s say you have a generic function that you want to test with different types:

function identity<T>(arg: T): T {
  return arg;
}

test('identity function works with different types', () => {
  expect(identity<number>(5)).toBe(5);
  expect(identity<string>('hello')).toBe('hello');
  expect(identity<boolean>(true)).toBe(true);
  
  const obj = { name: 'John' };
  expect(identity<{ name: string }>(obj)).toBe(obj);
});

This test ensures that our ‘identity’ function works correctly with various types, and TypeScript helps us maintain type safety throughout.

When working with Jest and TypeScript, it’s also important to understand how to type your mock functions properly. Here’s an advanced example:

interface Database {
  connect(): Promise<void>;
  query<T>(sql: string): Promise<T[]>;
  close(): Promise<void>;
}

const mockDatabase: jest.Mocked<Database> = {
  connect: jest.fn(),
  query: jest.fn(),
  close: jest.fn(),
};

beforeEach(() => {
  jest.clearAllMocks();
});

test('database operations', async () => {
  mockDatabase.connect.mockResolvedValue();
  mockDatabase.query.mockResolvedValue([{ id: 1, name: 'John' }]);
  mockDatabase.close.mockResolvedValue();

  await mockDatabase.connect();
  const results = await mockDatabase.query<{ id: number; name: string }>('SELECT * FROM users');
  await mockDatabase.close();

  expect(mockDatabase.connect).toHaveBeenCalledTimes(1);
  expect(mockDatabase.query).toHaveBeenCalledWith('SELECT * FROM users');
  expect(results).toEqual([{ id: 1, name: 'John' }]);
  expect(mockDatabase.close).toHaveBeenCalledTimes(1);
});

In this example, we’re using Jest’s ‘Mocked’ utility type to create a fully mocked version of our Database interface. This gives us type-safe access to all of Jest’s mocking utilities for each method.

Another powerful technique when working with Jest and TypeScript is using custom matchers. These can help you write more expressive and type-safe assertions. Here’s an example:

interface CustomMatchers<R = unknown> {
  toBeWithinRange(floor: number, ceiling: number): R;
}

declare global {
  namespace jest {
    interface Expect extends CustomMatchers {}
    interface Matchers<R> extends CustomMatchers<R> {}
    interface InverseAsymmetricMatchers extends CustomMatchers {}
  }
}

expect.extend({
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    const pass = received >= floor && received <= ceiling;
    if (pass) {
      return {
        message: () => `expected ${received} not to be within range ${floor} - ${ceiling}`,
        pass: true,
      };
    } else {
      return {
        message: () => `expected ${received} to be within range ${floor} - ${ceiling}`,
        pass: false,
      };
    }
  },
});

test('numeric ranges', () => {
  expect(100).toBeWithinRange(90, 110);
  expect(101).not.toBeWithinRange(0, 100);
});

This custom matcher allows us to easily test if a number is within a specific range, and TypeScript ensures we’re using it correctly.

Lastly, let’s talk about testing error cases. TypeScript can help us ensure we’re testing for the right types of errors:

class CustomError extends Error {
  constructor(public code: string, message: string) {
    super(message);
    this.name = 'CustomError';
  }
}

function throwsCustomError(): never {
  throw new CustomError('ERR_001', 'This is a custom error');
}

test('function throws CustomError', () => {
  expect(() => throwsCustomError()).toThrow(CustomError);
  expect(() => throwsCustomError()).toThrow('This is a custom error');
  
  try {
    throwsCustomError();
  } catch (error) {
    if (error instanceof CustomError) {
      expect(error.code).toBe('ERR_001');
    } else {
      throw new Error('Expected CustomError');
    }
  }
});

In this example, we’re not only testing that the function throws, but we’re also checking the specific properties of our custom error type.

And there you have it! These advanced typing techniques for Jest and TypeScript should help you write more robust, type-safe tests. Remember, the goal is to catch as many errors as possible at compile-time, so you can sleep easy knowing your tests are rock-solid. Happy testing!