Sitemap

Playwright API JavaScript with GraphQL 🚀

10 min readJan 10, 2025

Playwright is more than a browser automation tool—it's also perfect for API testing. This blog focuses on how you can use it to handle GraphQL requests with ease.

Press enter or click to view image in full size

Initially, we wanted to perform all activities like creating, updating, deleting, and filtering data. However, public GraphQL APIs may not support everything we need. So, I decided to set up a local server using Apollo Server because it’s easy to set up with JavaScript and beginner-friendly.

Install dependencies for local server

npm init -y
npm install @playwright/test @apollo/server graphql express
npm install cors body-parser
npm install -D @playwright/test

The GraphQL API has a single endpoint for all requests, using the POST method. In this case, the endpoint is http://localhost:4000/graphql. There are two types of operations when making requests:

  • Query: Used for reading data, such as filtering, searching, or retrieving all data.
  • Mutation: Used for creating, updating, or deleting data.

Here is our server.js file:

const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const express = require('express');
const cors = require('cors');
const http = require('http');
const { json } = require('body-parser');
const { authMiddleware } = require('./middleware/authMiddleware');

// Import our components
const Book = require('./models/Book');
const BookService = require('./services/BookService');
const { typeDefs } = require('./schema/typeDefs');
const { createBookResolvers } = require('./resolvers/bookResolvers');

async function startServer() {
const app = express();
const httpServer = http.createServer(app);

const bookService = new BookService();
const resolvers = createBookResolvers(bookService);

// Create Apollo Server
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
// Pass API key details to resolvers
return {
apiKeyDetails: req.apiKeyDetails
};
}
});

await server.start();

// Apply middleware
app.use(
'/graphql',
cors(),
json(),
authMiddleware, // Add authentication middleware
expressMiddleware(server)
);

await new Promise((resolve) => httpServer.listen({ port: 4000 }, resolve));
console.log(`🚀 Server ready at http://localhost:4000/graphql`);
}

startServer();

The file structure for setup API server:

project-root/
├── src/
│ ├── middleware/
│ └── authMiddleware.js # control all operation to use API key
│ ├── models/
│ └── Book.js # Book entity
│ ├── resolvers/
│ └── bookResolvers.js # GraphQL resolvers
│ ├── schema/
│ └── typeDefs.js # GraphQL schema definitions
│ ├── services/
│ └── BookService.js # Business logic
│ ├── server.js

Command to start server:

node src/server.js

In this example, we’re using a book scenario where the user needs to pass an API key to perform any request to the server. For creating or updating book data, we can use memory state instead of setting up a database for quick testing.

What is memory state?
In this case, in BookService.js, we declare a constructor with an empty array. Whenever the user wants to create or update a book, the data is simply pushed into this array.

Press enter or click to view image in full size
BookService.js

The bookResolvers.js file acts as a middleman, handling all activities such as query and mutation requests and calling the BookService functions for these operations.

Press enter or click to view image in full size
bookResolvers.js

The authMiddleware.js sets up the API key, which in this case has two types: one for a normal user who can only read data, and one for an admin who has full access. The API key is expected in the request header at req.headers['x-api-key'], meaning the request must include this header to be valid.

Press enter or click to view image in full size
authMiddleware.js

An example of where this middleware is used can be seen in the following URL setup:

app.use(
'/graphql',
cors(),
json(),
authMiddleware, // Add authentication middleware
expressMiddleware(server)
);

How to make a query with GraphQL?

Here’s an example payload for updating book data. As you can see, it includes the operation name “UpdateExistingBook,” uses a mutation for the update operation, and has two variables: id and input

# updateBook.js
# Operation name: "UpdateExistingBook"
# Variables:
# - $bookId: required ID
# - $updateData: required BookUpdateInput type
mutation UpdateExistingBook($bookId: ID!, $updateData: BookUpdateInput!) {
# Actual mutation name: "updateBook"
updateBook(
# Field arguments
id: $bookId, # maps $bookId to id
input: $updateData # maps $updateData to input
) {
# Selection set - what fields you want returned
id
title
# ... other fields
}
}

# Example usage:
const variables = {
bookId: "123",
updateData: {
title: "New Title", // Can update single field
pricing: { // Or nested objects
retailPrice: 29.99,
discount: 0.1
}
}
};

Here’s an example usage for fetching book details. As you can see, it includes the operation name “GetBookDetails,” uses a query to retrieve book data, and has three variables: bookId, ratingLimit, and includePublisher.

