By Stanislav Taran

Controllers and Clients in API Tests


Preface

I have been working with API testing for a while now, and I have noticed that a lot of people don’t know about the concept of controllers and clients in API testing. Of course, I mean the JS/TS world, because people who work with Java or C# are probably familiar with these concepts since they are built on top of the OOP paradigm.

Some time ago, I was working on a project where we had to write a lot of API tests. It was my first time working as QA Automation Engineer and I really appreciate the experience I got there. Plenty of skills I got there I still use in my daily work.

In this post, I want to share with you the concept of controllers and clients in API testing. I hope you will find it useful.

We will use Playwright Test framework and its ApiRequestContext for this example, but the concept is more or less the same for any other testing framework and HTTP client because it’s more about the architecture and not about the tools.

To make this post more interesting, let’s imagine you’ve been hired by a company which owns a hotels network. Your task is to write API tests for the company’s booking system.

Often people use mobile or web applications when they want to book a hotel room, like Booking.com or Airbnb and so on. These kind of applications aggregate hotels from different providers (like your company), and they need to have a way to communicate with these providers’ APIs to:

  • synchronize data about available rooms, prices, so the application can show it to the user (needs to get availabilities data)
  • book a room
  • cancel a booking

There are might be more communication scenarios, but those are enough for our goal.

I am not going to cover the whole API testing strategy in this post, but I will focus on the concept of controllers and clients in API testing. So I can afford to simplify the API and the tests.

So, you have to write API tests for the company’s API, which is responsible for providing availabilities data, booking, and canceling rooms possibilities to the clients (booking applications).

Simple API

Single File API Tests

When you start writing API tests, you could write them in a single file. You create a test suite, write a test case, and make an HTTP request to the API endpoint right in the test function.

Let’s create a pseudo-code for a test case that books a room via the API:

import {test, expect} from '@playwright/test';

test.describe('Booking applications API', () => {
  test.describe('Create booking', () => {
    test('should book a room for 1 guest for 1 day (minimum allowed days)', async ({request}) => {
      let hotelId;
      const requestData = {
        roomId: null,
        checkInDate: '2024-06-20',
        checkInTime: '10:00',
        checkOutDate: '2024-06-21',
        checkOutTime: '18:00',
        guests: 1,
        customer: {
          name: 'John Doe',
          email: 'john@doe@test.com',
          phone: '1234567890'
        }
      };

      await test.step('Get hotels list', async () => {
        const response = await request.get('bookingapi/hotels', {
          headers: {
            'X-Api-Key': process.env.BOOKING_API_KEY
          }
        });
        expect(response.status()).toBe(200);
        const hotels = await response.json();
        hotelId = hotels[0].id;
      });

      await test.step('Get available rooms list for the specified date range', async () => {
        const response =  await request.get('bookingapi/available-rooms', {
          headers: {
            'X-Api-Key': process.env.BOOKING_API_KEY
          },
          params: {
            checkInDate: requestData.checkInDate,
            checkOutDate: requestData.checkOutDate,
            guests: requestData.guests,
            hotelId
          }
        });

        expect(response.status()).toBe(200);
        const availableRooms = await response.json();
        // assigning the first available room to the request data for booking
        const firstAvailableRoom = availableRooms.find(room => room.available);
        requestData.roomId = firstAvailableRoom.id;
      })

      await test.step('Book a room', async () => {
        const response = await request.post('bookingapi/book', {
          headers: {
            'X-Api-Key': process.env.BOOKING_API_KEY
          },
          data: requestData
        });
        expect(response.status()).toBe(200);
        expect(await response.json()).toEqual({
          bookingId: expect.any(String),
          state: 'BOOKED'
        })
      })
    });

    test('should book a room for 2 guests 7 days (maximum allowed days)', async ({request}) => {
      let hotelId;
      const requestData = {
        roomId: null,
        checkInDate: '2024-06-20',
        checkInTime: '10:00',
        checkOutDate: '2024-06-21',
        checkOutTime: '18:00',
        guests: 2,
        customer: {
          name: 'John Doe',
          email: 'john@doe@test.com',
          phone: '1234567890'
        }
      };

      await test.step('Get hotels list', async () => {
        const response = await request.get('bookingapi/hotels', {
          headers: {
            'X-Api-Key': process.env.BOOKING_API_KEY
          }
        });
        expect(response.status()).toBe(200);
        const hotels = await response.json();
        hotelId = hotels[0].id;
      });


      await test.step('Get available rooms list for the specified date range', async () => {
        const response =  await request.get('bookingapi/available-rooms', {
          headers: {
            'X-Api-Key': process.env.BOOKING_API_KEY
          },
          params: {
            checkInDate: requestData.checkInDate,
            checkOutDate: requestData.checkOutDate,
            guests: requestData.guests,
            hotelId
          }
        });

        expect(response.status()).toBe(200);
        const availableRooms = await response.json();
        // assigning the first available room to the request data for booking
        const firstAvailableRoom = availableRooms.find(room => room.available);
        requestData.roomId = firstAvailableRoom.id;
      })

      await test.step('Book a room', async () => {
        const response = await request.post('bookingapi/book', {
          headers: {
            'X-Api-Key': process.env.BOOKING_API_KEY
          },
          data: requestData
        });
        expect(response.status()).toBe(200);
        expect(await response.json()).toEqual({
          bookingId: expect.any(String),
          state: 'BOOKED'
        })
      })
    })
  });
});

