How would you intercept a network request and use the data for assertions? Mock a network request using those assertions? And make sure that all data is loaded in the UI before making your assertions, and that all tests can pass when run?
We will be walking through Butch Mayhew's code that answers all of these questions, part of his LinkedIn Learning course: Playwright Essential Training. We will be examining the shopping cart test site PracticeSoftwareTesting.com and looking at code on Butch's companion GitHub site on how to mock out the API and use them in the UI tests.
Playwright has a lot of features when it comes to API testing. You can intercept network requests, aborting, modifying, and mocking network requests. You can also simulate a slow network. This is all done through the Playwright methods: page.route.
A route is the specific path or URL a client uses to request data or trigger a function on a server. GET /product would be an API call that gets everything from the address string called products, which then retrieves all products from the API endpoint called "products". The endpoint is "products" and the route is the name that accesses the endpoint.
The Playwright API has a method called route, which allows Playwright to monitor and modify browser network traffic.
- Playwright.dev / Docs / Network: https://playwright.dev/docs/network
- Playwright and page.route: https://playwright.dev/docs/api/class-route
- Playwright Mocking API Guide: https://playwright.dev/docs/mock
Adding page.route to your test allows you to do things such as:
- abort the route request with route.abort(), simulating various error codes such as accessdenied, addressunreachable, blockedbyclient, blockedbyresponse, connection aborted, connectionclosed, timedout, etc.
- continue sending the route's request with optional overrides such as changing the request URL, the headers, the method such as GET or POST, and the post data of the request.
- fetch the request without yet fulfilling it, so the response can be saved, modified, and fulfilled later. You can also change the HTTP headers, follow redirects automatically, etc.
- fulfill the route's request.
- request that the request is routed.
About Our Site Under Test
PracticeSoftwareTesting.com is a shopping cart with a web-based front-end pulling data from a web-based back end. Tools can be filtered out by category, such as hand tools (hammer, hand saw, wrench), and power tools ( grinder, sander, saw ) and by brand, such as ForgeFlex Tools and MightyCraft Hardware. Check the appropriate check box, and cards are display in a grid fashion of products.
These products can also be sorted, such as by "Name (A - Z)" and ""Name (Z - A)"
Explore the HTML of the site by opening up Google Chrome, right clicking on the first product in the grid to "Inspect" to open Chrome Dev Tools and look at the Elements tab to find locators:
- Product Grid: Class col-md-9
This class can be used as a locator to find the product grid.
About the Products API
Let's see an example product returned by the Products API Endpoint:
- Go to https://practicesoftwaretesting.com/
- Under the Filters category, check the checkbox "Saw" under "Power Tools". You will see one item display: a circular saw.
But where did the product information come from?
- Under Chrome Developer Tools, select the "Network" tab
- Uncheck and then check the "Saw" checkbox, and you will see a new products query at the bottom looking like products?page=page=0... select that.
- Select the "Response" tab.
"current_page": 1,
"data": [
{
"id": "01KSMVRWS80N7909CZTE7ACJQS",
"name": "Circular Saw",
"description": "Portable circular saw powered by a robust ...",
"price": 80.19,
"is_location_offer": false,
"is_rental": false,
"co2_rating": "D",
"in_stock": true,
"is_eco_friendly": false,
...
"category": {
"id": "01KSMVRWMQCBR9XDQ5TGC47MS6",
"name": "Saw",
"slug": "saw"
},
"brand": {
"id": "01KSMVRW6HT9HQZ2E113FQEFX7",
"name": "ForgeFlex Tools"
}
}
All this data was returned in a JSON format when the web app took the information in the filter and made the call to the product API
- https://api.practicesoftwaretesting.com/products?page=0&between=price,1,100&by_category=01KSMVRWMQCBR9XDQ5TGC47MS6&is_rental=false
All these parameters can be used to test out how your UI behaves under various conditions.
How to Intercept Network Responses
We can intercept the information coming back from the API using route.fetch and then using route.continue:
await page.route(
"https://api.practicesoftwaretesting.com/products**"
async (route) => {
const response = await route.fetch();
products = await response.json();
route.continue();
}
};
A glob, or "global command", is a pattern of characters used to find files, folders, or web addresses. The "**" is a globbing wildcard that matches all the nested folders which come after it. Had Butch used only one "*" it would have only returned the next level in the folder hierarchy.
What does this code do?
- await page.route: Tells Playwright to listen to network requests that match this URL pattern.
- async (route): Creates a callback function that triggers each time a call is made.
- const response: Declares a new constant variable called "response" where we will store the HTTP response retrieved.
- await route.fetch(): Performs the actual network request and retrieve the reponse.
- products = await response.json: Extracts the reponse payload as a JavaScript Object (JSON) and assigns it to a variable named "products".
- route.continue: Proceed with the original request.
How to Abort API Requests
Why would we want to abort a certain network call? Butch mentions in his LinkedIn Learning class how a certain pop up was interfering with a UI test he was writing.
You can also use route.abort in order to simulate the Product API being down, and check to see that the UI is behaving as it should.
Sample code Butch listed:
await page.route(
"https://api.practicesoftwaretesting.com/products**"
(route) => route.abort()
};
With this code, we are seeking to intercept anything from the Products API, and when the match is found, cancel the network request before it ever reaches the server.
How to Mock a Product
Let's say we want to see how our UI behaves with product names that are quite long, prices with many digits, or if a product is in stock. We can add these test products to the database and then search for them, or we can:
- Capture the response
- Alter any of the fields returned
- Submit that mock data as if coming from the API itself.
await page.route(
"https://api.practicesoftwaretesting.com/products**"
async (route) => {
const response = await route.fetch();
const json = await response.json();
json.data[0]["name"] = "Mocked Product";
json.data[0]["price"] = 100000.01;
json.data[0]["in_stock"] = false;
await route.fulfill({ response, json } ))
}
};
... For this, we are editing the name, price, and the variable to see if the item is in stock or out of stock.
json.data[0] represents the very first product listed.
How to Mock API Requests with HAR files
If you want to capture all HTTP traffic and store it, you can store it in an HTTP Archive Record, or HAR file.
According to Playwright.dev, a "HAR file is an HTTP Archive file that contains a record of all the network requests that are made when a page is loaded. It contains information about the request and response headers, cookies, content, timings, and more. You can use HAR files to mock network requests in your tests. You'll need to: 1) Record a HAR file. 2) Commit the HAR file alongside the tests. 3) Route requests using the saved HAR files in the tests.
"To record a HAR file we use page.routeFromHAR() or browserContext.routeFromHAR() method. This method takes in the path to the HAR file and an optional object of options. The options object can contain the URL so that only requests with the URL matching the specified glob pattern will be served from the HAR File. If not specified, all requests will be served from the HAR file.
"Setting update option to true will create or update the HAR file with the actual network information instead of serving the requests from the HAR file. Use it when creating a test to populate the HAR with real data".
await page.routeFromHAR(".hars/product.har", {
url: "https://api.practicesoftwaretesting.com/products**"
update: false,
});
With this code we are:
- Telling Playwright to intercept all network traffic for the current page using a recorded HAR file and store it in the root directory folder we are calling ".hars" in the file "product.har".
- Update: false runs the test in playback mode. We can run the test once with "update: true" to capture and store the information, then reset it to "update: false" to not have the data overwritten.
The problem with this approach is that sensitive data such as passwords might be part of the HAR file. To sanitize sensitive data, see the Medium article: HARmageddon is cancelled: how we taught Playwright to replay HAR with dynamic parameters
Simulating Slow API Requests
Let's say we want to see how the UI responds when it takes a good 4 seconds (4000 milliseconds) for the API to respond.
await page.route(
"https://api.practicesoftwaretesting.com/products**"
async (route) => {
await page.waitForTimeout(4000)
await route.continue();
}
};
We can then add a bit of lag into the system, then continue as normal.
Write a Test: Validating Brands
Putting together Butch's lessons above, Butch had an exercise listed: How can you validate the brands listed in the Filter on the web site matches the ones listed in the brand endpoint? And how can you do it by intercepting network data?
If you go to https://practicesoftwaretesting.com/ you can see that under the Filters, there are two major brands: ForgeFlexTools and MightyCraft Hardware.
As mentioned earlier, these products can be sorted, such as by "Name (A - Z)" and ""Name (Z - A)"
Explore the HTML of the site by opening up Google Chrome, right clicking on the first product in the grid to "Inspect" to open Chrome Dev Tools and look at the Elements tab to find locators:
- Product Grid: Class col-md-9
This class can be used as a locator to find the product grid.
We can get the information about the brands from the API, which Butch stored in the .env file as API_URL.
Using this information, we can write an end-to-end test to verify that the dynamic brand data loads correctly on a web page.
This test verifies that the web application displays a list of product brands on its front end by intercepting the backend API network responses.
home.spec.ts
test("validate brands by intercepting network data", async ({ page }) => {
let brands: any;
const apiUrl = process.env.API_URL;
await test.step("intercept /brands", async () => {
await page.route(apiUrl + "/brands", async (route) => {
const response = await route.fetch();
brands = await response.json();
route.continue();
});
});
await page.goto("/");
const productGrid = page.locator(".col-md-9");
await expect(productGrid).toBeVisible();
await expect(page.locator(".skeleton").first()).not.toBeVisible();
const brandFilterSection = page.getByText("SortName (A - Z)Name (Z - A)");
for (const brand of brands) {
await expect(brandFilterSection).toContainText(brand.name);
}
});- let brands: any: Declaring an empty variable to store all the brands found when they are fetched from the network. It is declared outside the test step so we can use it later on. It is set to "any" in order to keep it undefined for now.
- const apiUrl gets the API URL from the .env folder.
- await test.step("intercept /brands: This time, instead of products, we are registering a handler that fires whenever a request matches the URL pattern of "/brands".
- route.fetch makes the real network request on behalf of the browser.
- response.json parses and captures the response body into the variable, "brands".
- route.continue forwards the original response to the browser unchanged, so the UI renders normally.
The UI is getting the real data, with the test getting a copy of it.
Then we are doing:
- page.goto("/"): Loads the page and the browser makes the call, including GET /brands.
- When the page loads and the browser calls GET /brands, the handler executes and the "brands" variable we set up is populated.
- page.locator: Sets up the contant variable productGrid as a locator for the product grid.
- We expect the product grid to be visible, showing that the page is now loaded, and that the initial skeleton element placeholders PracticeSoftwareTesting.com has is no longer visible.
Finally, we are validating the UI data against the API data we have captured:
- We locate the Brand Filter Section web element by text, where we can sort the filter.
- We then loop through every single brand object that was captured in the "brand" variable.
- We confirm that each brand name provided in the backend API is accurately printed as text inside the frontend filter sidebar.
Write a Test: Validating Categories By Mocking
Let's say we want to write a test that the Categories API correctly adds mocked categories and subcategories to the left side index. Butch shows an example:
home.spec.ts
test("validate categories render in UI by mocking", async ({ page }) => {
let categories: any;
const apiUrl = process.env.API_URL;
await test.step("intercept /categories", async () => {
await page.route(apiUrl + "/categories/tree", async (route) => {
const response = await route.fetch();
const json = await response.json();
categories = json.data;
json[0].name = "Mocked Category";
if (json[0].sub_categories && json[0].sub_categories.length > 0) {
json[0].sub_categories[0].name = "Mocked Subcategory";
}
await route.fulfill({ response, json });
});
});
await page.goto("/");
const productGrid = page.locator(".col-md-9");
await expect(productGrid).toBeVisible();
await expect(page.locator(".skeleton").first()).not.toBeVisible();
const categoryFilterSection = page.getByText("SortName (A - Z)Name (Z - A)");
await expect(categoryFilterSection).toContainText("Mocked Category");
await expect(categoryFilterSection).toContainText("Mocked Subcategory");
});Walking through this code:
Create a new test:
- A new Playwright test case is defines, using a page fixture.
- We declare the undefined variable "categories" and add the API URL into a constant variable.
- We create a new test step, letting readers know we are going to intercept the /categories data.
Intercept the response:
- With page.route(apiUrl + "/categories/tree" we tell the browser to watch for and pause any outgoing requests matching /categories/tree.
- Route.fetch allows the real request to fetch the real response, and store it in the variable called "response".
- We then take that response in json format and place it in a variable we are calling "json".
Edit that response so the first item has "Mocked Category" and "Mocked Subcategory":
- We are going to take that first name, [0] and assign it the string "Mocked Category".
- If there are subcategories in that intercepted response, and the sub_categories length is greater than zero, we will assign the string to it: "Mocked Subcategory".
- route.fulfill will send this modified JSON response back to the frontend application instead of the original server data.
- Hopefully, in the left hand side panel of PracticeSoftwareTesting.com, under "Category" we will see "Mocked Category" and "Mocked Subcategory".
- We set up productGrid as a web element, using the class we found .col-md-9 as a locator.
- We expect the productGrid to be visible and the .skeleton to not be shown.
Expect the "Mocked Category" to appear!
- We declare a new web element, categoryFilterSection, using the SortName as a locator.
- Can we verify that the phrase "Mocked Category" appears? It passes!
- Can we verify that the phrase "Mocked subcategory" appears? It passes!
Whew! And this is just a taste on what Playwright can do to test at the API level. You can read more in the Playwright.dev Docs which covers API Testing, and the classes API Request, API Request Context, and API Response.
-T.J. Maher
Software Engineer in Test
BlueSky | YouTube | LinkedIn | Articles
No comments:
Post a Comment