By Stanislav Taran

Models in API Tests


Preface

The goal of this article is to share with you the concept of models as data units in API testing. Before we dive into the topic, let’s clarify what we mean by models in the context of API testing.

I highly recommend reading the post on API controllers and clients before proceeding with this one.

What is a model in API testing?

A model is a data unit that represents a specific entity or object in the system under test. It encapsulates the data structure (rarely behavior) of the entity it represents. In the context of API testing, a model can be used to define the structure of the request (rarely response) payloads exchanged between the client and the server.

Why use models in API testing?

Firstly I want to provide a bit of context. Imagine you’ve been hired by a company that owns a hotel management system. Your task is to write tests for the system’s API. The system has several APIs that are used by different clients:

  • 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)
Multiple API calls in a test scenario

Before we discuss the benefits of using models in API testing, let’s take a look at the following test scenario:

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";
import faker from 'faker';


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,
        guest_comment: 'Please, provide a baby crib',
        customer: {
          name: 'John Doe',
          email: 'john@test.com',
          phone: '1234567890'
        },
        notifications: {
          confirmation: {
            in_app: true,
            phone: true
          },
          mutation: {
            in_app: true,
            phone: true
          },
          cancellation: {
            in_app: true,
            phone: true
          }
        }
      }

      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 startTime = Date.now() + 60 * 60 * 24 * 1000;
        const endTime = Date.now() + 60 * 60 * 24 * 7 * 1000;
        const availabilities = await googleMapsAPIClient.hotel.getAvailableRooms({
          startTime,
          endTime,
          places: 2
        })

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

        const googleMapsAPIRequestData = {
          slot: {
            merchant_id: hotelId,
            service_id:  '123',
            start_sec: startTime,
            end_sec: endTime,
            resources: {
              room_id: firstAvailableRoom.id,
              party_size: 2
            }
          },
          comment: 'I might be late for check-in due to a meeting',
          idempotency_token: faker.random.alphaNumeric(10),
          user_information: {
            user_id: `G-ID-${ faker.random.alpha({count: 5})}`,
            given_name: faker.name.firstName(),
            family_name: faker.name.lastName(),
            address: {
              country: 'UA',
              locality: 'Kharkiv',
              region: "Kharkiv Region",
              postal_code: '8000',
              street_address: faker.address.streetAddress()
            },
            telephone: faker.phone.phoneNumber('380#########'),
            email: `google.user.${faker.internet.email()}`,
            language_code: 'ua'
          }
        }

        const response = await googleMapsAPIClient.bookings.createBooking(googleMapsAPIRequestData);
        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'
        })
      })
    })
  })
})

If you are confused by what are clients in the test above, you can check out the post on API controllers and clients.

In the provided test scenario we check if bookings made via two different APIs (bookings API and Google Maps API) can be successfully cancelled via the third API (Hotel API for employees).

You might argue on different aspects of the implementation of the test scenario above, but I’m sure you’ve noticed that the test scenario is quite complex and we have to deal with multiple complex data structures. More precisely, with 2 different booking payloads:

  • bookingAPIRequestData - the payload for the booking API
  • googleMapsAPIRequestData - the payload for the Google Maps API

We have defined these payloads directly in the test scenario. This approach has several drawbacks:

  1. Code duplication - if you will need to use these payloads in other test scenarios, you will have to duplicate them.
  2. Readability - the test scenario is hard to read and understand due to the presence of complex data structures.
  3. Maintainability - if the data structure changes, we have to update it in all test scenarios where it is used.

Yeah, those payloads have a lot of fields. But in most cases we don’t care about all of them during the testing. That means we don’t necessarily need to define all the fields in the test scenario. We could focus only on the fields that are important for the test scenario and fill others with some default/random values.

This is where the concept of models comes into play.

How to use models in API testing?

First of all, data models represents entities in the system under test. And what is the best way to represent entities in the system under test? Right, it’s classes.

Again, I hope you are familiar with the inheritance concept in Javascript. If not, I highly recommend you to read about it. Because we will start with defining a base model class that will be inherited by other models.

export default class BaseModel {
  constructor(data) {
    // Since data usually represent a reference type, I prefer to clone it to avoid any side effects
    this._data = structuredClone(data)
  }

  merge(data){
    this._data = structuredClone({
      ...this._data,
      ...data
    })
  }

  set(key, value){
    this._data[key] = structuredClone(value)
  }

  remove(key){
    delete this._data[key]
  }


  // This method used to extract the data from the model
  // In fact it can be named as "toJSON" or "build" etc., but I prefer to name it this way
  // And also I prefer to use structuredClone to avoid any side effects after we extract the data
  extract(){
    return structuredClone(this._data)
  }
}

So, we have defined a base model class that has several methods to work with the data. I think the methods are self-explanatory. To be honest I rarely use methods other than extract in the test scenarios, but actually it depends on the requirements of the project.

Now let’s define the models for the booking payloads:

import BaseModel from "../BaseModel.js";
import {faker} from "@faker-js/faker";