And now let’s create a pseudo-code for a test case that cancels a booking via the API:

import {test, expect} from '@playwright/test';

test.describe('Booking applications API', () => {
  test.describe('Create booking', () => {
    let bookingId;

    test.beforeEach(async ({request}) => {
      let hotelId;
      const requestData = {
        roomId: null,
        checkInDate: '2024-06-20',
        checkInTime: '10:00',
        checkOutDate: '2024-06-21',
        checkOutTime: '18:00',
        guests: 2,
        customer: {
          name: 'John Doe',
          email: 'john@doe@test.com',
          phone: '1234567890'
        }
      };

      await test.step('Get hotels list', async () => {
        const response = await request.get('bookingapi/hotels', {
          headers: {
            'X-Api-Key': process.env.BOOKING_API_KEY
          }
        });
        expect(response.status()).toBe(200);
        const hotels = await response.json();
        hotelId = hotels[0].id;
      })

      await test.step('Get available rooms list for the specified date range', async () => {
        const response =  await request.get('bookingapi/available-rooms', {
          headers: {
            'X-Api-Key': process.env.BOOKING_API_KEY
          },
          params: {
            checkInDate: requestData.checkInDate,
            checkOutDate: requestData.checkOutDate,
            guests: requestData.guests,
            hotelId
          }
        });

        expect(response.status()).toBe(200);
        const availableRooms = await response.json();
        // assigning the first available room to the request data for booking
        const firstAvailableRoom = availableRooms.find(room => room.available);
        requestData.roomId = firstAvailableRoom.id;
      })

      await test.step('Book a room', async () => {
        const response = await request.post('bookingapi/book', {
          headers: {
            'X-Api-Key': process.env.BOOKING_API_KEY
          },
          data: requestData
        });
        expect(response.status()).toBe(200);
        bookingId = (await response.json()).bookingId;
      })
    })

    test('should cancel the booking', async ({request}) => {
      const response = await request.post(`bookingapi/cancel/${bookingId}`, {
        headers: {
          'X-Api-Key': process.env.BOOKING_API_KEY
        }
      });
      expect(response.status()).toBe(200);
      expect(await response.json()).toEqual({
        state: 'CANCELLED',
        message: 'Booking cancelled successfully'
      });
    })
  });
});

This approach could be acceptable for a small number of tests, but when you have a lot of tests, it becomes hard to maintain them.

Let’s see what problems we have with this approach:

  1. Code duplication. We have the same code for getting hotels list, available rooms, and booking a room in each test case. As you can see, we have to pass API key in the headers for each request. If the authorization header changes, we have to update it in each test case.
  2. Hard to maintain. If the API changes, we have to update all the tests. Trust me it’s not fun. You have to go through all the tests and update the code.
  3. Hard to read. The test case is hard to read because of the code duplication and those API endpoints. You can’t see the test case logic at a glance. You have to scroll up and down to understand what each action does. Of course Playwright steps help a lot, but still, it’s not perfect.
  4. Hard to scale. If we want to add more tests, we have to duplicate the same code again. Yeah, it’s not a big deal mate but look at point 2 again. Your pain will be doubled. Most of the projects I worked on had at minimum 200 API tests. And it’s not the limit. One project had more than 1000 API tests.

Think about it before your company will start thinking why do you spend so much time on updating the tests each time.

Don’t worry, I have a solution for you.

Controllers

Let’s visualize the API we just used in our tests:

Booking API

As you can see, we have 4 endpoints:

  • GET bookingapi/hotels
  • GET bookingapi/available-rooms
  • POST bookingapi/book
  • POST bookingapi/cancel/{bookingId}

