Last Updated: 2023/05/15
Why Frontend Integration Test ?
In accordance with the Frontend Testing Philosophy outlined by Kent C. Dodds, the most valuable type of test for frontend developers to write is an integration test. This type of test strikes a good balance between effort and code quality confidence.
So, what should we test in frontend integration tests to achieve the best confidence and minimize effort in maintaining and coding these tests?
In my opinion, UI operations and API calls are the most important aspects, as they are fully controlled by the frontend application. Anything that occurs after the API call requires backend dependencies, and we have no responsibility to ensure that the database update logic or data synchronization works correctly. Therefore, testing any UI updates based on data fetching is not worth the effort. Additionally, we need to implement some part of the backend logic to return the newest data we just sent, which is not worth the effort either.
Playwright as a solution
As the philosophy presented, we need to make our test and environment as close to the real production environment as possible. Instead of using an interceptor like mock-axios, which would be injected into the frontend code, we can choose a testing framework like Playwright that provides API mocking on the browser side. This means that passing test cases ensure that our web app will work perfectly in the production environment, assuming that the backend service is functioning properly.
Please refer to the official document to set up Playwright in your current project.
Once you have set up the basic testing environment, you can write a test case that triggers an API request with Playwright's API mocking method (keep in mind that it mocks the API at the browser layer, so you don't need to modify your frontend codebase).
Here's an example:
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.route('https://backend.api', async (route) => {
const json = {
message: { msg: "It's a test message!" },
};
await route.fulfill({ json });
});
await page.goto('https://myApp.frontend/');
await expect(page).toHaveTitle(/MyApp/);
});
However, writing API mocking for every API in each test can be tedious. Instead of using the beforeEach hook, you can use Fixtures in Playwright to create a global mocking layer for your default API, making code maintenance easier.
Here's an example:
// helper.ts
import { test as baseTest } from '@playwright/test';
export const test = baseTest.extend({
model: async ({ page }, use) => {
await page.route('https://backend.api', async (route) => {
const json = {
message: { msg: "It's a test message!" },
};
await route.fulfill({ json });
});
},
});
//example.test.ts
import { test } from './helper';
test('should add an item', async ({ model }) => {
// ...
});
test('should remove an item', async ({ model }) => {
// ...
});
// All tests will have the mock API effect!
Make Playwright works with Graphql
GraphQL is built on top of the HTTP POST request, so we only need to parse the request body to extract information about the request. To simplify this process, we can use a popular mock helper library - MSW as a GraphQL parser (which we'll explore in more detail in the next post).
// handleRoute.ts
import type { Route } from '@playwright/test';
import type { RequestHandler } from 'msw';
import { Headers } from 'headers-polyfill';
import { handleRequest, MockedRequest, type MockedResponse } from 'msw';
import EventEmitter from 'events';
const emitter = new EventEmitter() as Parameters<typeof handleRequest>[3];
export const handleRoute = async (route: Route, handlers: RequestHandler) => {
try {
const request = route.request();
let requestHeaders = {};
try {
requestHeaders = await request.allHeaders();
} catch {}
const headers = new Headers(requestHeaders);
const method = request.method();
const url = new URL(request.url());
const postData = request.postData();
const mockedRequest = new MockedRequest(url, {
method,
headers,
body: postData ? Buffer.from(postData) : undefined,
});
const handleMockResponse = async ({
status,
headers,
body,
delay,
}: MockedResponse) => {
if (delay) {
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
}
return route.fulfill({
status,
body: body ?? undefined,
contentType: headers.get('content-type') ?? undefined,
headers: headers.all(),
});
};
await handleRequest(
mockedRequest,
[],
{
onUnhandledRequest: () => {
console.error('no match handler');
},
},
emitter,
{
resolutionContext: {
/**
* @note Resolve relative request handler URLs against
* the server's origin (no relative URLs in Node.js).
*/
baseUrl: url.origin,
},
onMockedResponse: handleMockResponse,
},
);
} catch (err) {
console.error(err);
}
};
// helper.ts
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import type { RequestHandler } from 'msw';
import { handleRoute } from './handleRoute';
type fixtures = {
model: { addHandler: (handlers: RequestHandler) => void };
};
export const test = baseTest.extend<fixtures>({
model: [
async ({ page }, use) => {
// if you want to have some default hadlers, you can put them into this array
const handlers: RequestHandler[] = [];
await page.route('**/graphql', (route) => handleRoute(route, handlers));
use({
addHandler: (handler: RequestHandler) => handlers.unshift(handler),
});
},
{ auto: true },
],
});
With above configuration we finally can add handler directly in our test!
//example.test.ts
import { test } from './helper';
import { graphql } from 'msw';
test('example test', async ({ page, model }) => {
model.addHandler(
graphql.query('GetAllUsers', (req, res, ctx) => {
return res(
ctx.data({
users: [
{
firstName: 'John',
lastName: 'Maverick',
},
{
firstName: 'Cathaline',
lastName: 'McCoy',
},
],
}),
);
}),
);
await page.goto('http://frontend,app/users');
});
The Frustration of writing Mocking Data
Writing mock data for integration tests can be frustrating, especially if your service keeps evolving, and each field of the API could change. We need a better way to make our lives easier.
Only when writing or maintaining tests doesn't consume too much time, can we push our test coverage to the next level. Fortunately, we have many tools available in the community for automatically generating RESTful APIs and GraphQL mock data with pre-defined schemas. We can rely on these tools to keep the implementation between the frontend and backend in sync.
Graphql Codegen for generating Mock Data
When your project starts to adopt GraphQL, you'll find many codegen tools that can help you improve your code quality. Today, we'll use a mature codegen tool called GraphQL Code Generator.
Please follow the official document to set up the environment.
We will depend on two useful plugins: TypeScript Mock Data and TypeScript MSW.
TypeScript Mock Data
With this plugin, you can generate mock data statically or dynamically based on your GraphQL schema using well-known mock data libraries such as Casual or Faker. It also makes writing mock data with default values easier. You can specify only the crucial attribute related to the test. For example:
scalar AWSTimestamp
type User {
id: ID!
name: String!
avatar: Avatar
status: Status!
updatedAt: AWSTimestamp
}
// generated mock data configuration with Casual static data
export const aUser = (overrides?: Partial<User>): User => {
return {
id: overrides && overrides.hasOwnProperty('id') ? overrides.id! : 'a5756f00-41a6-422a-8a7d-d13ee6a63750',
name: overrides && overrides.hasOwnProperty('name') ? overrides.name! : 'libero',
avatar: overrides && overrides.hasOwnProperty('avatar') ? overrides.avatar! : anAvatar(),
status: overrides && overrides.hasOwnProperty('status') ? overrides.status! : Status.Online,
updatedAt: overrides && overrides.hasOwnProperty('updatedAt') ? overrides.updatedAt! : 1458071232,
};
};
// In test case
import * as mockData from './generated'
const user = mockData.aUser({ name: 'johndoe' });
TypeScript Msw
This plugin integrates Graphql Codegen and MSW, allowing for the generation of API mocking helpers with strong types. This provides maximum benefits for writing API mocking. For example:
query GetUser($id: ID!) {
getUser(id: $id) {
name
id
}
}
mockGetUserQuery((req, res, ctx) => {
const { id } = req.variables
return res(
ctx.data({
getUser: { name: 'John Doe', id }
})
)
})
With these two brilliant plugins, we can write our mock APIs smoothly without losing any of the side properties that the API should respond with. For example:
mockGetUserQuery((req, res, ctx) => {
const { id } = req.variables
return res(
ctx.data({
getUser: mockData.aUser({ login: 'johndoe' });
})
)
})
Combine with Playwright Graphql Mocking
Combine the example in the previous part, we can write our code quite smoothly like follow:
//example.test.ts
import { test } from './helper';
import * as mock from './generated'
test('example test', async ({ page, model }) => {
model.addHandler(
mock.mockGetUserQuery((req, res, ctx) => {
const { id } = req.variables
return res(
ctx.data({
getUsers: [
mockData.aUser({ name: 'johndoe' }),
mockData.aUser({ name: 'charle' })
]
})
)
})
);
await page.goto('http://frontend,app/users');
});
Testing Mutation
So far, we have only demonstrated the use of mock data with queries. However, in most cases, we also need to test mutations, such as form submissions or order placements.
Since we can dynamically control API handlers, we can inject mutations into our handlers and test if the variables being passed to the backend are correct.
mutation UpdateUser($id: ID!, $name: String) {
updateUser(input: { id: $id, name: $name }) {
id
}
}
//example.test.ts
import { test } from './helper';
import * as mock from './generated'
test('update user', async ({ page, model }) => {
const targetUser = mock.aUser();
const editedUserName = 'test_user_name';
let isVarsRight = false;
model.addHandler(
mock.mockUpdateUser((req, res, ctx) => {
const { id, name } = req.variables;
expect(id).toEqual(targetUser.id);
expect(name).toEqual(editedUserName);
isVarsRight = true;
return res(ctx.data({} as any));
}),
);
await page.goto('http://frontend,app/user');
await page.getByLabel('name').fill(editedUserName);
await page.getByTestId('SaveIcon').click();
expect(isVarsRight).toBeTruthy();
});
You may find it odd that we need a flag called isVarsRight
and expect
it at the end of the test. This is because the expect function in the handler's callback runs outside of the test process, and any errors it produces will not be captured by Playwright.
Conclusion
This article only showcases some of the potential of using Graphql Codegen with Playwright to significantly improve the developer experience when writing frontend integration tests. As Graphql becomes more and more popular, I'm excited to see what other solutions the community will come up with. However, our team is quite satisfied with our current workflow. I hope that these articles can serve as a starting point or source of inspiration for you to build a better frontend test infrastructure!
Top comments (3)
this does not work, no mocking actually occurs with this approach
specifically your approach utilizing playwright with msw as a graphql request parser
Hi, thanks for your feedback. I found that we are using the older version of msw which support the above usage. We already updated the article to fit the newest library api, please do check it out again !