export default class BookingAPIBookingModel extends BaseModel {
  /*
   * I omit the constructor here because it is the same as the one in the BaseModel
   * */

  setRoomId(value){
    this._data.roomId = value
  }

  setCustomerEmail(value){
    this._data.customer.email = value
  }

  setCustomerPhone(value){
    this._data.customer.phone = value
  }

  setCheckInDate(value){
    this._data.checkInDate = value
  }

  setCheckInTime(value){
    this._data.checkInTime = value
  }

  setCheckOutDate(value){
    this._data.checkOutDate = value
  }

  setCheckOutTime(value){
    this._data.checkOutTime = value
  }

  get checkInDate(){
    return this._data.checkInDate
  }

  get checkOutDate(){
    return this._data.checkOutDate
  }

  get guests(){
    return this._data.guests
  }


  static withDefaultData(){
    return new BookingAPIBookingModel({
      roomId: null,
      checkInDate: new Date().toISOString().split('T')[0],
      checkInTime: '10:00',
      checkOutDate: new Date(new Date().setDate(new Date().getDate() + 7)).toISOString().split('T')[0],
      checkOutTime: '18:00',
      guests: 2,
      guest_comment: 'Please, provide a baby crib',
      customer: {
        name: faker.person.firstName(),
        email: faker.internet.email(),
        phone: `+380${faker.datatype.number(
          {
            min: 100000000,
            max: 999999999
          })}`
      },
      notifications: {
        confirmation: {
          in_app: true,
          phone: true
        },
        mutation: {
          in_app: true,
          phone: true
        },
        cancellation: {
          in_app: true,
          phone: true
        }
      }
    })
  }
}

Let’s take a break here and discuss the model above. I created a class BookingAPIBookingModel that extends the BaseModel class.

I omitted the constructor because JavaScript allows us to omit the constructor if it is the same as the parent’s one.

I created a static method withDefaultData that returns an instance of the BookingAPIBookingModel with kind of default or random data. Why did I do that? If we would not have this method, we would have to create an instance of the BookingAPIBookingModel and set all the fields manually.

Like this:

const bookingAPIBookingModel = new BookingAPIBookingModel({
  roomId: null,
  checkInDate: new Date().toISOString().split('T')[0],
  checkInTime: '10:00',
  checkOutDate: new Date(new Date().setDate(new Date().getDate() + 7)).toISOString().split('T')[0],
  checkOutTime: '18:00',
  guests: 2,
  guest_comment: 'Please, provide a baby crib',
  customer: {
    name: faker.person.firstName(),
    email: faker.internet.email(),
    phone: `+380${faker.datatype.number(
      {
        min: 100000000,
        max: 999999999
      })}`
  },
  notifications: {
    confirmation: {
      in_app: true,
      phone: true
    },
    mutation: {
      in_app: true,
      phone: true
    },
    cancellation: {
      in_app: true,
      phone: true
    }
  }
})

In this case we would not benefit from the model at all. We would have to define the data structure in the test scenario anyway.

But with the withDefaultData method we can create an instance of the BookingAPIBookingModel with default data and then modify only the fields that are important for the test scenario.

And for this purpose we created methods like setRoomId, setCustomerEmail, setCustomerPhone, etc. I created only a few of them, but you can create as many as you need.

I also added some getters to have quick access to the most important fields.

Let’s try to visualise how can we use this model now:

const bookingAPIBookingModel = BookingAPIBookingModel.withDefaultData();
bookingAPIBookingModel.setRoomId('123');
bookingAPIBookingModel.setCustomerEmail("test@test.com");
bookingAPIBookingModel.setCheckInDate('2024-06-20');
bookingAPIBookingModel.setCheckOutDate('2024-06-21');

Not bad really, but let’s make it even better. Let’s introduce an approach called “methods chaining” to make the code more readable:

To do that we need to modify or methods a bit:

import BaseModel from "../BaseModel.js";
import {faker} from "@faker-js/faker";

export default class BookingAPIBookingModel extends BaseModel {
  /*
   * I omit the constructor here because it is the same as the one in the BaseModel
   * */

  setRoomId(value){
    this._data.roomId = value
    return this
  }

  setCustomerEmail(value){
    this._data.customer.email = value
    return this
  }

  setCustomerPhone(value){
    this._data.customer.phone = value
    return this
  }

  setCheckInDate(value){
    this._data.checkInDate = value
    return this
  }

  setCheckInTime(value){
    this._data.checkInTime = value
    return this
  }

  setCheckOutDate(value){
    this._data.checkOutDate = value
    return this
  }

  setCheckOutTime(value){
    this._data.checkOutTime = value
    return this
  }


  get checkInDate(){
    return this._data.checkInDate
  }

  get checkOutDate(){
    return this._data.checkOutDate
  }

  get guests(){
    return this._data.guests
  }