Usually I tend to group API endpoints by the entity they are responsible for, but often you don’t need to think about it if you have proper documentation. In our case, we have 2 entities: hotels and bookings.

Now let’s talk about the concept of controllers. If you have ever written UI or E2E tests, you probably know what a Page Object is.

Controllers are similar to Page Objects, but for API tests. They are responsible for interacting with the API endpoints while hiding the implementation details from the test cases.

So since we alredy have 2 entities, we can create 2 controllers: HotelsController and BookingsController.

But I hope you my reader are already familiar with the concept of inheritance in OOP. So we can create a base controller, which will have the common logic for all controllers first.

But first, let’s create a PlaywrightController class, which will be responsible for making HTTP requests to the API.

export default class PlaywrightController {
  constructor(request,  options = {baseUrl:process.env.BASE_URL}) {
    this.options = options;
    this.request = request;
  }

  async get(path){
    return await this.req("GET", path);
  }

  async post(path) {
    return this.req("POST", path);
  }

  async put(path) {
    return this.req("PUT", path);
  }

  async patch(path) {
    return this.req("PATCH", path);
  }

  async delete(path) {
    return this.req("DELETE", path);
  }

  searchParams(queryParams) {
    this.options = {
      ...this.options,
      queryParams
    };
    return this;
  }

  body(data) {
    this.options = {
      ...this.options,
      body: data
    };
    return this;
  }

  headers(data) {
    this.options = {
      ...this.options,
      headers: {
        ...this.options.headers,
        ...data
      }
    };
    return this;
  }

  async req(method, path = '') {
    const cfg = {
      method,
      baseURL: this.options.baseUrl,
      params: this.options.queryParams,
      headers: {...this.options.headers},
      data: {}
    };

    const response = await this.request.fetch(path, {
      method: cfg.method,
      headers: cfg.headers,
      data: method === "GET"  ?  undefined  :  this.options.body,
      params: this.options.queryParams
    });

    this.options.body = undefined;
    this.options.queryParams = undefined;

    return response;
  }
}

At first glance, it might look a bit complicated, but you will see how it simplifies the controllers.

Now we are ready to create a BaseBookingAPIController:

import PlaywrightController from "../PlaywrightController.js";


export default class BaseBookingAPIController extends PlaywrightController {
  constructor(request) {
    super(request, {
      baseUrl: process.env.BASE_URL,
      headers: {
        "X-Api-Key": process.env.BOOKING_API_KEY,
      }
    });
  }
}

As you could notice we have added the X-Api-Key header to the base controller. In this way we can be sure that each request made by the controllers will have the X-Api-Key header and we don’t have to pass it each time we make a request.

These two controllers are core for our Hotel and Booking controllers.

Now let’s create a HotelsController:

import BaseBookingAPIController from "./BaseBookingAPIController.js";

export default class HotelsController extends BaseBookingAPIController {
  _GET_HOTELS_PATH = "bookingAPI/hotels";
  _GET_AVAILABLE_ROOMS_PATH = "bookingAPI/available-rooms";

  async getHotels() {
    return this.get(this._GET_HOTELS_PATH);
  }

  async getAvailableRooms(params) {
    return this.searchParams(params).get(this._GET_AVAILABLE_ROOMS_PATH)
  }
}

Because we described all the logic related to API calls in the PlaywrightController and BaseBookingAPIController, we can use the methods from these classes in the HotelsController. It allows us to write less code and make the code more readable.

Now a BookingsController:

import BaseBookingAPIController from "./BaseBookingAPIController.js";


export default class BookingController extends BaseBookingAPIController {
  _CREATE_BOOKING_PATH = "bookingAPI/book";
  _CANCEL_BOOKING_PATH = "bookingAPI/cancel/";

  async createBooking(data) {
    return this.body(data).post(this._CREATE_BOOKING_PATH);
  }

  async cancelBooking(bookingId) {
    return this.post(`${this._CANCEL_BOOKING_PATH}${bookingId}`);
  }
}

Wow, that’s it for now. Let’s see how we can use these controllers in our tests. We will refactor the tests we wrote before to use the controllers.

import {test, expect} from '@playwright/test';
import HotelsController from "../../src/controllers/bookingAPI/HotelsController.js";
import BookingController from "../../src/controllers/bookingAPI/BookingController.js";

