How to mock server responses while testing web apps with Playwright?
Playwright is a relatively new automation library for browser testing. Despite that, it has already gained some user base, with articles and tutorials regularly appearing on Software Testing Weekly. It comes with many features necessary for UI tests, but crucially, it can also work with API requests made by the web app under test. This allows us to either completely mock responses from a backend server, or alter them - depending on what we want to achieve. In this post, I will show how to do both things in Java.
Test project
For this tutorial, I'll be using this website developed by Kristin Jackvony for us to practice automating both API and UI tests. It's a simple app to manage a list of contacts. You'll need to sign up to use it. Here's what the main page look like:
A form to add a new contact:
I created three classes to describe these two pages and the login page:
LoginPage
with alogin(String email, String password)
method to sign into the system.ContactsPage
with methods to access contact details from the table and to click the 'Add New Contact' button.AddContactPage
with afillForm(ContactDto contactDto)
method to fill the form and asubmitForm()
method to submit it.
We also need a model to represent contacts:
ContactDto
- a plain old java object that matches the API contract.ContactDtoFactory
- a utility class to generate a contact with random values.
You can find my page objects here and the contact model here. I used Lombok and other dependencies to make my life easier. For relevant details, please refer to the pom.xml file.
Now that our test support classes are ready, we can implement tests. I'll be using this class as a starting point:
public class ContactsTest {
// json serializer/deserializer to use in tests
private final Gson gson = new Gson();
private Page page;
private String username;
private String password;
@BeforeTest
public void setUp() {
// launch the browser before running any tests
page = Playwright.create().chromium().launch().newPage();
// read user credentials from environment variables
username = System.getenv("PW_USERNAME");
password = System.getenv("PW_PASSWORD");
}
@AfterMethod
public void resetMocks() {
// reset mocks between tests
page.unrouteAll();
}
@AfterTest
public void tearDown() {
// close the browser after all tests have been executed
page.close();
}
}
This code takes the username and password from environment variables. You can define them in IntelliJ IDEA in the run configuration for this test:
Mock response
Using mocks can be part of a testing strategy, or it may come in handy when the backend development is lagging behind, or when the software under test has an external integration that is not available/desirable during test execution.
In the base class, we created a new page that has access to the Route interface. It allows us to define urls that we want to handle ourselves:
// intercept all API requests to /contacts
page.route("**/contacts", route -> {
// do something here
});
In this scenario, we fully mock the server response. To do this, we can take a pre-generated contactDto
, convert it to a json string, and fulfill the request with our response body.
// intercept all API requests to /contacts
page.route("**/contacts", route -> {
// serialize our contactDto into a json object
// this endpoint returns a list
// so put it into an array
var json = gson.toJson(new ContactDto[]{contactDto});
// finally, fulfill the request
// by setting our json as the response body
route.fulfill(new Route.FulfillOptions().setBody(json));
});
All requests to /contacts
will now go through this code instead of the actual backend server. Here's a test that utilizes this mock:
@Test
public void mockContact() {
// generate a contact
var contactDto = ContactDtoFactory.withRandomData();
// intercept all API requests to /contacts
page.route("**/contacts", route -> {
// serialize our contactDto into a json object
// this endpoint returns a list
// so we have to put it into an array
var json = gson.toJson(new ContactDto[]{contactDto});
// finally, fulfill the request
// by setting our json as the response body
route.fulfill(new Route.FulfillOptions().setBody(json));
});
// let's log in to the system
var contactsPage = new LoginPage(page)
.open()
.login(username, password);
// after logging in, we should see the list of contacts
// in the mocked response there's only one contact: our contactDto
// now we can assert that all values are displayed correctly by the web app
assertThat(contactsPage.getName()).hasText(format("%s %s", contactDto.getFirstName(), contactDto.getLastName()));
assertThat(contactsPage.getBirthday()).hasText(contactDto.getBirthdate());
assertThat(contactsPage.getEmail()).hasText(contactDto.getEmail());
assertThat(contactsPage.getPhone()).hasText(contactDto.getPhone());
assertThat(contactsPage.getAddress()).hasText(format("%s %s", contactDto.getStreet1(), contactDto.getStreet2()));
assertThat(contactsPage.getCity()).hasText(format("%s %s %s", contactDto.getCity(), contactDto.getStateProvince(), contactDto.getPostalCode()));
assertThat(contactsPage.getCountry()).hasText(contactDto.getCountry());
}
Modify response
This mechanism also enables us to alter the actual response if needed. Although this might not be needed in automated tests, I occasionally use this feature to support my manual testing. There are browser extensions that can do the same job, but if you're already familiar with Playwright and have automated scenarios that can quickly set up most things for you, why not consider using it?
The key difference in this case is that we need to complete the request initiated by the web app. Otherwise, we wouldn't have a response to modify! The Route interface offers the fetch()
method, which makes the request and returns the response. We can then modify it and then fulfill the route, just like we did in the previous example.
// intercept all API requests to /contacts
page.route("**/contacts", route -> {
// call fetch() to perform the request to the real backend server
// this method returns a real response
var actualResponse = route.fetch();
// deserialize this response into an array of ContactDto objects
var deserializedResponse = gson.fromJson(actualResponse.text(), ContactDto[].class);
// todo: modify deserializedResponse
// serialize our modified contact into a json object
// this endpoint returns a list
// so we have to put it into an array
var modifiedJson = gson.toJson(deserializedResponse);
// finally, fulfill the request
// by setting our json as the response body
route.fulfill(new Route.FulfillOptions().setBody(modifiedJson));
});
This is where it gets a bit tricky. The website we're using warns us that "the database will be purged as needed to keep costs down". This means that even if we add a contact manually, we can't guarantee that it will always be present in the response to modify. To address this, I implemented the functionality to add a new contact using the UI. I know, this doesn't make much sense to create an object on the UI and then modify it in the API response. But the UI part is only necessary to ensure the stability of the code in this post. It wouldn't exist in a real scenario.
With that said, here is the full test. It creates a contact to ensure the server returns some data, modifies this data, and finally validates the contact list.
@Test
public void modifyContact() {
// generate a contact
var contactDto = ContactDtoFactory.withRandomData();
// generate a new email address for this contact
var modifiedEmail = format("%[email protected]", RandomStringUtils.randomAlphabetic(10));
// let's log in to the system
new LoginPage(page)
.open()
.login(username, password)
// and add a new contact
.clickAddNewContactButton()
.fillForm(contactDto)
// this action will send our form to the real backend server
.submitForm();
// intercept all API requests to /contacts
page.route("**/contacts", route -> {
// call fetch() to perform the request to the real backend server
// this method returns a real response
var actualResponse = route.fetch();
// deserialize this response into an array of ContactDto objects
var deserializedResponse = gson.fromJson(actualResponse.text(), ContactDto[].class);
// find the contact we created by its email
var actualContact = Arrays.stream(deserializedResponse)
.filter(contact -> contact.getEmail().equalsIgnoreCase(contactDto.getEmail()))
.findFirst()
.orElseThrow();
// modify the email
actualContact.setEmail(modifiedEmail);
// serialize our modified contact into a json object
// this endpoint returns a list
// so we have to put it into an array
var modifiedJson = gson.toJson(new ContactDto[]{actualContact});
// finally, fulfill the request
// by setting our json as the response body
route.fulfill(new Route.FulfillOptions().setBody(modifiedJson));
});
// go back to the contacts page
var contactsPage = new ContactsPage(page).open();
// assert that all values match the contact we created using the UI
// expect the email as we modified it above
assertThat(contactsPage.getName()).hasText(format("%s %s", contactDto.getFirstName(), contactDto.getLastName()));
assertThat(contactsPage.getBirthday()).hasText(contactDto.getBirthdate());
assertThat(contactsPage.getEmail()).hasText(modifiedEmail);
assertThat(contactsPage.getPhone()).hasText(contactDto.getPhone());
assertThat(contactsPage.getAddress()).hasText(format("%s %s", contactDto.getStreet1(), contactDto.getStreet2()));
assertThat(contactsPage.getCity()).hasText(format("%s %s %s", contactDto.getCity(), contactDto.getStateProvince(), contactDto.getPostalCode()));
assertThat(contactsPage.getCountry()).hasText(contactDto.getCountry());
}