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.

A laptop with a code editor open

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:

Contacts page

A form to add a new contact:

Add contact page

I created three classes to describe these two pages and the login page:

  • LoginPage with a login(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 a fillForm(ContactDto contactDto) method to fill the form and a submitForm() 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:

IntelliJ IDEA: Run configuration

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());
}

Useful links