test.describe('Booking applications API', () => {
  test.describe('Create booking', () => {
    let hotelsController;
    let bookingsController;

    test.beforeAll(async ({request}) => {
      hotelsController = new HotelsController(request);
      bookingsController = new BookingController(request);
    })

    test('should book a room for 1 guest for 1 day (minimum allowed days)', async () => {
      let hotelId;
      const requestData = {
        roomId: null,
        checkInDate: '2024-06-20',
        checkInTime: '10:00',
        checkOutDate: '2024-06-21',
        checkOutTime: '18:00',
        guests: 1,
        customer: {
          name: 'John Doe',
          email: 'john@doe@test.com',
          phone: '1234567890'
        }
      };

      await test.step('Get hotels list', async () => {
        const response = await hotelsController.getHotels();
        expect(response.status()).toBe(200);
        const hotels = await response.json();
        hotelId = hotels[0].id;
      });

      await test.step('Get available rooms list for the specified date range', async () => {
        const response =  await hotelsController.getAvailableRooms({
          checkInDate: requestData.checkInDate,
          checkOutDate: requestData.checkOutDate,
          guests: requestData.guests,
          hotelId
        });

        expect(response.status()).toBe(200);
        const availableRooms = await response.json();
        // assigning the first available room to the request data for booking
        const firstAvailableRoom = availableRooms.find(room => room.available);
        requestData.roomId = firstAvailableRoom.id;
      })

      await test.step('Book a room', async () => {
        const response = await bookingsController.createBooking(requestData);
        expect(response.status()).toBe(200);
        expect(await response.json()).toEqual({
          bookingId: expect.any(String),
          state: 'BOOKED'
        })
      })
    });

    test('should book a room for 2 guests 7 days (maximum allowed days)', async () => {
      let hotelId;
      const requestData = {
        roomId: null,
        checkInDate: '2024-06-20',
        checkInTime: '10:00',
        checkOutDate: '2024-06-21',
        checkOutTime: '18:00',
        guests: 2,
        customer: {
          name: 'John Doe',
          email: 'john@doe@test.com',
          phone: '1234567890'
        }
      };

      await test.step('Get hotels list', async () => {
        const response = await hotelsController.getHotels();
        expect(response.status()).toBe(200);
        const hotels = await response.json();
        hotelId = hotels[0].id;
      });


      await test.step('Get available rooms list for the specified date range', async () => {
        const response =  await hotelsController.getAvailableRooms({
          checkInDate: requestData.checkInDate,
          checkOutDate: requestData.checkOutDate,
          guests: requestData.guests,
          hotelId
        })

        expect(response.status()).toBe(200);
        const availableRooms = await response.json();
        // assigning the first available room to the request data for booking
        const firstAvailableRoom = availableRooms.find(room => room.available);
        requestData.roomId = firstAvailableRoom.id;
      })

      await test.step('Book a room', async () => {
        const response = await bookingsController.createBooking(requestData);
        expect(response.status()).toBe(200);
        expect(await response.json()).toEqual({
          bookingId: expect.any(String),
          state: 'BOOKED'
        })
      })
    })
  });
});

and second test case:

import {test, expect} from '@playwright/test';
import HotelsController from "../../src/controllers/bookingAPI/HotelsController.js";
import BookingController from "../../src/controllers/bookingAPI/BookingController.js";

test.describe('Booking applications API', () => {
  test.describe('Create booking', () => {
    let bookingId;
    let hotelsController;
    let bookingsController;

    test.beforeEach(async ({request}) => {
      let hotelId;

      hotelsController = new HotelsController(request);
      bookingsController = new BookingController(request);
      const requestData = {
        roomId: null,
        checkInDate: '2024-06-20',
        checkInTime: '10:00',
        checkOutDate: '2024-06-21',
        checkOutTime: '18:00',
        guests: 2,
        customer: {
          name: 'John Doe',
          email: 'john@doe@test.com',
          phone: '1234567890'
        }
      };

      await test.step('Get hotels list', async () => {
        const response = await hotelsController.getHotels();
        expect(response.status()).toBe(200);
        const hotels = await response.json();
        hotelId = hotels[0].id;
      })

      await test.step('Get available rooms list for the specified date range', async () => {
        const response =  await hotelsController.getAvailableRooms({
          checkInDate: requestData.checkInDate,
          checkOutDate: requestData.checkOutDate,
          guests: requestData.guests,
          hotelId
        })

        expect(response.status()).toBe(200);
        const availableRooms = await response.json();
        // assigning the first available room to the request data for booking
        const firstAvailableRoom = availableRooms.find(room => room.available);
        requestData.roomId = firstAvailableRoom.id;
      })

      await test.step('Book a room', async () => {
        const response = await bookingsController.createBooking(requestData);
        expect(response.status()).toBe(200);
        bookingId = (await response.json()).bookingId;
      })
    })

    test('should cancel the booking', async () => {
      const response = await bookingsController.cancelBooking(bookingId);
      expect(response.status()).toBe(200);
      expect(await response.json()).toEqual({
        state: 'CANCELLED',
        message: 'Booking cancelled successfully'
      });
    })
  });
});

