typescriptjestjsreact-oidc-contextoidc-client-ts

Mocking return value of react-oidc-context in jest with typescript


In our project we are using react-oidc-context (which uses oidc-client-ts) to authenticate a user.

react-oidc-context exposes useAuth, which contains information like isAuthenticated, isLoading, the user object when authenticated, etc.

In order to fully test my components I need to mock it with different return values in each test. Normally I would do this with jest.spyOn and mockImplementation to set the right conditions for my test.

However, the number of (nested) properties that useAuth returns is pretty big and may be prone to change in the future, so I don't want to type everything out. I just want to pass the properties that I care about. But typescript doesn't allow that.

An example:

// Login.tsx
// AuthProvider has redirect_uri set that points back to this file

import { Button } from 'react-components';
import { useAuth } from 'react-oidc-context';
import { Navigate } from 'react-router-dom';

const Login = (): JSX.Element => {
  const auth = useAuth();

  if (auth.isAuthenticated) {
    return <Navigate to="/dashboard" replace />;
  }

  return (
    <main style={{ display: 'flex', justifyContent: 'center', paddingTop: '5rem' }}>
      <Button variant="secondary" onClick={() => auth.signinRedirect()}>
        Sign in
      </Button>
    </main>
  );
};

export default Login;

// Login.test.tsx

import { renderWithProviders, screen } from '@test/utils';
import * as oidc from 'react-oidc-context';

import Login from '../Login';

describe('Login view', () => {
  it('Shows a login button', () => {
    jest.spyOn(oidc, 'useAuth').mockImplementation(
      () =>
        ({
          isAuthenticated: false,
        })
    );

    // renderWithProviders wraps the normal testing-library render with the routing provider
    renderWithProviders(<Login />);
    expect(screen.findByRole('button', { name: 'Sign in' }));
  });
});

In the above code typescript will complain that the mockImpelmentation doesn't match AuthContextProps. And it is right! 12 direct props are missing and many more deeply nested ones.

If I try to trick TS:

// Login.test.tsx with type casting

import { renderWithProviders, screen } from '@test/utils';
import * as oidc from 'react-oidc-context';

import Login from '../Login';

describe('Login view', () => {
  it('Shows a login button', () => {
    jest.spyOn(oidc, 'useAuth').mockImplementation(
      () =>
        ({
          isAuthenticated: false,
        }) as oidc.AuthContextProps // <--- NEW
    );

    renderWithProviders(<Login />);
    expect(screen.findByRole('button', { name: 'Sign in' }));
  });
});

Now I get a runtime error: TypeError: Cannot redefine property: useAuth

Crap.

I have tried many different mocking tricks but everything fails at some point.

Going back to the drawing board, I tried to forego the whole mocking thing and just setup a Provider with fake credentials. Basically what I do for react-router.

This will work for the unauthenticated state, but to my knowledge I can't fake the authenticated state.

import { renderWithProviders, screen } from '@test/utils';

import Login from '../Login';

describe('Login view', () => {
  it('Shows a login button', () => {
    renderWithProviders(
      <AuthProvider
        {...{
          authority: 'authority',
          client_id: 'client',
          redirect_uri: 'redirect',
        }}
      >
        <Login />
      </AuthProvider>
    );
    expect(screen.findByRole('button', { name: 'Sign in' }));
  });
});

So the last thing I can think of is to write some helper to generate a big return object for useAuth that satisfies TS.
Like I said I've been putting it off, because this doesn't seem very future-proof.

Anyone an idea how to fix this and make it pretty?


Solution

  • After a good night sleep, I finally found a way. Inspired by this answer: https://stackoverflow.com/a/73761102/1783174

    // jest.config.ts
    
    {
      setupFilesAfterEnv: ['<rootDir>/test/jest-setup.ts'],
    }
    

    In jest-setup I create the default mocked return values for useAuth

    // test/jest-setup.ts
    
    jest.mock('react-oidc-context', () => ({
      // The result of this mock can be altered by changing `mockAuthenticatedStatus` in your test
      useAuth() {
        const { isLoading, isAuthenticated } = getMockAuthStatus();
        return {
          isLoading,
          isAuthenticated,
          signinRedirect: jest.fn(),
          removeUser: jest.fn(),
          settings: {},
        };
      },
    }));
    
    // test/utils.ts
    
    // ..in this file is also the renderWithProviders function that adds the react-router MemoryBrowser..
    
    export const mockAuthenticatedStatus = {
      isLoading: false,
      isAuthenticated: false,
    };
    
    export const getMockAuthStatus = () => {
      return mockAuthenticatedStatus;
    };
    
    // views/__tests__/Login.test.tsx
    
    import { mockAuthenticatedStatus, renderWithProviders, screen } from '@test/utils';
    import * as router from 'react-router-dom';
    
    import Login from '../Login';
    
    jest.mock('react-router-dom', () => ({
      ...jest.requireActual('react-router-dom'),
      Navigate: jest.fn(),
    }));
    
    describe('Login view', () => {
      it('Shows a login button', () => {
        // BY DEFAULT WE ARE LOGGED OUT
        renderWithProviders(<Login />);
        expect(screen.findByRole('button', { name: 'Sign in' }));
      });
    
      it('Navigates when logged in', () => {
        // HERE WE CHANGE THE AUTH STATUS FOR THIS TEST
        mockAuthenticatedStatus.isAuthenticated = true;
        const navigateSpy = jest.spyOn(router, 'Navigate');
        renderWithProviders(<Login />);
        expect(navigateSpy).toHaveBeenCalled();
      });
    });
    
    

    For reference, here is the Login component file that we tested

    // views/Login.tsx
    
    import { Button } from 'react-components';
    import { useAuth } from 'react-oidc-context';
    import { Navigate } from 'react-router-dom';
    
    import { signIn } from '../auth/utils';
    
    const Login = (): JSX.Element => {
      const auth = useAuth();
    
      if (auth.isAuthenticated) {
        return <Navigate to="/" replace />;
      }
    
      return (
        <main style={{ display: 'flex', justifyContent: 'center', paddingTop: '5rem' }}>
          <Button variant="secondary" onClick={() => signIn(auth)}>
            Sign in
          </Button>
        </main>
      );
    };
    
    export default Login;