The Definitive Guide to API Test Automation With Playwright: Part 1 - Basics of API Testing GET Request With and Without Authorization
It's time to jump right in to building our your API tests with Playwright. First thing we will need is a website to test against. I have an awesome list of sites I keep up to date.
From this list I am going to pick a site that I know has a Front end and a Backend that I know will continue to be available for the foreseeable future. https://automationintesting.online/
This website is a companion to the Automation in Testing Workshop put out by Mark Winteringham and Richard Bradshaw, along with it being the featured system under test in the Testing Web APIs book (great book I highly recommend). The book has API automation examples using Java, I will be building out the same + additional examples featured in the book using Playwright.
I'm not going to go into great detail about how the site functions, but I would encourage you to take some time and explore the website with the Dev Tools Network tab open in your browser. Doing this will help you get a good understanding of what different endpoints do and how they are utilized in the UI of a site. One note, the endpoint that we will be automating today is only used on the admin section of the site (there is a link at the bottom of the page with un: admin | pw: password).
Booking Endpoints
We will first be focusing in on the booking endpoints for the restful booker platform. Thankfully there is a Swagger page (https://automationintesting.online/booking/swagger-ui/index.html) that is provided to see what endpoints are available to work with.
Before I ever start writing code, I always start by exploring the endpoints through a tool like Postman or this go around I'm testing out Thunder Client an extension I can use from VS Code.
The first thing I notice is some of the endpoints require a token.
- https://automationintesting.online/booking/summary?roomid=1 (no token needed)
- https://automationintesting.online/booking/ (needs a token)
After some exploring around I found that there is an auth endpoint with Swagger page (https://automationintesting.online/auth/swagger-ui/index.html#/) to be able to generate a token, and I can pass it into the above /booking/ call as a http header | cookie: token={token-goes-here}. There are many different ways an APIs authenticate users, and this is typically the first thing you will have to figure out when building out API automation. A decent article around some of the different technologies used in auth can be found here: Beginners Guide to HTTP Part 5 Authentication.
Explore the System Under Test!!!
This step is critically important. If you don't have a solid understanding of how the system you are testing, stop and do that first. For me I went ahead and built out a collection in Thunder Client with all the end points. When doing this I learned what endpoints required authentication, what endpoints required parameters, and learned about the json body un-documented limitations (ex: the phone number requires at least 11 characters and is expecting a string on the request body. Below is a walkthrough session I recorded interacting with all the endpoints. I parameterized the token to an environment variable, as I found I was having to update each request. This leads me to knowing I'll definitely be saving that as a variable in my API automation as the suite of tests grows!
One thing as I'm exploring the app for the first time and starting to think through how will we automate all the things, is how will we manage our test data. One really nice thing that I noticed when testing, is every 10min or so, any data that is created is wiped form the database, and it is re-seeded with some static data (specifically a booking from James Dean where the checkin date is in the past 2022-02-01
). We will create most of our assertions on this data in this tutorial as we are assuming it will always be available. If it weren't we would need to create data to assert against every time (which we will get to in later tutorials).
Let's write our first check!
Create a directory where you want to house your test suite. If this is your first time go ahead and use an empty folder on your computer. Assuming you have node installed, cd
into the directory with the empty folder and, let's run
npm init playwright@latest
This will take you through a series of question via the command line my answers are:
- Typescript
- tests
- n (We don't need a GitHub actions file yet.)
- n (We don't need the browsers, we're testing the API!)
Once the command is finished you should have a tests
and tests-examples
folder along with a package.json
and playwright.config.ts
in your main directory.
First thing we will do is update the playwright.config.ts
to the following
import { defineConfig, devices } from "@playwright/test";
import { config } from "dotenv";
config();
export default defineConfig({
use: {
baseURL: process.env.URL,
ignoreHTTPSErrors: true,
trace: "retain-on-failure",
},
retries: 0,
reporter: [["list"], ["html"]],
});
Install dotenv
which will allow us to use a .env file at the root of our project for environment variables.
npm install dotenv --save
Next delete the /tests-examples/
directory
Next we will modify the example.spec.ts
to build out the most simple API GET request. The official docs for API testing describe two ways of making API calls, the built in request
fixture which we will use below, or using request context
. We will focus our testing on using the request
fixture which can be used within test blocks. We will use request context
when we need to make API calls outside of our test block (from a function that lives in another file outside of the test block).
import { test, expect } from "@playwright/test";
test("GET booking summary", async ({ request }) => {
const response = await request.get(
"https://automationintesting.online/booking/summary?roomid=1"
);
expect(response.status()).toBe(200);
const body = await response.json();
console.log(JSON.stringify(body));
});
This test will make a GET call to the summary?roomid=1 with no authentication. I currently save the response to a response
variable which represents the APIResponse class. This allows us access to the response body object, response body in JSON, response body in text, response headers, status code, status text, url, and a method called .ok()
which will return true if the status code is between 200-299.
For our first test, I am only making an assertion on the response.status()
expecting it toBe 200. I also show how to interact with the json body from the response as we will want to make some assertions against it.
Let's clean it up and add some better assertions
Let's first create a .env
file in the root directory. and add 1 line for the URL. If you look back in your `playwright.config.ts` we added a baseURL: process.env.URL. We will be setting that value with the below line.
URL=https://automationintesting.online/
Now we can clean up our spec file.
- I want to organize my specs by endpoint, so I'll create a
/booking/
folder in the test director. - I'll rename
example.spec.ts
tobooking.get.spec.ts
- And I'll update the spec adding a describe block, a better named test, and some additional assertions.
- I'll also add a helper function isValidDate() to validate the dates returned for checkin and checkout are real dates.
import { test, expect } from "@playwright/test";
test.describe("booking/summary?roomid={id}", async () => {
test("GET booking summary with specific room id", async ({ request }) => {
const response = await request.get("booking/summary?roomid=1");
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.bookings.length).toBeGreaterThanOrEqual(1);
expect(isValidDate(body.bookings[0].bookingDates.checkin)).toBe(true);
expect(isValidDate(body.bookings[0].bookingDates.checkout)).toBe(true);
});
});
export function isValidDate(date: string) {
if (Date.parse(date)) {
return true;
} else {
return false;
}
}
Let's add automated checks for the rest of the GET endpoints to this file
The next two GET endpoints do require authentication via the token saved as a cookie, so we will have to pass in the cookie as a header in order successfully make the call. The two calls left are GET /booking
and GET /booking/{id}
. I went back to the thunder client and made a successful call to understand the response body, this can also be done via the test itself and debugging the test after you assign the body to a variable, but I like to keep things as simple as possible.
My next iteration of code I first add a new variable within the describe block on line 4 savedToken
this is a value that we will programmatically set in our next step, but for testing I went head and just hardcoded a value. You can see our get request now has an additional option for header where we are passing in a cookie, with the token=${savedToken}
, in JavaScript when ` is used for a string, you are allowed to add code within ${}, this is called interpolation, and its super handy when writing automation. We are also going to make assertions on the data we are expecting to be there based on the exploring we did earlier. Also note we are doing assertions on every value in the returned body. This is typically a good practice if we assume all the data should be returned.
Also in this I found a bug that should be reported to the developer, in the booking/summary
call the bookingDates
are camel case, where the response from booking/
the bookingdates
object is all lowercase. It's small but by going through and automating this section it was easy to notice the differences.
import { test, expect } from "@playwright/test";
test.describe("booking/ GET requests", async () => {
const savedToken = "r2dBKvt8rCo5p74s";
test("GET booking summary with specific room id", async ({ request }) => {
const response = await request.get("booking/summary?roomid=1");
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.bookings.length).toBeGreaterThanOrEqual(1);
expect(isValidDate(body.bookings[0].bookingDates.checkin)).toBe(true);
expect(isValidDate(body.bookings[0].bookingDates.checkout)).toBe(true);
});
test("GET all bookings with details", async ({ request }) => {
const response = await request.get("booking/", {
headers: { cookie: `token=${savedToken}` },
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.bookings.length).toBeGreaterThanOrEqual(1);
expect(body.bookings[0].bookingid).toBe(1);
expect(body.bookings[0].roomid).toBe(1);
expect(body.bookings[0].firstname).toBe("James");
expect(body.bookings[0].lastname).toBe("Dean");
expect(body.bookings[0].depositpaid).toBe(true);
expect(isValidDate(body.bookings[0].bookingdates.checkin)).toBe(true);
expect(isValidDate(body.bookings[0].bookingdates.checkout)).toBe(true);
});
//booking/{id}
});
export function isValidDate(date: string) {
if (Date.parse(date)) {
return true;
} else {
return false;
}
}
The next step we'll go ahead and add automation for the GET booking by id with details GET booking/1
. For this I went ahead and copied and pasted the previous test and started modifying to match what I was seeing in the Thunder Client. First off there isn't a bookings
array so I removed all of those from each assertion and also moved the toBeGreaterThanOrEqual()
assertion.
import { test, expect } from "@playwright/test";
test.describe("booking/ GET requests", async () => {
const savedToken = "r2dBKvt8rCo5p74s";
test("GET booking summary with specific room id", async ({ request }) => {
const response = await request.get("booking/summary?roomid=1");
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.bookings.length).toBeGreaterThanOrEqual(1);
expect(isValidDate(body.bookings[0].bookingDates.checkin)).toBe(true);
expect(isValidDate(body.bookings[0].bookingDates.checkout)).toBe(true);
});
test("GET all bookings with details", async ({ request }) => {
const response = await request.get("booking/", {
headers: { cookie: `token=${savedToken}` },
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.bookings.length).toBeGreaterThanOrEqual(1);
expect(body.bookings[0].bookingid).toBe(1);
expect(body.bookings[0].roomid).toBe(1);
expect(body.bookings[0].firstname).toBe("James");
expect(body.bookings[0].lastname).toBe("Dean");
expect(body.bookings[0].depositpaid).toBe(true);
expect(isValidDate(body.bookings[0].bookingdates.checkin)).toBe(true);
expect(isValidDate(body.bookings[0].bookingdates.checkout)).toBe(true);
});
test("GET booking by id with details", async ({ request }) => {
const response = await request.get("booking/1", {
headers: { cookie: `token=${savedToken}` },
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.bookingid).toBe(1);
expect(body.roomid).toBe(1);
expect(body.firstname).toBe("James");
expect(body.lastname).toBe("Dean");
expect(body.depositpaid).toBe(true);
expect(isValidDate(body.bookingdates.checkin)).toBe(true);
expect(isValidDate(body.bookingdates.checkout)).toBe(true);
});
});
export function isValidDate(date: string) {
if (Date.parse(date)) {
return true;
} else {
return false;
}
}
Now we have a good set of checks that test the happy path scenarios of the 3 GET calls under the bookings api. Next lets create an API call that we can use in the beforeAll() step in the playwright test, which saves the authentication cookie.
The first thing I did was create a post request passing in a username and password into the body (data), and inspecting the response from the provided methods that the APIResponse provides. I did this using the VS Code debugger for Playwright, it can be quite useful writing code and learning more about how Playwright and JavaScript work.
From debugging the response headers I decided to just use the response.headers()
function and set that to a variable named cookies.
import { test, expect } from "@playwright/test";
test.describe("booking/ GET requests", async () => {
let cookies = "";
test.beforeAll(async ({ request }) => {
const response = await request.post("auth/login", {
data: {
username: "admin",
password: "password",
},
});
expect(response.status()).toBe(200);
const headers = await response.headers();
cookies = headers["set-cookie"];
});
...
As you can see in the code above, before I set the variable in the beforeAll block, I actually created the variable outside within the describe block. This has been a best practice for me and my team, as it allows us to re-use these variables for any of my tests. Also note I used a let
variable this allows the variable to change or be set (as we are in the beforeAll block).
Now we have our cookies which is actually more than the token, we can refactor our code to pass in the whole cookie header.
# From
test("GET all bookings with details", async ({ request }) => {
const response = await request.get("booking/", {
headers: { cookie: `token=${savedToken}` },
});
# To
test("GET all bookings with details", async ({ request }) => {
const response = await request.get("booking/", {
headers: { cookie: cookies },
});
To ensure that our code isn't flakey I'll go ahead and run npx playwright test --repeat-each=10
which will run all the tests 10 times each, and 💥 They all passed!
The repo & branch (api-part1) for the working code can be found here.
In the next section Part 2 we will continue to work on this example code, by adding even more assertions on the GET booking endpoints, along with adding coverage for the over booking endpoints. We'll also go ahead and reorganize some of our code putting reusable methods in a separate area of the code base so things are nice and tidy.
Big thanks and shout out to Joel Black and Sergei Gapanovich without their influences, feedback, and code reviews in my life these examples would be much more terrible 😅.
Thanks for reading! If you found this helpful, reach out and let me know on LinkedIn or consider buying me a cup of coffee. If you want more content delivered to you in your inbox subscribe below.