Now let’s see what we have achieved:

  1. Code duplication. We have removed the code duplication. We have a single place where we make HTTP requests to the API. If the Authorization header changes, we have to update it in a single place.
  2. Hard to maintain. If the API endpoints change, we have to update the controllers. We don’t have to update the tests. We have a single place where we interact with the API.
  3. Hard to read. The test case is now more readable. You can see the test case logic at a glance since your controllers’ methods are named properly.
  4. Hard to scale. If we want to add more tests, we don’t have to duplicate the same code again. We just have to create a new test case and use the controllers.

But is there any challenges with this approach? Yes, there are some challenges:

  1. Your API might have a lot of entities and endpoints. You have to import all the controllers you need in the test file. In an addition you have to initialize them in the beforeAll or beforeEach hook. It might be a bit annoying and messy.

  2. Your company might have a lot of APIs. You have to create controllers for each API. And there is a high probability that you will have same functionality on different APIs. Each API more likely to work with a bit different DTOs. Let’s add a bit of complexity.

Previously we had a single API that should be used by abstract booking applications.

Simple API

But now we add few more:

Multiple APIs

So, let’s clarify a bit the channels shown on the image:

  • Booking API - the main API that should be used by some abstract booking applications
  • Hotel API for employees - the API that should be used by the company’s employees to manage bookings, guests profiles, manage hotels schedules, etc. It used when a gust just arrives to a hotel and wants to check-in, or when a guest wants to extend the stay, or when a guest wants to cancel the booking by phone, etc.
  • Google Maps API - the API that should be used to provide the availabilities of the hotels on the map and provide a possibility to make a booking from Google Maps(I am not really sure if it’s a real thing for hotels, but let’s imagine it is)

So let’s imagine we have to write a test case that confirms a hotel employees are able to cancel bookings made via the Booking API or Google Maps API.

For this case, we have to create more controllers for new APIs. Let’s do it.

Hotel API first:

import PlaywrightController from "../PlaywrightController.js";


export default class BaseHotelAPIController extends PlaywrightController {
  // I do not add any methods here for now but I can add some common methods in the future
}
import BaseHotelAPIController from "./BaseHotelAPIController.js";


export default class AuthController extends BaseHotelAPIController {
    _LOGIN_PATH = "hotelapi/login";
    _LOGOUT_PATH = "hotelapi/logout";

    async login(data) {
        return this.body(data).post(this._LOGIN_PATH);
    }

    async logout() {
        return this.post(this._LOGOUT_PATH);
    }
}
import BaseHotelAPIController from "./BaseHotelAPIController.js";


export default class BookingsController extends BaseHotelAPIController {
  _CREATE_BOOKING_PATH = "hotelapi/bookings";
  _GET_BOOKINGS_PATH = "hotelapi/bookings";
  _UPDATE_BOOKING_PATH = "hotelapi/bookings";
  _CANCEL_BOOKING_PATH = "hotelapi/bookings/";

  getBookings(params) {
    return this.searchParams(params).get(this._GET_BOOKINGS_PATH);
  }

  async createBooking(data) {
    return this.body(data).post(this._CREATE_BOOKING_PATH);
  }

  async updateBooking(bookingId, data) {
    return this.body(data).put(`${this._UPDATE_BOOKING_PATH}${bookingId}`);
  }

  async cancelBooking(bookingId) {
    return this.delete(`${this._CANCEL_BOOKING_PATH}${bookingId}`);
  }
}
import BaseHotelAPIController from "./BaseHotelAPIController.js";


export default class HotelsController extends BaseHotelAPIController {
  _GET_HOTELS_PATH = "hotelapi/hotels";
  _GET_AVAILABLE_ROOMS_PATH = "hotelapi/rooms/availability";

  // Returns a list of hotels an employee has access to
  async getHotels() {
    return this.get(this._GET_HOTELS_PATH);
  }

  async getAvailableRooms(params) {
    return this.searchParams(params).get(this._GET_AVAILABLE_ROOMS_PATH)
  }
}

And Google Maps API:

import PlaywrightController from "../PlaywrightController.js";