# getBook.js
const GET_BOOK = `
query GetBookDetails(
$bookId: ID!, # Required parameters
$ratingLimit: Int = 5, # Optional with default value
$includePublisher: Boolean = true # Controls conditional field inclusion
) {
book(id: $bookId) {
id
title
author
genre
publishedYear
tags
ratings(limit: $ratingLimit) { # Using parameter in field argument
userId
score
review
dateRated
}
... @include(if: $includePublisher) { # Conditional field inclusion
publisher {
id
name
country
}
}
}
}
`;

# Example usage:
// Example usage:
const variables = {
bookId: "123", // Required ID for the book
ratingLimit: 10, // Optional: Limits the number of ratings returned
includePublisher: false // Optional: Determines whether to include publisher information
};

How to make an API request with Playwright?

In Playwright, the request object comes from the test fixtures provided by Playwright's test runner. It's different from the page object used for web testing.

Here’s a breakdown:

// Web UI testing uses 'page'
test('web test', async ({ page }) => {
await page.goto('http://example.com');
});

// API testing uses 'request'
test('api test', async ({ request }) => {
const response = await request.post('http://api.example.com');
});

The request fixture:

  • It’s a built-in Playwright API testing client
  • Provides methods like get(), post(), put()⁣,delete()
  • Handles HTTP requests independently of browser context
  • Automatically manages things like keeping connections alive and following redirects

Usage in bookPage.jsfor GraphQL:

class BookPage {
constructor(request) {
// This is Playwright's APIRequestContext
this.request = request;
this.endpoint = 'http://localhost:4000/graphql';
}

async sendQuery(query, variables = {}) {
// Using the request object for API calls
const response = await this.request.post(this.endpoint, {
headers: {
'Content-Type': 'application/json'
},
data: {
query,
variables
}
});
}
}

// In test file
test.beforeEach(({ request }) => {
// Pass Playwright's request fixture to our BookPage
bookAPI = new BookPage(request);
});

Example of CRUD operation test case with GraphQL:

const { test, expect } = require('@playwright/test');
const BookPage = require('../../api/bookPage');
const bookPayload = require('../../data/api/book_payload.json');

test.describe('Book API Tests with Authentication', () => {
let bookAPI;

test.beforeEach(({ request }) => {
bookAPI = new BookPage(request);
});

test('complete CRUD cycle with valid admin API key', async () => {
test.setTimeout(30000);
bookAPI.setApiKey('test-api-key-123');

// Create book
console.log('Creating book...');
const createPayload = {
bookInput: {
title: "Test Book",
author: "Test Author",
genre: "FICTION",
publishedYear: 2023,
tags: ["test"],
isAvailable: true,
publisher: {
id: "pub123",
name: "Test Publisher",
country: "USA"
},
metadata: {
isbn: "123-456",
edition: "First",
language: "English",
format: "Hardcover",
pageCount: 200
},
pricing: {
retailPrice: 29.99,
discount: 0.1,
currency: "USD"
},
ratings: [{
userId: "user1",
score: 4.5,
review: "Good",
dateRated: "2024-01-01"
}]
}
};

const createResponse = await bookAPI.createBook(createPayload);
console.log('Book created:', createResponse.id);
expect(createResponse.title).toBe(createPayload.bookInput.title);

const bookId = createResponse.id;

// Get book
console.log('Getting book...');
const getResponse = await bookAPI.getBook({
bookId,
includeRatings: true,
includePublisher: true,
ratingLimit: 5,
includePricing: true
});
expect(getResponse.title).toBe(createPayload.bookInput.title);

// Update book
console.log('Updating book...');
const updateResponse = await bookAPI.updateBook({
bookId,
updateData: {
title: "Updated Test Book"
}
});
expect(updateResponse.title).toBe("Updated Test Book");

// Delete book
console.log('Deleting book...');
const deleteResponse = await bookAPI.deleteBook({
id: bookId,
softDelete: true,
reason: "Test cleanup"
});
expect(deleteResponse.success).toBe(true);

// Verify deletion
console.log('Verifying deletion...');
const verifyResponse = await bookAPI.getBook({
bookId,
includeRatings: false,
includePublisher: false
});
expect(verifyResponse).toBeNull();
console.log('Test completed successfully');
});
});

Want more best practices?

