Master API testing with Playwright Javascript

Ploy Thanasornsawan
10 min readJan 18, 2025

--

First, let’s begin with a basic understanding of API testing. We’ll explore the essential methods used in API testing and then dive deeper into best practices for designing effective API test cases.

API Testing with HTTP Methods

  1. GET: Used to retrieve data.
    Example: Retrieve the book price, author name, and book category for the book with ID'1'.
  2. POST: Used to create a new resource.
    Example: Create a new book by providing details such as the book name and ID.
  3. PUT: Used to update all fields of an existing resource by completely replacing its data.
    Example: Update the book price, author name, and book category for the book with ID'1'.
  4. PATCH: Used to update specific fields of an existing resource without replacing the entire data.
    Example: Update only the book price for the book with ID'1'.
  5. DELETE: Used to delete a specific resource.
    Example: Delete all details of the book with ID'1'..

Now, the excited part!! How the Playwright Javscript makes a request.

Project setup:
Assuming you already have Node.js installed on your local environment, you can install Playwright with one command here.

npm init @playwright/test

How to make the API request in Playwright:

Using request with http method directly:

const { test, expect } = require('@playwright/test');

// Example with different HTTP methods
test('different HTTP methods', async ({ request }) => {
// GET request
const getResponse = await request.get(url, {
params: { id: 123 } // These become query parameters
});

// POST request
const postResponse = await request.post(url, {
data: { name: 'test' } // Request body
});

// PUT request
const putResponse = await request.put(url, {
data: { name: 'updated' }
});

// PATCH request
const patchResponse = await request.patch(url, {
data: { status: 'active' }
});

// DELETE request
const deleteResponse = await request.delete(url);

// HEAD request
const headResponse = await request.head(url);
});

Using newContext:

// Create a context with shared settings
const apiContext = await request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
'Authorization': 'Bearer token123',
'Accept': 'application/json'
}
});

// Use the context for multiple requests
const usersResponse = await apiContext.get('/users');
const postsResponse = await apiContext.get('/posts');

Key benefits of using newContext:

  1. Reusable Configuration:
// Set up once, use everywhere
const apiContext = await request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
'Authorization': 'Bearer token123',
'Accept': 'application/json'
},
timeout: 30000
});

// All these requests use the same configuration
await apiContext.get('/users');
await apiContext.post('/orders');
await apiContext.put('/profile');

2. Flexibility: when you need different configurations within the same test

test('test multiple APIs', async ({ playwright }) => {
// Context for API version 1
const v1Context = await playwright.request.newContext({
baseURL: 'https://api.v1.example.com',
extraHTTPHeaders: { 'API-Version': '1.0' }
});

// Context for API version 2
const v2Context = await playwright.request.newContext({
baseURL: 'https://api.v2.example.com',
extraHTTPHeaders: { 'API-Version': '2.0' }
});

// Use both contexts in the same test
const v1Response = await v1Context.get('/data');
const v2Response = await v2Context.get('/data');

// Clean up
await v1Context.dispose();
await v2Context.dispose();
});

3. Session Management:

test('detailed session handling example', async ({ request }) => {
const apiContext = await request.newContext();

// 1. Initial attempt to access protected route (should fail)
const initialResponse = await apiContext.get('/protected-data');
expect(initialResponse.status()).toBe(401); // Unauthorized

// 2. Make login request
const loginResponse = await apiContext.post('/login', {
data: {
username: 'testuser',
password: 'testpass'
}
});

// 3. Server typically sends back something like:
// Set-Cookie: sessionId=abc123; Path=/; HttpOnly
// apiContext captures this automatically

// You can inspect the cookies if needed
console.log('Response headers:', loginResponse.headers());

// 4. Next request automatically includes the cookie
const protectedResponse = await apiContext.get('/protected-data');
expect(protectedResponse.ok()).toBeTruthy(); // Now it works!

// 5. You can make multiple requests, all sharing the same session
const profileResponse = await apiContext.get('/profile');
const ordersResponse = await apiContext.get('/orders');
const settingsResponse = await apiContext.get('/settings');

// All these requests automatically included the session cookie
});

When to use direct request and newContext:

However, if you do not need to share sessions between test cases and prefer to isolate each test with a fresh session, I recommend using the direct request API with test.use() for global setup, such as configuring API headers. The test.use() method automatically handles cleanup, ensures test isolation by default, and makes tests more maintainable through consistent configuration. However, when using request.newContext, it maintains the session state (e.g., cookies and localStorage) across tests until you explicitly call the dispose() function.

Example code with test.use():

test.describe('Testing with test.use', () => {
test.use({
extraHTTPHeaders: {
'Authorization': 'Bearer token'
}
});

test('first login', async ({ request }) => {
const loginResponse = await request.post('/api/login', {
data: { username: 'user1', password: 'pass1' }
});
// This creates a cookie session

const profileResponse = await request.get('/api/profile');
// This request uses the cookie from login
});

test('second login', async ({ request }) => {
// This test starts completely fresh!
// The cookies from the previous test are NOT carried over
const profileResponse = await request.get('/api/profile');
// This would fail because we don't have a session
});
});