export default class BaseGoogleMapsAPIController extends PlaywrightController {
  constructor(request) {
    super(request, {
      baseUrl: process.env.baseURL,
      headers: {
        "X-Authorization": process.env.GOOGLE_MAPS_API_KEY,
      }
    });
  }
}
import BaseGoogleMapsAPIController from "./BaseGoogleMapsAPIController.js";


export default class HotelController extends BaseGoogleMapsAPIController {
  _HOTEL_AVAILABILITIES_PATH = "googlemapsapi/avalabilitiesLookup";

  async getHotelAvailabilities(data) {
    return this.body(data).post(this._HOTEL_AVAILABILITIES_PATH);
  }
}
import BaseGoogleMapsAPIController from "./BaseGoogleMapsAPIController.js";


export default class BookingsController extends BaseGoogleMapsAPIController {
  _BOOKINGS_PATH = "googlemapsapi/bookings";

  async createBooking(data) {
    return this.body(data).post(this._BOOKINGS_PATH);
  }
}

That is a lot, is not it? Let’s see how we can use these controllers in our tests.

import {test, expect} from '@playwright/test';
import BookingAPIHotelsController from "../../src/controllers/hotelAPI/HotelsController.js";
import BookingAPIBookingController from "../../src/controllers/bookingAPI/BookingController.js";
import GoogleMapsHotelController from "../../src/controllers/googleMapsAPI/HotelController.js";
import GoogleMapsBookingController from "../../src/controllers/googleMapsAPI/BookingsController.js";
import HotelAPIAuthController from "../../src/controllers/hotelAPI/AuthController.js";
import HotelAPIBookingController from "../../src/controllers/hotelAPI/BookingsController.js";


test.describe('Hotel/Employee API', () => {
  test.describe('Bookings', () => {
    let bookingAPIBookingId;
    let googleMapsAPIBookingId;

    let bookingAPIHotelsController;
    let bookingAPIBookingsController;
    let googleMapsHotelController;
    let googleMapsBookingController;

    let hotelAPIAuthController;
    let hotelAPIBookingController;

    let hotelId;

    test.beforeEach(async ({request}) => {

      hotelAPIAuthController = new HotelAPIAuthController(request);
      hotelAPIBookingController = new HotelAPIBookingController(request);

      bookingAPIHotelsController = new BookingAPIHotelsController(request);
      bookingAPIBookingsController = new BookingAPIBookingController(request);

      googleMapsHotelController = new GoogleMapsHotelController(request);
      googleMapsBookingController = new GoogleMapsBookingController(request);

      const bookingAPIRequestData = {
        roomId: null,
        checkInDate: '2024-06-20',
        checkInTime: '10:00',
        checkOutDate: '2024-06-21',
        checkOutTime: '18:00',
        guests: 2,
        customer: {
          name: 'John Doe',
          email: 'john@test.com',
          phone: '1234567890'
        }
      }

      await test.step('Get hotels list', async () => {
        const response = await bookingAPIHotelsController.getHotels();
        expect(response.status()).toBe(200);
        const hotels = await response.json();
        hotelId = hotels[0].id;
      })

      await test.step('Get available rooms list for the specified date range', async () => {
        const response =  await bookingAPIHotelsController.getAvailableRooms({
          checkInDate: bookingAPIRequestData.checkInDate,
          checkOutDate: bookingAPIRequestData.checkOutDate,
          guests: bookingAPIRequestData.guests,
          hotelId
        })

        expect(response.status()).toBe(200);
        const availableRooms = await response.json();
        // assigning the first available room to the request data for booking
        const firstAvailableRoom = availableRooms.find(room => room.available);
        bookingAPIRequestData.roomId = firstAvailableRoom.id;
      })

      await test.step('Book a room via booking API', async () => {
        const response = await bookingAPIBookingsController.createBooking(bookingAPIRequestData);
        expect(response.status()).toBe(200);
        bookingAPIBookingId = (await response.json()).bookingId;
      })

      await test.step('Make a booking via google maps API', async () => {
        const availabilities = await googleMapsHotelController.getAvailableRooms({
          startTime: Date.now(),
          endTime: Date.now() + 1000 * 60 * 60 * 24 * 7,
          places: 2
        })

        const firstAvailableRoom = availabilities.find(room => room.available);

        const bookingData = {
          roomId: firstAvailableRoom.id,
          startTime: Date.now(),
          endTime: Date.now() + 1000 * 60 * 60 * 24 * 7,
          customer: {
            name: 'John Doe',
            email: 'faker-john@gmail.com'
          },
          place: 2
        }

        const response = await googleMapsBookingController.createBooking(bookingData);
        expect(response.status()).toBe(200);
        googleMapsAPIBookingId = (await response.json()).id;
      })

      await test.step('Login as an employee', async () => {
        const response = await hotelAPIAuthController.login({
          email: 'employee@hotel.com',
          password: 'password'
        });

        const loginBody = await response.json();

        hotelAPIBookingController = new HotelAPIBookingController(request, {
          headers: {
            Authorization: `Bearer ${loginBody.token}`
          }
        });
      })
    })

    test('should cancel the booking via booking API', async () => {

      await test.step('Cancel the booking made via booking API', async () => {
        const response = await hotelAPIBookingController.cancelBooking(bookingAPIBookingId);
        expect(response.status()).toBe(200);
        expect(await response.json()).toEqual({
          state: 'CANCELLED'
        })
      })

      await test.step('Check cancelled booking status', async () => {
        const response = await hotelAPIBookingController.getBooking(bookingAPIBookingId);
        expect(response.status()).toBe(200);
        expect(await response.json()).toMatchObject({
          state: 'CANCELLED'
        })
      })

      await test.step('Cancel the booking made via google maps API', async () => {
        const response = await hotelAPIBookingController.cancelBooking(googleMapsAPIBookingId);
        expect(response.status()).toBe(200);
        expect(await response.json()).toEqual({
          state: 'CANCELLED'
        })
      })

      await test.step('Check cancelled booking status', async () => {
        const response = await hotelAPIBookingController.getBooking(googleMapsAPIBookingId);
        expect(response.status()).toBe(200);
        expect(await response.json()).toMatchObject({
          state: 'CANCELLED'
        })
      })
    })
  })
})

