About a year ago, my team & I were presented with a problem in our application:
We had different types of users who interacted in different ways, and we wanted to test this properly. We wanted to introduce E2E testing to avoid manual regression testing, and we started looking at Cypress.
Our application also had authentication with social logins, and we couldn't at the time properly test. Also, we did not want to hit the authentication servers each time our Cypress was running. So I thought: wouldn't it be nice to impersonate the different types of users on our e2e tests? The gameplan was simple: For development and our e2e tests, we wanted to bypass our authentication and impersonate some test users.
To illustrate the solution, I will be showing snippets of a small application I created for this article. The application is a project management app with projects, tasks, and different roles for your users.
Each project consists of a name, description, invitation link, tasks, and user roles. Every task can be assigned to a user, and each user role is linked to a project and user. The role is, so we know who is a project admin or a project member. The stack I will use will be:
Backend: Nodejs, Typescript, MongoDB, Mongoose, Express, Open API
Frontend: CRA Typescript, Tailwindcss, Open API, Cypress
I won't go into detail about the whole setup and codebase, but if you want to see how it works, let me know in the comments! These concepts can be applied to any stack, and this article is meant to explain conceptually what the point is.
Backend
The work that we need to do in the backend is pretty straight-forward. We want to bypass any authentication and have a way to choose a test user of our liking on each request.
With Express, this is quite simple; we can create a middleware function that takes care of this by doing the following:
- First, there is a check if the test users feature are enabled by checking an environment variable; this gives us the flexibility of choice on which environments the test user logic will be enabled.
- If the test user logic is enabled, we check for a request header
test-user
where on the client we would set an id to identify which test user we are using. - We try to find this user by this id in our DB; if this one doesn't exist, we create it. We use the
testUsers.ts
to do this. - We set this user in our
res.locals
to easily accessible by the route function (res.locals
is an object when can use scoped to this specific request. It's specific to Express).
The first point is crucial because it makes sure you are not a security risk. In production, this environment variable should always be disabled.
middleware/currentAuthenticatedUser.ts
import { NextFunction, Response, Request } from 'express';
import { User } from '../model/user';
import { testUsers } from './testUsers';
import { ExtendedResponse } from '../types/types';
export const currentAuthenticatedUser = async (req: Request, res: ExtendedResponse, next: NextFunction) => {
if (process.env.TEST_USERS_ENABLED === 'false') {
// service call to do proper authentication and get the actual user.
} else {
const testUserId: string = (req.headers['test-user'] as string) || '1';
const user = await User.findOne({ id: testUserId });
if (!user) {
const newUser = new User({ ...testUsers.find((x) => x.id === testUserId) });
const createdUser = await newUser.save();
res.locals.currentUser = createdUser;
} else {
res.locals.currentUser = user;
}
}
next();
};
testUsers.ts
export interface IUser {
id: string;
name: string;
email: string;
}
export const testUsers: IUser[] = [
{
email: 'test_user_1@test.com',
id: '1',
name: 'James Hetfield',
},
{
email: 'test_user_2@test.com',
id: '2',
name: 'Rabea massaad',
},
{
email: 'test_user_3@test.com',
id: '3',
name: 'Andrew Goddard',
},
];
That's all the work we need to do in our backend. If we want to use this middleware, we can add it to the handlers in our routes:
import { currentAuthenticatedUser } from '../middleware/currentAuthenticatedUser';
import express from 'express';
const userRouter = express.Router();
userRouter.get(`/me`, currentAuthenticatedUser, async (req, res) => {
return res.send(res.locals.currentUser);
});
export default userRouter;
Frontend
In the frontend, we need to choose from a list of users in the UI and make sure that all our API calls are done with the right request header. We need the following pieces to make this work:
- A user selector in the UI.
- Proper Axios configuration, so each request has the ID of the user selected.
We will store the chosen ID in the localstorage; this will also help us later in our Cypress tests to manipulate which user is selected instead of using the test user selector.
TestUserSelector.tsx
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useEffect, useState } from 'react';
import { faArrowCircleLeft } from '@fortawesome/free-solid-svg-icons/faArrowCircleLeft';
import { faArrowCircleRight } from '@fortawesome/free-solid-svg-icons/faArrowCircleRight';
import { User } from '../../shared/types';
import { testUsers } from './testUsers';
const LOCALSTORAGE_USER_KEY = 'current_test_user_id';
const TestUserSelector = () => {
const [hidden, setHidden] = useState(true);
const [selectedUser, setSelectedUser] = useState<string | undefined>(undefined);
const setUser = (id: string) => {
localStorage.setItem(LOCALSTORAGE_USER_KEY, id);
setSelectedUser(id);
window.location.reload();
};
useEffect(() => {
const userFromLocalStorage = localStorage.getItem(LOCALSTORAGE_USER_KEY);
if (userFromLocalStorage) {
setSelectedUser(userFromLocalStorage);
} else {
localStorage.setItem(LOCALSTORAGE_USER_KEY, '1');
window.location.reload();
}
}, []);
return (
<div className="absolute right-0.5 top-1/2 bg-white p-2 shadow-xl rounded">
{hidden ? (
<FontAwesomeIcon size="lg" icon={faArrowCircleLeft} data-testid="open-user-panel" onClick={() => setHidden(false)} />
) : (
<FontAwesomeIcon size="lg" icon={faArrowCircleRight} onClick={() => setHidden(true)} />
)}
{!hidden && (
<div className="bg-white mt-2">
{testUsers.map((testUser) => (
<TestUser selectUser={(id) => setUser(id)} selected={selectedUser === testUser.id} key={testUser.id} user={testUser} />
))}
</div>
)}
</div>
);
};
const TestUser: React.FC<{ user: User; selected: boolean; selectUser: (id: string) => void }> = ({ user, selected, selectUser }) => {
return (
<div
data-testid={`select-user-id-${user.id}`}
className={selected ? 'bg-blue-300 p-2 rounded text-white' : 'p-2 rounded'}
onClick={() => selectUser(user.id)}
>
<div>
<p>
<strong>Id: </strong>
{user.id}
</p>
<p>
<strong>Name: </strong>
{user.name}
</p>
</div>
</div>
);
};
export default TestUserSelector;
And we can now add this component to the root of our app:
import React from 'react';
import './App.css';
import AppRouter from './config/router';
import ReactQueryAppProvider from './providers/ReactQueryProvider';
import TestUserSelector from './components/TestUserSelector';
import UserProvider from './providers/UserProvider';
import { TEST_USERS_ENABLED } from './config/constants';
function App() {
return (
<ReactQueryAppProvider>
<UserProvider>
{TEST_USERS_ENABLED && <TestUserSelector />}
<AppRouter />
</UserProvider>
</ReactQueryAppProvider>
);
}
export default App;
For the Axios configuration, we have to ensure that every API call is done with the selected user ID in the request header.
const APIconfig: AxiosRequestConfig = {
headers: {
['Content-Type']: 'application/json',
},
};
if(TEST_USERS_ENABLED){
const currentUserId = localStorage.getItem('current_test_user_id') || '1';
APIconfig.headers['test-user'] = currentUserId;
}
export const getCurrentUser = async () => {
try {
const { data } = await axios.get<User>(`${BACKEND_URL}${ME_ROUTE}`, APIconfig);
return data;
} catch (e) {
throw new Error(e);
}
};
This API call will be used in a UserProvider that always fetches the current user with the help of react-query and makes it available with React Context API.
import React from 'react';
import { User } from '../shared/types';
import { useQuery } from 'react-query';
import { getCurrentUser } from '../shared/api';
export const UserContext = React.createContext<User | undefined>(undefined);
const UserProvider: React.FC = ({ children }) => {
const { data } = useQuery<User>('getUser', () => {
return getCurrentUser();
});
if (data) {
return <UserContext.Provider value={data}>{children}</UserContext.Provider>;
}
return <p>Loading..</p>;
};
export default UserProvider;
That's it! We are done with our frontend. If everything worked correctly, every API call now should be done with the proper header containing the id of the user we are impersonating.
Bonus: Cypress
This setup becomes powerful when creating E2E tests; we can easily switch users and see if the changes we did are correct on both ends. Let's say I want to invite a user to a project with the invitation code. I can now do as if I were user A, create the project, copy the invitation code, reload as another user, navigate to the invitation link as User B, accept the invite and gain access to the project.
For this, it would be handy to create some util functions that we can use in Cypress.
export const setCurrentUser = (id: string, reload?: boolean) => {
window.localStorage.setItem('current_test_user_id', id);
if (reload) {
window.location.reload();
}
};
import { setCurrentUser } from '../../support/commands';
import * as faker from 'faker';
context('Projects', () => {
const TEST_PROJECT_ADMIN = '1';
const TEST_PROJECT_MEMBER = '2';
beforeEach(() => {
setCurrentUser(TEST_PROJECT_ADMIN);
});
it('as a project admin I should be able to successfully invite other users to my project', () => {
const PROJECT_NAME = faker.company.bsBuzz() + faker.company.bs() + faker.commerce.product();
cy.visit('/');
cy.findByTestId('actions-create-project').click();
cy.findByTestId('field-project-name').type(PROJECT_NAME);
cy.findByTestId('actions-confirm-create-project').click();
cy.findByText(PROJECT_NAME).click();
cy.findByTestId('invitation-link')
.invoke('text')
.then((text) => {
const shareLink = String(text);
setCurrentUser(TEST_PROJECT_MEMBER);
cy.visit(shareLink);
cy.findByTestId('actions-join-project').click();
cy.findByTestId('project-title').should('have.text', `Project: ${PROJECT_NAME}`);
});
});
});
And the result:
Thanks for reading!
Sample code can be seen here: https://github.com/jdcas89/project-butler
Top comments (0)