Example code with newContext:

test.describe('Testing with request.newContext', () => {
let context;

test.beforeAll(async ({ request }) => {
context = await request.newContext({
extraHTTPHeaders: {
'Authorization': 'Bearer token'
}
});
});

test('first login', async () => {
const loginResponse = await context.post('/api/login', {
data: { username: 'user1', password: 'pass1' }
});
// This creates a cookie session

const profileResponse = await context.get('/api/profile');
// This request uses the cookie from login
});

test('second login', async () => {
// This test WILL HAVE access to cookies from the previous test!
const profileResponse = await context.get('/api/profile');
// This would succeed because the session is still active
});

test.afterAll(async () => {
await context.dispose(); // Clean up resources
});
});

Let’s see the complete structure of Playwright’s request options:

test('demonstrate all request options', async ({ request }) => {
const response = await request.post('https://api.example.com/endpoint', {
// 1. Headers
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123',
'Custom-Header': 'value'
},

// 2. Request body - can be sent as 'data'
data: {
name: 'Test User',
email: 'test@example.com'
},

// 3. Query parameters
params: {
page: 1,
limit: 10
},

// 4. Timeout settings
timeout: 30000, // 30 seconds

// 5. Form data (multipart/form-data)
multipart: {
file: {
name: 'test.txt',
mimeType: 'text/plain',
buffer: Buffer.from('content')
},
field: 'value'
},

// 6. Ignore HTTPS errors
ignoreHTTPSErrors: true,

// 7. Maximum redirects to follow
maxRedirects: 5,

// 8. Fail on non-200 status codes
failOnStatusCode: true,

// 9. Retry failed requests
maxRetries: 3
});
});

// Example with form submission
test('form submission', async ({ request }) => {
const response = await request.post(url, {
form: { // application/x-www-form-urlencoded
username: 'testuser',
password: 'testpass'
}
});
});

// Example with file upload
test('file upload', async ({ request }) => {
const response = await request.post(url, {
multipart: {
file: {
name: 'test.jpg',
mimeType: 'image/jpeg',
buffer: imageBuffer
},
description: 'Test image'
}
});
});

// Example with query parameters
test('query parameters', async ({ request }) => {
const response = await request.get(url, {
params: {
search: 'test',
page: 1,
limit: 10
// These will be converted to ?search=test&page=1&limit=10
}
});
});

// Example with error handling
test('error handling', async ({ request }) => {
const response = await request.post(url, {
data: { test: 'data' },
failOnStatusCode: false, // Don't throw on 4xx/5xx status codes
timeout: 5000, // 5 second timeout
ignoreHTTPSErrors: true // Ignore HTTPS certificate errors
});
});

// Example with complex headers
test('complex headers', async ({ request }) => {
const response = await request.get(url, {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer token123',
'X-Custom-Header': 'value',
'User-Agent': 'Playwright',
'Cookie': 'session=123'
}
});
});

Key things to know about Playwright’s request API:

Common options:

  • data: Request body (automatically stringified for JSON)
  • headers: Request headers
  • params: Query parameters
  • timeout: Request timeout in milliseconds
  • failOnStatusCode: Whether to throw on non-200 responses
  • ignoreHTTPSErrors: Whether to ignore HTTPS errors
  • form: For form submissions
  • multipart: For file uploads

Additionally, if you want to perform security testing on an API related to redirect endpoints, you can use the option followRedirects: false.

test('should redirect to login page for unauthenticated access', async () => {
// Create a new context without any default headers
const cleanContext = await request.newContext();

try {
// Make request with only the redirect header and ensure it's a fresh request
const response = await cleanContext.get(`${BASE_URL}/admin/inventory`, {
headers: {
'Accept': 'application/json',
'X-Handle-Redirect': 'true'
},
followRedirects: false,
maxRedirects: 0
});

const responseHeaders = await response.headers();
const responseStatus = response.status();

// Verify redirect
expect(responseStatus).toBe(302);
expect(responseHeaders['location']).toBe('/auth/login');

// Follow redirect manually
if (responseHeaders['location']) {
const loginResponse = await cleanContext.get(
`${BASE_URL}${responseHeaders['location']}`,
{
headers: { 'Accept': 'application/json' }
}
);

expect(loginResponse.status()).toBe(200);
const loginData = await loginResponse.json();
expect(loginData.message).toBe('Please login to continue');
expect(loginData.redirected).toBe(true);
}
} finally {
await cleanContext.dispose();
}
});

Without followRedirects: false, if the server returns a 302 redirect, Playwright will automatically follow the redirect to /auth/login and the response will be from the login page (status 200). We won’t be able to verify that a redirection occurred.

What’s a common check in the API response:

// Making requests using this context
const response = await apiContext.get('/users');

// Important response checks:

// 1. Status Code Check
expect(response.status()).toBe(200);

// 2. Content-Type Header Check
const headers = response.headers();
expect(headers['content-type']).toContain('application/json');
expect(headers['cache-control']).toBeDefined();

// 3. Response Structure Check
const data = await response.json();
expect(data).toHaveProperty('users');

// Schema validation
expect(body).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
createdAt: expect.any(String)
});