I have to admit it’s better than what we would have without controllers. But it’s still a bit messy.

Let’s see what problems we have with this approach:

  1. A lot of imports and initializations. We have to import all the controllers we need in the test file. In addition, we have to initialize them. That’s a lot of code we need to get rid of.
  2. Because our controllers have similar methods and names, we have to be careful when we use them. It’s easy to make a mistake and use the wrong controller.
  3. We have to pass the Authorization header to the controllers. It’s not a big deal, but it’s still a bit annoying. And again duplicated code.

So we are ready to introduce the concept of clients.

Clients

Clients are responsible for initializing and managing controllers. They are responsible for creating instances of controllers and passing the necessary data to them.

You can think of clients as a container for controllers or a real-world client that interacts with the API. Each client is responsible for a specific API. Each client has a set of functions it can perform.

So, in our case, we can create a BookingAPIClient, GoogleMapsAPIClient, and HotelAPIClient.

import HotelsController from "../controllers/bookingAPI/HotelsController.js";
import BookingController from "../controllers/bookingAPI/BookingController.js";


export default class BookingAPIClient {
  constructor(request) {
    this.hotels = new HotelsController(request);
    this.bookings = new BookingController(request);
  }
}
import HotelController from "../controllers/googleMapsAPI/HotelController.js";
import BookingsController from "../controllers/googleMapsAPI/BookingsController.js";


export default class GoogleMapsAPIClient {
  constructor(request) {
    this.hotel = new HotelController(request);
    this.bookings = new BookingsController(request);
  }
}

And the one for hotel API will be a bit more complex due to the fact that here we have token based authorization and for two others we have API key based authorization.

import AuthController from "../controllers/hotelAPI/AuthController.js";
import HotelsController from "../controllers/hotelAPI/HotelsController.js";
import BookingsController from "../controllers/hotelAPI/BookingsController.js";


export default class HotelAPIClient {
  constructor(request, options) {
    this.auth = new AuthController(request, options);
    this.hotels = new HotelsController(request, options);
    this.bookings = new BookingsController(request, options);
  }

  async authorize(request, loginData) {
    const authController = new AuthController(request);
    const response = await authController.login(loginData);
    const token = (await response.json()).token;

    return new HotelAPIClient(request, {
      headers: {
        Authorization: `Bearer ${token}`
      }
    });
  }
}

Now let’s refactor the test case to use the clients.

import {test, expect} from '@playwright/test';
import BookingAPIClient from "../../src/clients/BookingAPIClient.js";
import GoogleMapsAPIClient from "../../src/clients/GoogleMapsAPIClient.js";
import HotelAPIClient from "../../src/clients/HotelAPIClient.js";


