What this class covered
- Ready first, then three levels of API tests
- The ping test and the request fixture
- Where the base URL comes from
- Choosing a reporter, and screenshots
- POST /booking: payload and headers
- Postman or Playwright, and other questions
- A new request context
- PUT: a token, a booking, then the update
- Negative tests from AI, and the Ponytail review
- Which requests need a token
- Tasks, and what comes next
- Have the test plan, the test cases and the Postman collection ready first. Then the API tests grow in three levels: raw, an API helper, a fixture. You keep only the last.
- An API test takes the built-in
requestfixture instead ofpage, and gives only the last part of the path, such as/ping. The base URL comes from outside, on the command line. - In this framework
BASE_URLis checked first, soTTA_ENV=apiworks only whenBASE_URLis empty in.env. - Keep all the reporters in the config, and let the user pick one with
--reporter. - To log an object, use
JSON.stringify, or you get[object Object]. - In restful-booker, PUT, PATCH and DELETE need a token. Create the token and the booking first, then send
Cookie: token=.... Never print the token in a report. - AI agents add extra code: give their tests a Ponytail review before you push.
Ready first, then three levels
Last class ended with a task: a test plan, test cases and a Postman collection for restful-booker. This class starts from there. The instructor called it the endgame: the APIs go into the Playwright framework, and then comes the last part, the AI part.
First, a walk through the Postman collection. You can generate a token, get all the bookings, create a booking with a JSON payload, and get one booking. You can update a booking or partially update it, but for an update you need a token: set it first, or the answer is forbidden. Deleting needs a token too. So, at this level, you need three things: a base URL, a token and a booking ID. The endpoints to automate are the eight on last class's page: ping, auth, get bookings, get one booking, POST, PUT, PATCH and DELETE.
The most important thing right now, the instructor said, is that three things are ready for restful-booker: the test plan, the test cases and the Postman collection. Until they are, it will be very difficult to proceed. A few learners had done it, and a few had not. With all three ready, you can select a test case and automate it, and this is the real testing starting.
Three levels. The specs go in a new folder, apiTests, beside the end-to-end tests. Its first folder is 01_restfulbooker_raw, and raw means basic test cases. Level 2 stays on restful-booker and uses an API helper. Level 3 uses a fixture, which the instructor called the final boss, with the proper end-to-end API tests.
The instructor keeps all three folders in order to teach them. You will keep only the last one. It is how the end-to-end tests grew too: first a basic login test, then checkout, then fixtures, then a Ponytail review, and last the visual step. Taught directly with a fixture, the instructor said, you would be a little confused, so the code gets better slowly.
In the repo, this class's code is the folder 01_restfulbooker_raw: five specs numbered 01 to 06, with no 05.
The ping test and the request fixture
A ping request helps you test if the server is up: a communication between client and server, to check that all is well. It is like a health check of an API.
The instructor's own ping: every time the class starts, the first thing typed is "Is my audio video is good?" Before teaching anything at all, that is the health check.
In Playwright, the instructor said, API testing is super easy. The class opened the Playwright API Testing page from The Testing Academy's Playwright notes. Its diagram, the instructor said, has everything, and the 29 October page draws it. The request fixture is already created, like the page fixture. It uses something called APIRequestContext, which makes the HTTP request and gives you the response automatically. Expectations you already know. So where a test used to take page, it now takes request, and that is the only thing you need to do.
import { test, expect} from '@playwright/test';
import { request } from 'node:http';
// Ping Request.
// Ping request help you to test if the server is up.
// Client -> Server to check if all well.
// HTTP GET -> https://restful-booker.herokuapp.com/ping
test('ping request - GET', async({ request})=>{
const responseData = await request.get("/ping");
console.log(responseData);
expect(responseData.status()).toBe(201);
});
When you type request., you can see all the HTTP functions it can make. On the installed Playwright, a run for these notes lists them: get, post, put, patch, delete, head, fetch, and also dispose, storageState, cookies, addCookies and clearCookies.
The path is only /ping. Where the rest of the URL comes from is the next section. And status is a function you call directly, the instructor pointed out, with no await. The test expects 201.
Run for these notes:
BASE_URL=https://restful-booker.herokuapp.com npx playwright test src/tests/apiTests/01_restfulbooker_raw/01_basic_ping.spec.ts --reporter=list
Running 1 test using 1 worker
APIResponse: 201 Created
Content-Length: 7
Content-Type: text/plain; charset=utf-8
Date: Thu, 05 Nov YYYY 06:29:05 GMT
Etag: W/"7-rM9AyJuqT6iOan/xHh+AW+7K/T8"
Server: Heroku
Via: 1.1 heroku-router
X-Powered-By: Express
✓ 1 [chromium] › src/tests/apiTests/01_restfulbooker_raw/01_basic_ping.spec.ts:10:5 › ping request - GET (711ms)
1 passed (992ms)
console.log(responseData) prints the status and its text, then the headers. Three long Heroku reporting headers and the year in the date are cut above. In class the instructor found the same two things in the output: 201 and created. The repo's README counts this among the API's unusual status codes: /ping returns 201.
Line 2 is never used. The request inside the test is the fixture from the test's own parameters, so the import from node:http does nothing. With that line removed, the spec passed the same way in a run for these notes. In the new-context spec below, request is imported from @playwright/test, and there the import is what gets used.
Where the base URL comes from
Do we use the full URL, or something else? This is very controversial, the instructor said, and the mystery is solved by the Playwright configuration:
function resolveBaseURL(): string {
if (process.env.BASE_URL) return process.env.BASE_URL;
const env = (process.env.TTA_ENV || 'qa').toLowerCase();
switch (env) {
case 'api':
return process.env.API_BASE_URL || 'https://restful-booker.herokuapp.com';
case 'dev':
case 'local':
return process.env.DEV_BASE_URL || 'http://localhost:3000';
case 'stg':
case 'stage':
case 'staging':
return process.env.STG_BASE_URL || 'https://stage.thetestingacademy.com';
case 'prod':
case 'production':
return process.env.PROD_BASE_URL || 'https://app.thetestingacademy.com';
case 'qa':
default:
return process.env.QA_BASE_URL || 'https://app.thetestingacademy.com';
}
}
Line 56 of the same file sets baseURL: resolveBaseURL(). The condition, in the instructor's reading: if BASE_URL is set, use it. Else check TTA_ENV, and if that is api, the API base URL is used. The advantage is that you do not have to type the base URL: it comes from the configuration, and you add only the last part, ping.
The class also tried exporting TTA_ENV as api, then running the npx command. In that run, the instructor thought, the environment had not been set. Then came the AI's explanation, read out: TTA_ENV will not work, because the URL resolver checks BASE_URL first, so it is better to set the base URL. The command it gave set BASE_URL first, then the test, then the custom reporter. An inline value overrides, the instructor said. There are multiple options, so use whichever works for you, and the command the AI gave, the instructor thought, is actually good: it contains everything. The repo's README gives the same form for the whole folder:
BASE_URL=https://restful-booker.herokuapp.com npx playwright test src/tests/apiTests # API specs
Why TTA_ENV lost: the deck's .env, from the framework build, sets BASE_URL to the TTA site, and the README and .env.example both say TTA_ENV works only when BASE_URL is empty. Run for these notes, with a spec that only prints its baseURL:
| In the file | On the command | The spec's base URL |
|---|---|---|
no .env |
nothing | the TTA site |
no .env |
TTA_ENV=api |
restful-booker |
BASE_URL is the TTA site, as in the deck |
nothing | the TTA site |
BASE_URL is the TTA site, as in the deck |
TTA_ENV=api |
the TTA site |
BASE_URL is the TTA site, as in the deck |
BASE_URL= restful-booker |
restful-booker |
BASE_URL is empty, as in .env.example |
TTA_ENV=api |
restful-booker |
The fourth row is the one to remember. With that .env, the class's ping goes to the wrong host, gets a 200 with an HTML page, and fails (the other headers are cut here):
TTA_ENV=api npx playwright test src/tests/apiTests/01_restfulbooker_raw/01_basic_ping.spec.ts --reporter=list
APIResponse: 200 OK
Content-Type: text/html; charset=utf-8
✘ 1 [chromium] › src/tests/apiTests/01_restfulbooker_raw/01_basic_ping.spec.ts:10:5 › ping request - GET (507ms)
1) [chromium] › src/tests/apiTests/01_restfulbooker_raw/01_basic_ping.spec.ts:10:5 › ping request - GET
Error: expect(received).toBe(expected) // Object.is equality
Expected: 201
Received: 200
11 | const responseData = await request.get("/ping");
12 | console.log(responseData);
> 13 | expect(responseData.status()).toBe(201);
| ^
14 | });
at src/tests/apiTests/01_restfulbooker_raw/01_basic_ping.spec.ts:13:39
1 failed
The README warns about this too: a plain npx playwright test now picks up these specs, and most of them fail unless BASE_URL points at restful-booker.
The order of preference. Asked whether the order is command line, test file and configuration: yes, you can say that, the instructor said. First the command line, then the test file, and then the configuration file.
One exception, from a run for these notes: with BASE_URL inline and a test.use({ baseURL }) in the spec file, the spec got the test file's value. The variable only reaches a test through the config, so a test file's own setting still beats it.
Jenkins. Where does this command go when the automation runs? Into Jenkins, the instructor said. In Jenkins you change the command whenever you run for the API, and there is an option to select: run the API test cases, run both, or run only the UI test cases.
Two short answers. Asked whether the URL can be stored in a global variable: you can. And asked whether a different host URL can be added as a full path: you just add it here, the instructor said.
Choosing a reporter, and screenshots
The batch config lists three reporters. Line means the command line, HTML is Playwright's default, and the third is the TTA custom reporter:
reporter: [
['line'],
['html', { open: 'never' }],
/* TTA custom report, written to tta-report/ (index.html always points to the latest run). */
['./src/utils/CustomReporter.ts'],
],
Which one do you want? With only one in the list you do not have to type anything, the instructor said, but with three you can choose: sometimes only HTML, sometimes line. For a web test you may want the custom reporter, while for an API test the custom reporter does not make sense, and a normal HTML reporter does. So always keep all of them, and through the --reporter argument, ask the user. Give this ability to the user. The base URL, the reporter and the project: we will ask everything from the user.
Asked whether this could be configured in the framework instead of passed on the command: we can, the instructor said, but give the ability to the user also.
--reporter replaces what the config lists. Each of these ran and passed for these notes, under the batch config; the second writes playwright-report/, the third tta-report/:
BASE_URL=https://restful-booker.herokuapp.com npx playwright test src/tests/apiTests/01_restfulbooker_raw/01_basic_ping.spec.ts --reporter=line --project=chromium
BASE_URL=https://restful-booker.herokuapp.com npx playwright test src/tests/apiTests/01_restfulbooker_raw/01_basic_ping.spec.ts --reporter=html --project=chromium
BASE_URL=https://restful-booker.herokuapp.com npx playwright test src/tests/apiTests/01_restfulbooker_raw/01_basic_ping.spec.ts --reporter=line,./src/utils/CustomReporter.ts --project=chromium
--project=chromium, the instructor said, is because we want to use Chromium as the browser.
An API test still opens no browser: it asks for request, not page, and the README says these specs run with no browser. Run for these notes under the batch config, with no browser installed at all, the ping spec still passed.
Screenshots. Asked how to take a screenshot of a passed or failed test: Playwright does this automatically, the instructor said, with no code to write. In the configuration, screenshot: 'on' turns it on, and there is also an option for only on failure. The class uses on, every time, because the custom reporter has this option. Asked where they are stored: in the report, in the Playwright report data, with the videos.
/* Media for the custom report. 'on' captures for every test, pass or fail.
For a real suite prefer 'only-on-failure' / 'retain-on-failure'. */
screenshot: 'on',
video: 'on',
trace: 'on',
An API test has no screenshot and no video, the instructor said. Under this config, with screenshot, video and trace all on, a run for these notes left a trace.zip and no image or video in test-results/.
POST /booking: payload and headers
The next test case is create booking. Before writing it, the class listed what changes. The URL is the base URL plus /booking, and the HTTP method is POST. There is a payload, which can be a simple JSON file or a JS object: some people call it the request body, the body or the data. Headers need to be set. And authentication? For create booking, nothing is required, and no token. The @p0 in the title is just a tag.
import { test, expect } from '@playwright/test';
import { logger } from '@utils/logger';
// Ping Request.
// Ping request help you to test if the server is up.
// Client -> Server to check if all well.
// HTTP GET -> https://restful-booker.herokuapp.com/ping
test('TC#1 @p0 - POST : Verify that create booking is working fine.', async ({ request }) => {
// HTTP -> POST
// URL => https://restful-booker.herokuapp.com/booking
// PAYLOAD / request body /. Body / data -> json file or js object
// headers - content-type json
// auth ?
const payload = {
firstname: 'Jim',
lastname: 'Brown',
totalprice: 111,
depositpaid: true,
bookingdates: {
checkin: '2018-01-01',
checkout: '2019-01-01',
},
additionalneeds: 'Breakfast',
}
logger.info(`Payload is set: ${JSON.stringify(payload)}`);
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
logger.info(`Header is set: ${JSON.stringify(headers)}`);
const responseData = await request.post('/booking', {
headers,
data: payload,
});
expect(responseData.status()).toBe(200);
const data = await responseData.json();
logger.info(`Response is set: ${JSON.stringify(data)}`);
expect(data.bookingid).toBeTruthy();
expect(data.booking.firstname).toBe(payload.firstname);
expect(data.booking.lastname).toBe(payload.lastname);
expect(data.booking.bookingdates.checkin).toBe(payload.bookingdates.checkin);
logger.info(`Created booking id: ${data.bookingid}`);
});
Lines 4 to 8 are the ping spec's comments: the file began as a copy of it.
Headers. Content-Type says what the content is, and Accept what comes back. Here both say JSON.
A header, the instructor said, basically means a contract between client and server. The content we are sending is JSON, and we accept JSON. Do not send me XML, I will not accept. Do not send me a text file or an image, I will not accept.
Asked whether this header is the same for most applications: 90% this is the same header, the instructor said, and the developer will tell you which one to use.
The call. request.post takes the URL first, then headers and data as the second argument. Someone had said the base URL has to be set again and again, so the instructor showed a way round it: a template literal around process.env.API_BASE_URL, using that variable if it is set and a default if not. Then the verdict: ideally, this is not a good approach. The best approach is the plain /booking, because the base URL can then be set from outside, on the command line. The pushed spec has the plain path.
The checks. The status should be 200. Then responseData.json() gives the data, and everything can be verified. toBeTruthy on data.bookingid means it should exist: if it is null or zero or something, the booking was not created. In Playwright's own types, truthy means anything but false, 0, '', null, undefined or NaN. First name and last name should equal the payload's. A learner asked whether check-in and check-out can be checked too. Absolutely: keep adding dots, data.booking.bookingdates.checkin. The pushed spec has check-in. Asked whether each field needs its own step: we can verify in one go also.
Run for these notes:
BASE_URL=https://restful-booker.herokuapp.com npx playwright test src/tests/apiTests/01_restfulbooker_raw/02_post_operation.spec.ts --reporter=list
Running 1 test using 1 worker
YYYY-MM-DD 11:59:06 [info] Payload is set: {"firstname":"Jim","lastname":"Brown","totalprice":111,"depositpaid":true,"bookingdates":{"checkin":"2018-01-01","checkout":"2019-01-01"},"additionalneeds":"Breakfast"}
YYYY-MM-DD 11:59:06 [info] Header is set: {"Accept":"application/json","Content-Type":"application/json"}
YYYY-MM-DD 11:59:07 [info] Response is set: {"bookingid":4284,"booking":{"firstname":"Jim","lastname":"Brown","totalprice":111,"depositpaid":true,"bookingdates":{"checkin":"2018-01-01","checkout":"2019-01-01"},"additionalneeds":"Breakfast"}}
YYYY-MM-DD 11:59:07 [info] Created booking id: 4284
✓ 1 [chromium] › src/tests/apiTests/01_restfulbooker_raw/02_post_operation.spec.ts:10:5 › TC#1 @p0 - POST : Verify that create booking is working fine. (679ms)
1 passed (994ms)
It will be super fast, the instructor said, and in the logs you can see everything. Here it took 679ms.
[object Object]. The first run of those logger lines printed the objects directly, and the class got this. It is rebuilt here from the recording, by putting the objects straight into the three lines, and run for these notes:
logger.info(`Payload is set: ${payload}`);
logger.info(`Header is set: ${headers}`);
logger.info(`Response is set: ${data}`);
YYYY-MM-DD 11:55:29 [info] Payload is set: [object Object]
YYYY-MM-DD 11:55:29 [info] Header is set: [object Object]
YYYY-MM-DD 11:55:30 [info] Response is set: [object Object]
YYYY-MM-DD 11:55:30 [info] Created booking id: 2370
The payload is an object, so to print its values it has to become a string. Either convert it to a string or stringify it, the instructor said, and asked the AI, which used JSON.stringify on all three lines. Those are the pushed lines 29, 36 and 46. A plain .toString() would not have helped, as a run for these notes shows:
const payload = { firstname: 'Jim', lastname: 'Brown' };
console.log(`${payload}`);
console.log(payload.toString());
console.log(JSON.stringify(payload));
[object Object]
[object Object]
{"firstname":"Jim","lastname":"Brown"}
Why a logger at all? A logger is created to log the data, the instructor said: it keeps printing, so we know what is going on.
Postman or Playwright, and other questions
What is the difference between Postman and Playwright? Postman is generally more helpful for manual API testing, the instructor said. It can do automation too, but limited. Playwright helps you automate the APIs, and the advantage is that you can mix UI and API.
We can automate through Postman, right? You can, but Postman automation is not scalable. When you have something like 6000 or 5000 test cases, Postman will be very, very difficult to maintain.
Can it support REST and SOAP? It supports REST, and the class works on REST only. SOAP can also be done, but there is another way.
Do we convert XML to JSON? If it is required, yes.
How do we validate a response that has extra fields? That will also be discussed.
In a real project, do we combine API and UI test cases? Yes, the instructor said. In a real-time project we will combine them.
The workers. Asked about 8 workers: it is a default value, the instructor said. In the config, workers is undefined outside CI, and Playwright's own types give the default as half of the machine's logical CPU cores, so your number can differ.
All of this in one spec? Asked what happens to the auth, the headers and everything else sitting in a single spec: wait and watch, the instructor said. This is not optimized code, and optimized code will come with the fixture.
The extra things in the report. You will also notice AI Data, AI Verdict, flakiness and self-healing, the instructor said. These are AI agents the class will create in the upcoming classes.
A new request context
Suppose you have written around 5000 test cases around restful-booker, and suddenly you have to test Go REST. Do you change everything: the base URL, the API URL? No. You can write a test case normally and create a different context. This time you do not use the request fixture:
import { test, expect, request } from '@playwright/test';
test('newContext for isolated headers', async () => {
const ctx = await request.newContext({
baseURL: 'https://gorest.in',
extraHTTPHeaders: { 'X-Trace-Id': 'demo-123' },
timeout: 15_000,
});
const ping = await ctx.get('/public/v2/users/1001?page=1&per_page=10');
expect(ping.status()).toBe(200);
await ctx.dispose();
});
The context has everything of its own: its own base URL, its own extra HTTP headers, its own timeout. The test makes its GET request, verifies it, and disposes the context. Create a new context, run your test case, verify it, then dispose it: this is the beauty, the instructor said.
Run for these notes. This spec brings its own base URL, so the command sets none, and it prints nothing but the result:
npx playwright test src/tests/apiTests/01_restfulbooker_raw/03_newContext_api.spec.ts --reporter=list
Running 1 test using 1 worker
✓ 1 [chromium] › src/tests/apiTests/01_restfulbooker_raw/03_newContext_api.spec.ts:3:5 › newContext for isolated headers (893ms)
1 passed (1.2s)
Several projects in one framework. Asked how to combine multiple products in one framework: very simple, the instructor said. You just change the base URLs, and the test cases are always separate anyway. You can also use a different context for them.
Sixteen API projects in one repo. Asked whether a company's 16 different API projects in the same repo can be combined: absolutely yes. Create a configuration file containing all the different URLs, and whichever test case you want to run, change the base URL. Base URL 1 with run 1, base URL 2 with run 2: the commands change, that's it.
PUT: a token, a booking, then the update
A PUT is a little difficult, the instructor said. You cannot make one directly: if you do, it gives you forbidden. First create a token, which is a POST. Then create a booking, also a POST, to /booking, which gives you a booking ID. Only by using this token and this booking ID can you make the PUT. In Postman it is the same: create the booking and put its ID in the variable, create the token and put it in the variable, and only when both values are there will update booking work.
import { test, expect } from '@playwright/test';
import { logger } from '@utils/logger';
test.describe('Restful Booker booking API', () => {
// PUT
// Create Token -> Post Auth -> token
// Create Booking -> Post Booking -> bookng ID
// by using this token and bookingid
// we can make the put request.
test('TC#2 @p0 - PUT : Verify that update the existing booking is working fine.', async ({ request }) => {
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
const payload = {
firstname: 'Pramod',
lastname: 'Dutta',
totalprice: 111,
depositpaid: true,
bookingdates: {
checkin: '2018-01-01',
checkout: '2019-01-01',
},
additionalneeds: 'Breakfast',
};
let token = '';
let bookingId = 0;
await test.step('Step 1 - Create auth token', async () => {
const responseData = await request.post('/auth', {
headers,
data: {
username: '<username>',
password: '<password>',
},
});
expect(responseData.status()).toBe(200);
const data = await responseData.json();
token = data.token;
expect(token).toBeTruthy();
logger.info('Created auth token for PUT booking flow');
});
await test.step('Step 2 - Create booking to update', async () => {
const responseData = await request.post('/booking', {
headers,
data: payload,
});
expect(responseData.status()).toBe(200);
const data = await responseData.json();
bookingId = data.bookingid;
expect(bookingId).toBeTruthy();
logger.info(`Created booking id for update: ${bookingId}`);
});
await test.step('Step 3 - Update booking', async () => {
const responseData = await request.put(`/booking/${bookingId}`, {
headers: {
...headers,
Cookie:`token=${token}`
},
data: payload,
});
const data = await responseData.json();
expect(data.firstname).toBe(payload.firstname);
expect(data.lastname).toBe(payload.lastname);
logger.info(`Updated booking id ${bookingId}: ${data.firstname} ${data.lastname}`);
});
});
});
The login is masked. Lines 40 and 41 hold restful-booker's login in the class's code. They are shown here as <username> and <password>, and the login details are in the task post. Every other byte is the repo's.
Steps. test.describe is there because the file can hold more test cases. Inside the test, each part is a test.step: create a token, create a booking, update the booking. This is a more modular way, the instructor said, so that a proper report comes out: it is about reporting. It is why the instructor loves steps: it is very easy to debug.
Step 1. Yes, the username and password are hard-coded here, and it is a bad practice for now. They will be replaced later, with something like a process.env value, when this moves to the API helper and the fixture. The step stores the token.
Step 2 is the same create booking as before, and it stores the booking ID.
Step 3. The update is the same shape, with changes. It is request.put. The URL ends in the booking ID, and backticks are how you inject a variable. And there is one more change: ...headers, the spread operator, gives all the headers, plus one more, Cookie, set to token= and the token. So all three go. The payload does not change.
What the class saw. The instructor read the report step by step: create token, token is created; create booking, booking is created; update booking, booking is updated to Pramod.
Not run for these notes. Step 1 sends restful-booker's login to POST /auth, and these notes never send a login to any site. What is above is what the class saw.
Why the token is not printed. Line 49 logs a sentence, not the token. Because of security, the instructor said: never, never, never print your tokens in your HTML reports.
Two more answers from this part. Asked why the step's async function does not declare the fixture: steps do not need your fixtures, and request is already there in the test. Asked whether several PUT endpoints can use the same token: yes, but a token also expires after 1 hour, so make sure your test case runs within 1 hour.
Where this is going. Everything common, like the token and the booking ID, will move to the fixture, the instructor said. In the end a fixture gives you a token and a booking ID automatically, before your test case starts, and you can run unlimited test cases. We will come to fixtures probably next class.
Two gaps in Step 3, as pushed. It sends the same payload that Step 2 created, so data.firstname would be Pramod even if the PUT changed nothing. And it never checks the status, so a rejected PUT fails at .json(), with a message about JSON.
Here is that failure, run for these notes on the class's Steps 2 and 3 with the Cookie header left out, so no token and no login are sent:
const responseData = await request.put(`/booking/${bookingId}`, {
headers,
data: payload,
});
const data = await responseData.json();
SyntaxError: Unexpected token 'F', "Forbidden" is not valid JSON
The fix is to change a field and check the status first:
await test.step('Step 3 - Update booking', async () => {
const updatedPayload = { ...payload, firstname: 'Jim' };
const responseData = await request.put(`/booking/${bookingId}`, {
headers: {
...headers,
Cookie:`token=${token}`
},
data: updatedPayload,
});
expect(responseData.status()).toBe(200);
const data = await responseData.json();
expect(data.firstname).toBe(updatedPayload.firstname);
expect(data.lastname).toBe(updatedPayload.lastname);
logger.info(`Updated booking id ${bookingId}: ${data.firstname} ${data.lastname}`);
});
This fix was checked only on a stand-in server on the local machine, because the real flow needs the login. Against a stand-in whose PUT applied nothing, the class's Step 3 still passed and this one failed, which is the point.
Negative tests from AI, and the Ponytail review
A learner asked: how do you find a defect in the API, when the tests so far are positive scenarios? You can add negative test cases too, the instructor said, and when you have written the positive, the AI agent can add the negative by itself. So the agent was asked, and it wrote them.
Then the problem: the agent had created code we do not understand. That is why a Ponytail review is needed, the instructor said. We hate this extra stuff, and we want our test case to look like a normal one, like we used to write. So the instructor asked for a Ponytail review of the 06 file: it will start removing the extra stuff, the instructor said. After the review, the code was to be pushed, with a go command. This is the file as pushed:
import { test, expect } from '@playwright/test';
import { logger } from '@utils/logger';
test.describe('Restful Booker booking API - PUT negative scenarios', () => {
// PUT - Negative
// No token / invalid token -> 403
// Booking id does not exist -> 405 (this API returns 405 here, not 404)
// Required field missing -> 400
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
const payload = {
firstname: 'Pramod',
lastname: 'Dutta',
totalprice: 111,
depositpaid: true,
bookingdates: {
checkin: '2018-01-01',
checkout: '2019-01-01',
},
additionalneeds: 'Breakfast',
};
test('TC#3 @p1 - PUT : Verify that update without a token is rejected.', async ({ request }) => {
let bookingId = 0;
await test.step('Step 1 - Create booking to update', async () => {
const responseData = await request.post('/booking', {
headers,
data: payload,
});
expect(responseData.status()).toBe(200);
const data = await responseData.json();
bookingId = data.bookingid;
expect(bookingId).toBeTruthy();
logger.info(`Created booking id for update: ${bookingId}`);
});
await test.step('Step 2 - Update booking without a token', async () => {
const responseData = await request.put(`/booking/${bookingId}`, {
headers,
data: payload,
});
expect(responseData.status()).toBe(403);
});
});
test('TC#4 @p1 - PUT : Verify that update with an invalid token is rejected.', async ({ request }) => {
let bookingId = 0;
await test.step('Step 1 - Create booking to update', async () => {
const responseData = await request.post('/booking', {
headers,
data: payload,
});
expect(responseData.status()).toBe(200);
const data = await responseData.json();
bookingId = data.bookingid;
expect(bookingId).toBeTruthy();
logger.info(`Created booking id for update: ${bookingId}`);
});
await test.step('Step 2 - Update booking with an invalid token', async () => {
const responseData = await request.put(`/booking/${bookingId}`, {
headers: {
...headers,
Cookie: 'token=invalidToken123',
},
data: payload,
});
expect(responseData.status()).toBe(403);
});
});
test('TC#5 @p1 - PUT : Verify that update of a non-existent booking is rejected.', async ({ request }) => {
let token = '';
await test.step('Step 1 - Create auth token', async () => {
const responseData = await request.post('/auth', {
headers,
data: {
username: '<username>',
password: '<password>',
},
});
expect(responseData.status()).toBe(200);
const data = await responseData.json();
token = data.token;
expect(token).toBeTruthy();
logger.info('Created auth token for PUT booking flow');
});
await test.step('Step 2 - Update a booking id that does not exist', async () => {
const responseData = await request.put('/booking/99999999', {
headers: {
...headers,
Cookie: `token=${token}`,
},
data: payload,
});
expect(responseData.status()).toBe(405);
});
});
test('TC#6 @p1 - PUT : Verify that update with a missing required field is rejected.', async ({ request }) => {
let token = '';
let bookingId = 0;
await test.step('Step 1 - Create auth token', async () => {
const responseData = await request.post('/auth', {
headers,
data: {
username: '<username>',
password: '<password>',
},
});
expect(responseData.status()).toBe(200);
const data = await responseData.json();
token = data.token;
expect(token).toBeTruthy();
logger.info('Created auth token for PUT booking flow');
});
await test.step('Step 2 - Create booking to update', async () => {
const responseData = await request.post('/booking', {
headers,
data: payload,
});
expect(responseData.status()).toBe(200);
const data = await responseData.json();
bookingId = data.bookingid;
expect(bookingId).toBeTruthy();
logger.info(`Created booking id for update: ${bookingId}`);
});
await test.step('Step 3 - Update booking without firstname', async () => {
const responseData = await request.put(`/booking/${bookingId}`, {
headers: {
...headers,
Cookie: `token=${token}`,
},
data: {
lastname: 'Dutta',
totalprice: 111,
depositpaid: true,
bookingdates: {
checkin: '2018-01-01',
checkout: '2019-01-01',
},
additionalneeds: 'Breakfast',
},
});
expect(responseData.status()).toBe(400);
});
});
});
The login values on lines 97, 98, 132 and 133 are masked the same way. The recording does not mention a run of these four tests, and only two of them send no login:
| Test | What it sends | The spec expects | For these notes |
|---|---|---|---|
| TC#3 | no token | 403 | run: passed |
| TC#4 | the made-up invalidToken123 |
403 | run: passed |
| TC#5 | a real token, booking 99999999 | 405 | not run: it sends the login |
| TC#6 | a real token, no firstname |
400 | not run: it sends the login |
BASE_URL=https://restful-booker.herokuapp.com npx playwright test src/tests/apiTests/01_restfulbooker_raw/06_put_operation_negative_scenario.spec.ts -g "TC#3|TC#4" --workers=1 --reporter=list
Running 2 tests using 1 worker
YYYY-MM-DD 11:59:11 [info] Created booking id for update: 4325
✓ 1 [chromium] › src/tests/apiTests/01_restfulbooker_raw/06_put_operation_negative_scenario.spec.ts:29:9 › Restful Booker booking API - PUT negative scenarios › TC#3 @p1 - PUT : Verify that update without a token is rejected. (906ms)
YYYY-MM-DD 11:59:12 [info] Created booking id for update: 4334
✓ 2 [chromium] › src/tests/apiTests/01_restfulbooker_raw/06_put_operation_negative_scenario.spec.ts:57:9 › Restful Booker booking API - PUT negative scenarios › TC#4 @p1 - PUT : Verify that update with an invalid token is rejected. (912ms)
2 passed (2.1s)
The 405 is the spec's own comment, and the README adds that a PUT to a booking that does not exist returns 405, not 404, and that the specs assert what the API returns today.
Which requests need a token
Asked to explain the token that was used: for a GET or a POST there is no token, and the client is directly allowed to talk to the server. With a PUT there is an extra layer, which asks you for a token: first get a token, then send me a token again, then only I will allow you. PUT, PATCH and DELETE, for only this project, restful-booker, are marked red, the instructor said, and red means they need a token. You cannot update without one. For the rest, you can do whatever. restful-booker's API documentation agrees: it lists a Cookie header on exactly those three.
Different companies have different ways of doing it, the instructor said: in some, every request has a token. It depends on your company.
Asked how the token gets created: by a POST request. There is a request named auth, and it creates the token.
A normal token, a bearer token, a JWT. Asked whether "token" here means a bearer token or something different: this is a normal token as of now, the instructor said. A bearer token is also a type of token, one with no meaning, and a JWT is a token which has a meaning. (Bearer also names how a token is sent, Authorization: Bearer ..., as the diagram on the guide page shows, so a JWT can be sent as a bearer token too.)
Personas. Asked about different personas needing different tokens: wait for the fixture, the instructor said. You can inject anything there.
Checking the backend. Asked how to check whether the booking ID was created in the backend: if you have access to the database, you can check it. If a developer gives you access, open the database and verify.
Tasks and announcements
- Carried over from last class, if not done: the restful-booker test plan, test cases and Postman collection. This session starts from there, the instructor said. Asked about that task, whether code needs to be pushed: you can push it, but it is not required.
- Practise up to this point. Add tests for everything, for the GET requests too.
- Doubts go in the doubt thread, created for this. The instructor asked everyone to add them there and said they would be answered.
- Next class: CRUD operations using interfaces. The fixture, the instructor said, probably next class too. The deck's 5 November section holds one heading so far: CRUD with the request fixture.
- Later: the AI agents, in the upcoming classes.
- The code: the five specs are in the batch repo.
Task. If your restful-booker test plan, test cases and Postman collection are not ready, finish those first. Then practise up to this point: write these specs yourself and add tests for everything, for the GET requests too.
What is the difference between Postman and Playwright, and can you automate through Postman?
Worth practising too: How does an API test in Playwright differ from a UI test, and where does its base URL come from? You need to update a booking through restful-booker's API: why can't you just send the PUT, and what do you send? Your suite targets one API, and one test suddenly has to call another host: what do you use?
Show the answer
Answer. Postman is generally more helpful for manual API testing. It can automate too, but that is limited, and it is not scalable: at something like 5000 or 6000 test cases it is very difficult to maintain. Playwright automates the APIs, and you can mix UI and API. An API test takes the built-in request fixture instead of page: request uses APIRequestContext to make the HTTP call and give back the response, and no browser opens. The test gives only the last part of the path, and the base URL comes from outside, here BASE_URL on the command line, which reaches the test through the config. For the PUT: with no token, or an invalid one, the answer is 403 Forbidden, so first POST /auth for a token, then POST /booking for a booking ID, then PUT /booking/{id} with the ID in the URL and Cookie: token=... among the headers, in three test.step blocks. Never print the token in a report. For another host, use a new context, request.newContext(), imported from @playwright/test. It has its own base URL, extra HTTP headers and timeout. Verify the call, then dispose the context.