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?
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;