// 4. Response Time Check (Performance)
const timing = await response.timing();
expect(timing.responseEnd).toBeLessThan(2000); // 2 seconds

//5. Error Handling
const errorResponse = await request.get('https://api.example.com/non-existent');
if (!errorResponse.ok()) {
const errorBody = await errorResponse.json();
expect(errorBody).toHaveProperty('error');
expect(errorBody.error).toHaveProperty('message');
}

To make API requests in Playwright, it’s important to have a good understanding of JavaScript concepts like async/await, Promise.all(), Promise.race(), Promise.allSettled(), and promise chaining. Below is an example of how to use each type in API requests:

  1. Promise.all() - Use when you need to make multiple requests in parallel and ALL must succeed:
test('Promise.all example', async ({ request }) => {
// Use when you need all requests to succeed
try {
const [usersResponse, postsResponse, commentsResponse] = await Promise.all([
request.get('https://api.example.com/users'),
request.get('https://api.example.com/posts'),
request.get('https://api.example.com/comments')
]);

// If ANY request fails, the entire Promise.all fails
const users = await usersResponse.json();
const posts = await postsResponse.json();
const comments = await commentsResponse.json();

// Now you can work with all the data
expect(users.length).toBeGreaterThan(0);
expect(posts.length).toBeGreaterThan(0);
expect(comments.length).toBeGreaterThan(0);
} catch (error) {
console.error('One of the requests failed:', error);
}
});

2. Promise.race() - Use when you want the result from whichever request finishes first:

test('Promise.race example', async ({ request }) => {
// Useful for testing failover scenarios or implementing timeouts
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), 5000)
);

try {
// Race between actual request and timeout
const response = await Promise.race([
request.get('https://api.example.com/data'),
timeoutPromise
]);

const data = await response.json();
expect(data).toBeDefined();
} catch (error) {
console.error('Request timed out or failed:', error);
}
});

3. Promise.allSettled() - Use when you want to know the outcome of all requests, regardless of success or failure:

When you make normal requests, if any request fails, it throws an error immediately.

test('normal requests vs Promise.allSettled', async ({ request }) => {
// NORMAL REQUEST APPROACH
try {
// If any of these fail, code stops executing at that point
const response1 = await request.post('https://api.example.com/orders', {
data: { id: '001', item: 'Valid Item' }
});

const response2 = await request.post('https://api.example.com/orders', {
data: { id: '002', item: '' } // Invalid - empty item
});

const response3 = await request.post('https://api.example.com/orders', {
data: { id: '003', item: 'Another Valid Item' }
});

// This code won't run if response2 fails
console.log('All orders created');
} catch (error) {
console.log('An error occurred, stopping all operations');
}

// PROMISE.ALLSETTLED APPROACH
const orderRequests = [
{ id: '001', item: 'Valid Item' },
{ id: '002', item: '' }, // Invalid - empty item
{ id: '003', item: 'Another Valid Item' }
];

const results = await Promise.allSettled(
orderRequests.map(order =>
request.post('https://api.example.com/orders', {
data: order
})
)
);

// This code runs regardless of any failures
// You can see which orders succeeded and which failed
const summary = {
successful: [],
failed: []
};

for (let i = 0; i < results.length; i++) {
const result = results[i];
const order = orderRequests[i];

if (result.status === 'fulfilled' && result.value.ok()) {
summary.successful.push({
id: order.id,
response: await result.value.json()
});
} else {
summary.failed.push({
id: order.id,
error: result.status === 'rejected' ?
result.reason :
await result.value.json()
});
}
}

console.log('Summary:', summary);
// This might show:
// Summary: {
// successful: [
// { id: '001', response: {...} },
// { id: '003', response: {...} }
// ],
// failed: [
// { id: '002', error: 'Item cannot be empty' }
// ]
// }

// You can still work with the successful orders
expect(summary.successful.length).toBe(2);
expect(summary.failed.length).toBe(1);
});

Key differences:

4. Promise chaining — Use when operations need to happen in sequence and depend on previous results:

test('Promise chaining example', async ({ request }) => {
// First approach - using .then()
request.post('https://api.example.com/users', {
data: { name: 'John' }
})
.then(response => response.json())
.then(user => request.post(`https://api.example.com/users/${user.id}/orders`, {
data: { product: 'item1' }
}))
.then(response => response.json())
.then(order => {
expect(order.status).toBe('created');
})
.catch(error => console.error('Error:', error));

// Second approach - using async/await (more readable)
try {
const userResponse = await request.post('https://api.example.com/users', {
data: { name: 'John' }
});
const user = await userResponse.json();

const orderResponse = await request.post(`https://api.example.com/users/${user.id}/orders`, {
data: { product: 'item1' }
});
const order = await orderResponse.json();

expect(order.status).toBe('created');
} catch (error) {
console.error('Error:', error);
}
});

Thanks for reading, and I hope you found this article helpful.

If you wish to know more about best practices in API testing with Playwright,. I recommend you read this blog.

--

--

Ploy Thanasornsawan
Ploy Thanasornsawan

Written by Ploy Thanasornsawan

Sharing knowledge about security and automation techniques.

No responses yet