test.describe('Hotel/Employee API', () => {
  test.describe('Bookings', () => {
    let bookingAPIClient;
    let hotelAPIClient;
    let googleMapsAPIClient;
    let hotelId;

    let bookingAPIBookingId;
    let googleMapsAPIBookingId;

    test.beforeEach(async ({request}) => {

      bookingAPIClient = new BookingAPIClient(request);
      googleMapsAPIClient = GoogleMapsAPIClient(request);

      const bookingAPIRequestData = {
        roomId: null,
        checkInDate: '2024-06-20',
        checkInTime: '10:00',
        checkOutDate: '2024-06-21',
        checkOutTime: '18:00',
        guests: 2,
        customer: {
          name: 'John Doe',
          email: 'john@test.com',
          phone: '1234567890'
        }
      }

      await test.step('Get hotels list', async () => {
        const response = await bookingAPIClient.hotels.getHotels();
        expect(response.status()).toBe(200);
        const hotels = await response.json();
        hotelId = hotels[0].id;
      })

      await test.step('Get available rooms list for the specified date range', async () => {
        const response =  await bookingAPIClient.hotels.getAvailableRooms({
          checkInDate: bookingAPIRequestData.checkInDate,
          checkOutDate: bookingAPIRequestData.checkOutDate,
          guests: bookingAPIRequestData.guests,
          hotelId
        })

        expect(response.status()).toBe(200);
        const availableRooms = await response.json();
        // assigning the first available room to the request data for booking
        const firstAvailableRoom = availableRooms.find(room => room.available);
        bookingAPIRequestData.roomId = firstAvailableRoom.id;
      })

      await test.step('Book a room via booking API', async () => {
        const response = await bookingAPIClient.bookings.createBooking(bookingAPIRequestData);
        expect(response.status()).toBe(200);
        bookingAPIBookingId = (await response.json()).bookingId;
      })

      await test.step('Make a booking via google maps API', async () => {
        const availabilities = await googleMapsAPIClient.hotel.getAvailableRooms({
          startTime: Date.now(),
          endTime: Date.now() + 1000 * 60 * 60 * 24 * 7,
          places: 2
        })

        const firstAvailableRoom = availabilities.find(room => room.available);

        const bookingData = {
          roomId: firstAvailableRoom.id,
          startTime: Date.now(),
          endTime: Date.now() + 1000 * 60 * 60 * 24 * 7,
          customer: {
            name: 'John Doe',
            email: 'faker-john@gmail.com'
          },
          place: 2
        }

        const response = await googleMapsAPIClient.bookings.createBooking(bookingData);
        expect(response.status()).toBe(200);
        googleMapsAPIBookingId = (await response.json()).id;
      })

      await test.step('Login as an employee', async () => {
        hotelAPIClient = await HotelAPIClient.authorize(request,
          {
            email: 'employee@hotel.com',
            password: 'password'
          });
      })
    })

    test('should cancel the booking via booking API', async () => {

      await test.step('Cancel the booking made via booking API', async () => {
        const response = await hotelAPIClient.bookings.cancelBooking(bookingAPIBookingId);
        expect(response.status()).toBe(200);
        expect(await response.json()).toEqual({
          state: 'CANCELLED'
        })
      })

      await test.step('Check cancelled booking status', async () => {
        const response = await hotelAPIClient.bookings.getBooking(bookingAPIBookingId);
        expect(response.status()).toBe(200);
        expect(await response.json()).toMatchObject({
          state: 'CANCELLED'
        })
      })

      await test.step('Cancel the booking made via google maps API', async () => {
        const response = await hotelAPIClient.bookings.cancelBooking(googleMapsAPIBookingId);
        expect(response.status()).toBe(200);
        expect(await response.json()).toEqual({
          state: 'CANCELLED'
        })
      })

      await test.step('Check cancelled booking status', async () => {
        const response = await hotelAPIClient.bookings.getBooking(googleMapsAPIBookingId);
        expect(response.status()).toBe(200);
        expect(await response.json()).toMatchObject({
          state: 'CANCELLED'
        })
      })
    })
  })
})

That’s it. We have refactored the test case to use clients. Let’s see what we have achieved:

  1. Plenty of imports and initializations. We have removed a lot of imports and initializations. We have a single place where we create instances of controllers. We don’t have to import controllers in the test file. We don’t have to initialize them. We have a single place where we interact with the API.
  2. Similar methods and names. We have removed the possibility of using the wrong controller. Because now every api call will be made using the client that is responsible for the API.
  3. Passing the Authorization header. We have removed the need to pass the Authorization header to the controllers.

That’s it for controllers and clients. I hope you will find this approach useful in your projects.

Source code for this article can be found here.

But as you can see our test still can be improved. What I personally do not like in the test is that we have a lot of hardcoded payloads for API calls. That might be a topic for the next article. Stay tuned!