/**
* @description Complete CRUD cycle tests
* @requirements
* - Must handle create, read, update, delete operations
* - Must validate data at each step
* - Must verify response format and content
* - Must complete within performance requirements
*/
test('complete CRUD cycle', async () => {
let bookId;
bookAPI.setApiKey('test-api-key-123');

await test.step('create book', async () => {
const startTime = Date.now();
const createResponse = await bookAPI.createBook({
bookInput: bookPayload.fiction.bookInput
});

// Performance validation
expect(Date.now() - startTime).toBeLessThan(5000);

// Response structure validation
expect(createResponse).toMatchObject({
id: expect.any(String),
title: bookPayload.fiction.bookInput.title,
author: bookPayload.fiction.bookInput.author,
genre: bookPayload.fiction.bookInput.genre,
publishedYear: expect.any(Number),
isAvailable: expect.any(Boolean),
ratings: expect.any(Array),
publisher: expect.objectContaining({
id: expect.any(String),
name: expect.any(String),
country: expect.any(String)
}),
metadata: expect.objectContaining({
isbn: expect.any(String),
edition: expect.any(String),
pageCount: expect.any(Number)
})
});

bookId = createResponse.id;
});

await test.step('read book', async () => {
const startTime = Date.now();
const getResponse = await bookAPI.getBook({
bookId,
ratingLimit: 5
});

// Performance validation
expect(Date.now() - startTime).toBeLessThan(5000);

// Data validation
expect(getResponse.id).toBe(bookId);
expect(getResponse.title).toBe(bookPayload.fiction.bookInput.title);

// Type validation
expect(typeof getResponse.averageRating).toBe('number');
expect(Array.isArray(getResponse.ratings)).toBe(true);
});

await test.step('update book', async () => {
const startTime = Date.now();
const updateTitle = "Updated Test Book";
const updateResponse = await bookAPI.updateBook({
bookId,
updateData: {
title: updateTitle
}
});

// Performance validation
expect(Date.now() - startTime).toBeLessThan(5000);

// Verify update
expect(updateResponse.title).toBe(updateTitle);
// Verify other fields remain unchanged
expect(updateResponse.author).toBe(bookPayload.fiction.bookInput.author);
expect(updateResponse.ratings).toEqual(expect.any(Array));
});

await test.step('delete book', async () => {
const startTime = Date.now();
const deleteResponse = await bookAPI.deleteBook({
id: bookId,
softDelete: true,
reason: "Test cleanup"
});

// Performance validation
expect(Date.now() - startTime).toBeLessThan(5000);

// Verify deletion
expect(deleteResponse.success).toBe(true);
expect(deleteResponse.deletedBookId).toBe(bookId);

// Verify book no longer exists
const verifyResponse = await bookAPI.getBook({ bookId });
expect(verifyResponse).toBeNull();
});
});

What’s the Difference Between the Original Version and Best Practice?

The original version uses multiple operations in a single test case, making it difficult to debug where the test fails. Unless you run Playwright in debug mode, it can be challenging to identify which step caused the failure during execution.

To run Playwright in debug mode, use this command:

DEBUG=pw:api npx playwright test tests/api/book.spec.js --grep "complete CRUD cycle" --reporter=list

On the other hand, the best practice leverages test.step to separate each operation into distinct sections for create, read, update, and delete. This approach makes the test more readable and easier to debug, as it clearly defines where an error occurs in the CRUD cycle.

What Should We Expect in API Testing?

You can use expect to validate various aspects of API responses in Playwright. Here's a list of what you can test and examples:

  1. Status Code:
  • Ensure the API returns the correct HTTP status.
expect(response.status()).toBe(200);

2. Response Body:

  • Validate the response body for specific keys or values.
const body = await response.json();
expect(body.success).toBe(true);
expect(body.data).toHaveProperty('id');

3. Content Type:

  • Ensure the response has the correct content type, such as application/json.
expect(response.headers()['content-type']).toContain('application/json');

4. Response Headers:

  • Validate specific headers, such as Authorization, Cache-Control, or Content-Type.
expect(response.headers()).toHaveProperty('authorization');

5. Response Time:

  • Measure and validate response time.
const start = Date.now();
await response;
const responseTime = Date.now() - start;
expect(responseTime).toBeLessThan(500);

6. Complex Assertions (e.g., Arrays):

  • Validate arrays in the response.
const data = await response.json();
expect(data.orders).toBeInstanceOf(Array);
expect(data.orders.length).toBeGreaterThan(0);

You can see more examples about Playwright assertions on their website:

Press enter or click to view image in full size
https://playwright.dev/docs/test-assertions

From the picture, I highlighted two assertions that should be used carefully because they are not recommended as best practices.

  1. expect(value).toBeDefined()

toBeDefined() is not a good practice as it only checks if a value is not undefined. Let's look at better assertion practices:

// ❌ Bad Practices
test('filtering books', async () => {
const response = await bookAPI.filterBooks(filterCriteria);
expect(response.items).toBeDefined(); // Too weak
expect(response.success).toBeTruthy(); // Not specific enough
});

// ✅ Good Practices
test('filtering books', async () => {
const response = await bookAPI.filterBooks(filterCriteria);

// Check response structure
expect(Array.isArray(response.items)).toBe(true);
expect(response.items.length).toBeGreaterThan(0);

// Verify each item has correct structure
response.items.forEach(book => {
expect(book).toEqual(
expect.objectContaining({
id: expect.any(String),
title: expect.any(String),
author: expect.any(String),
genre: expect.stringMatching(/^(FICTION|NON_FICTION|MYSTERY)$/),
publishedYear: expect.any(Number)
})
);
});

// Check specific values
const firstBook = response.items[0];
expect(firstBook.publishedYear).toBeGreaterThanOrEqual(2020);
expect(firstBook.publishedYear).toBeLessThanOrEqual(2024);
});

Better Alternatives:

Instead, consider using more specific assertions that validate the actual value or behavior, such as:

  • expect(value).not.toBeNull() – Ensures the value is neither null nor undefined.
  • expect(value).toBeInstanceOf(SomeClass) – Checks if the value is an instance of a specific class.
  • expect(value).toEqual(expectedValue) – Validates the value against an expected one.

2. expect(value).toBeTruthy()

// ✅ Use toBe(true) for explicit boolean checks
expect(deleteResponse.success).toBe(true);

// ❌ Avoid toBeTruthy() when expecting specifically true
// toBeTruthy() would pass for 1, "yes", [], {}, etc.
expect(deleteResponse.success).toBeTruthy();

When handling errors in async functions, it’s recommended to use .rejects.toThrow instead of manually checking error.message.
Here’s why:

// Scenario 1: Unexpected error message
// Using try-catch
test('error message mismatch - try-catch', async () => {
try {
await bookAPI.createBook({
bookInput: {
...validBook,
title: 'a'.repeat(256)
}
});
fail('Expected an error but none was thrown');
} catch (error) {
// If server returns "Title too long" instead of expected message
// This test will fail with confusing error:
// Expected "Title must be less than 255 characters" but got "Title too long"
expect(error.message).toBe('Title must be less than 255 characters');
}
});

// Using rejects.toThrow
test('error message mismatch - rejects.toThrow', async () => {
// More clear error message:
// "Expected error matching 'Title must be less than 255 characters' but got 'Title too long'"
await expect(bookAPI.createBook({
bookInput: {
...validBook,
title: 'a'.repeat(256)
}
})).rejects.toThrow('Title must be less than 255 characters');
});

// Scenario 2: Unexpected successful response
// Using try-catch
test('unexpected success - try-catch', async () => {
try {
await bookAPI.createBook({
bookInput: {
...validBook,
title: 'a'.repeat(256)
}
});
// If we forget this line, test might pass incorrectly
fail('Expected an error but none was thrown');
} catch (error) {
expect(error.message).toBe('Title must be less than 255 characters');
}
});

// Using rejects.toThrow
test('unexpected success - rejects.toThrow', async () => {
// Will automatically fail with clear message:
// "Expected promise to reject but it resolved instead"
await expect(bookAPI.createBook({
bookInput: {
...validBook,
title: 'a'.repeat(256)
}
})).rejects.toThrow('Title must be less than 255 characters');
});

// Scenario 3: Different type of error occurs
// Using try-catch
test('wrong error - try-catch', async () => {
try {
await bookAPI.createBook({
bookInput: {
...validBook,
title: 'a'.repeat(256)
}
});
fail('Expected an error but none was thrown');
} catch (error) {
// If network error occurs instead of validation error
// Test might still pass if we only check message
expect(error.message).toBe('Title must be less than 255 characters');
}
});

// Using rejects.toThrow
test('wrong error - rejects.toThrow', async () => {
// Will fail with clear message showing the actual error was different:
// "Expected error matching 'Title must be less than 255 characters' but got 'Network error'"
await expect(bookAPI.createBook({
bookInput: {
...validBook,
title: 'a'.repeat(256)
}
})).rejects.toThrow('Title must be less than 255 characters');
});

You can see full code on my github repository here:

--

--

Ploy Thanasornsawan
Ploy Thanasornsawan

Written by Ploy Thanasornsawan

Sharing knowledge about security and automation techniques.

No responses yet