  static withDefaultData(){
    return new BookingAPIBookingModel({
      roomId: null,
      checkInDate: new Date().toISOString().split('T')[0],
      checkInTime: '10:00',
      checkOutDate: new Date(new Date().setDate(new Date().getDate() + 7)).toISOString().split('T')[0],
      checkOutTime: '18:00',
      guests: 2,
      guest_comment: 'Please, provide a baby crib',
      customer: {
        name: faker.person.firstName(),
        email: faker.internet.email(),
        phone: `+380${faker.datatype.number(
          {
            min: 100000000,
            max: 999999999
          })}`
      },
      notifications: {
        confirmation: {
          in_app: true,
          phone: true
        },
        mutation: {
          in_app: true,
          phone: true
        },
        cancellation: {
          in_app: true,
          phone: true
        }
      }
    })
  }
}

As you can see, I added return this to the methods that set the data. This allows us to chain the methods like this:

const bookingAPIBookingModel = BookingAPIBookingModel.withDefaultData()
    .setRoomId('123')
    .setCustomerEmail("test@test.com")
    .setCheckInDate('2024-06-20')
    .setCheckOutDate('2024-06-21')

Now the code looks more readable and understandable.

Let’s define the model for the Google Maps API booking payload by analogy:

import BaseModel from "../BaseModel.js";
import {faker} from "@faker-js/faker";


export default class GoogleMapsAPIBookingModel extends BaseModel{

  setStartSec(startSec) {
    this.data.slot.start_sec = startSec;
    return this;
  }

  setEndSec(endSec) {
    this.data.slot.end_sec = endSec;
    return this;
  }

  setMerchantId(merchantId) {
    this.data.slot.merchant_id = merchantId;
    return this;
  }

  setRoomId(roomId) {
    this.data.slot.resources.room_id = roomId;
    return this;
  }


  static withDefaultData() {
    return new GoogleMapsAPIBookingModel({
      slot: {
        merchant_id: null,
        service_id:  '123',
        start_sec: Date.now(),
        end_sec: Date.now() + 60 * 60 * 24 * 2 * 1000,
        resources: {
          room_id: null,
          party_size: 2
        }
      },
      comment: 'qa note',
      idempotency_token: faker.random.alphaNumeric(10),
      user_information: {
        user_id: `G-ID-${ faker.random.alpha({count: 5})}`,
        given_name: faker.name.firstName(),
        family_name: faker.name.lastName(),
        address: {
          country: faker.address.countryCode(),
          locality: faker.address.city(),
          region: faker.address.state(),
          postal_code: faker.address.zipCode(),
          street_address: faker.address.streetAddress()
        },
        telephone: `+380${faker.datatype.number(
          {
            min: 100000000,
            max: 999999999
          })}`,
        email: `google.user.${faker.internet.email()}`,
        language_code: 'ua'
      }
    });
  }
}

Now let’s modify the test scenario to use the models:

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";
import BookingAPIBookingModel from "../../src/models/bookingAPI/BookingAPIBookingModel.js";
import GoogleMapsAPIBookingModel from "../../src/models/googleMapsAPI/GoogleMapsAPIBookingModel.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);

      // I create a new instance of the model and set the default data
      const bookingAPIRequestData = BookingAPIBookingModel.withDefaultData()
      // I use the `setCheckOutDate` method from the model to set custom check-out date
        .setCheckOutDate(new Date(Date.now() + 60 * 60 * 24 * 1000).toISOString().split('T')[0])


      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 () => {

        // I use getters below to access the data from the model
        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();
        const firstAvailableRoom = availableRooms.find(room => room.available);

        // here I use the `setRoomId` method from the model to set the room id
        bookingAPIRequestData.setRoomId(firstAvailableRoom.id);
      })

      await test.step('Book a room via booking API', async () => {
        // And here I use the `extract` method to get the data from the model as an object
        const response = await bookingAPIClient.bookings.createBooking(bookingAPIRequestData.extract());
        expect(response.status()).toBe(200);
        bookingAPIBookingId = (await response.json()).bookingId;
      })

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

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

        // Another example of using the model to create the request data
        const googleMapsAPIRequestData = GoogleMapsAPIBookingModel.withDefaultData()
          .setStartSec(startTime)
          .setEndSec(endTime)
          .setMerchantId(hotelId)
          .setRoomId(firstAvailableRoom.id);

        // I use the `extract` method to get the data from the model as an object
        const response = await googleMapsAPIClient.bookings.createBooking(googleMapsAPIRequestData.extract());
        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'
        })
      })
    })
  })
})

IMO, the test scenario looks much cleaner and more readable now. Let’s summarize what we’ve achieved by using models in the test scenario:

  1. Code duplication - we’ve eliminated code duplication by defining the data structure in the models.
  2. Readability - the test scenario is now more readable and understandable due to the presence of models.
  3. Maintainability - if the data structure changes, there is high probability that we will have to update it only in the models.
  4. Reusability - we can reuse the models in other test scenarios.
  5. We’ve saved 40 lines of code in the test scenario by using the models :)

If you are wondering If I really work with dates and times with standard JS methods, the answer is no. I was just too lazy to install any library for that. But I highly recommend you to use some library like date-fns or moment to work with dates and times.

I appreciate your attention and hope you found this post useful. If you have any questions or suggestions, feel free to contact me.