By Stanislav Taran

Моделі в API тестах


Передмова

Мета цієї статті - поділитися з вами концепцією моделей як зручного способу формування даних в API тестуванні. Перш ніж ми заглибимося в тему, давайте уточнимо, що ми маємо на увазі під моделями в контексті API тестування.

Я дуже рекомендую прочитати пост про API controllers and clients перед тим, як читати цей пост.

Що таке моделі в API тестуванні?

Модель символізує об’єкт або сутність в системі, яку ми тестуємо. Вона інкапсулює структуру/поля (рідше поведінку) об’єкта, який вона представляє. В контексті API тестування модель може бути використана для визначення структури запитів (рідше відповідей), якими обмінюються клієнт і сервер.

Які переваги використання моделей в API тестуванні?

Давайте розглянемо це на прикладі. Припустимо, вас найняла компанія, яка володіє системою управління готелями. Ваше завдання - написати тести для API системи. Система має кілька API, які використовують різні клієнти:

  • Booking API - основне API, яке має використовуватися деякими абстрактними застосунками для бронювання (Airbnb, Booking.com, Hotels.com, тощо).
  • Hotel API для працівників - API, яке має використовуватися працівниками готелю для управління бронюваннями, профілями гостей, управлінням графіками готелів тощо. Воно використовується, коли гість тільки прибуває до готелю і хоче забронювати кімнату, або коли гість хоче продовжити перебування, або коли гість хоче скасувати бронювання по телефону тощо.
  • Google Maps API - API, яке має використовуватися для надання доступності готелів на мапі та надання можливості зробити бронювання з Google Maps (я не впевнений, чи це справді існує для готелів, але давайте уявимо, що це так).
Multiple API calls in a test scenario

Перш ніж ми розглянемо переваги використання моделей в API тестуванні, давайте розглянемо наступний тестовий сценарій:

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'
        })
      })
    })
  })
})

Якщо ви збентежені тим, що таке клієнти в тесті вище, ви можете переглянути пост про API controllers and clients.

У вказаному тестовому сценарії ми перевіряємо, чи можна успішно скасувати бронювання, зроблене через два різні API (API бронювань та Google Maps API), через третє API (Hotel API для працівників).

Ви можете сперечатися на рахунок різних аспектів реалізації тестового сценарію вище, але я впевнений, що ви помітили, що тестовий сценарій досить складний і ми маємо справу з кількома складними структурами даних. Зокрема, з двома різними об’єктами для створення бронювань:

  • bookingAPIRequestData - тіло запиту для Booking API
  • googleMapsAPIRequestData - тіло запиту для Google Maps API

Ми визначили ці тіла запитів безпосередньо в тестовому сценарії. Цей підхід має кілька недоліків:

  1. Дублювання коду - якщо вам знадобиться використовувати ці структури запитів в інших тестових сценаріях, вам доведеться їх дублювати бездумно копіюючи.
  2. Читабельність - тестовий сценарій важко читати і розуміти через складні структури даних які там присутні.
  3. Підтримка - якщо структура даних зміниться, нам доведеться оновлювати її в усіх тестах, де вона використовується.

Ці об’єкти можуть бути великими, і вони можуть містити багато полів. Але в більшості випадків нам цікаві лише деякі з них. Іншими словами, нам не обов’язково визначати всі поля в тестовому сценарії. Ми можемо сконцентруватися лише на полях, які важливі для тестового сценарію, і заповнити інші значеннями за замовчуванням/випадковими значеннями.

Саме тут можуть допомогти моделі.

Як використовувати моделі в API тестуванні?

Перш за все, моделі допомагають нам визначити структуру даних, які ми використовуємо в тестовому сценарії. А який найкращий спосіб представити сутності в системі, яку ми тестуємо? Правильно, це класи.

Якщо ви знайомі з концепцією успадкування в Javascript, вам буде легше зрозуміти, як працюють моделі. Якщо ні, я дуже рекомендую вам прочитати про це. Оскільки ми почнемо з визначення класу базової моделі, від якого будуть успадковуватися інші моделі.

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)
  }
}

Тож, ми створили базову модель класу, який має кілька методів для роботи з даними. Я думаю, що методи самі по собі зрозумілі, тому я не буду їх пояснювати. Якщо бути чесним, я майже ніколи не використовую інші методи, крім extract в тестових сценаріях, але насправді це залежить від вимог проекту.

Тепер давайте визначимо моделі для тіл запитів для бронювань:

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
        }
      }
    })
  }
}

Давайте зупинимося тут і обговоримо модель вище. Я створив клас BookingAPIBookingModel, який успадковує клас BaseModel.

Я не вказав конструктор, оскільки JavaScript дозволяє нам це зробити, якщо він такий самий, як у батьківського класу.

Я створив статичний метод withDefaultData, який повертає екземпляр BookingAPIBookingModel з деякими значеннями за замовчуванням або випадковими значеннями. Чому я це зробив? Якби у нас не було цього методу, нам довелося б створювати екземпляр BookingAPIBookingModel і вручну встановлювати всі поля.

Якось так:

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
    }
  }
})

В цьому випадку не було б ніякої користі від моделі. Ми все одно мусили б визначити структуру даних у тестовому сценарії.

Але з методом withDefaultData ми можемо створити екземпляр BookingAPIBookingModel з даними за замовчуванням і потім модифікувати лише ті поля, які важливі для тестового сценарію.

І саме для цього були створені методи setRoomId, setCustomerEmail, setCustomerPhone і т.д. Я створив лише декілька з них, але ви можете створити стільки, скільки потрібно.

Також я додав декілька геттерів, щоб мати швидкий доступ до найважливіших полів.

Давайте спробуємо візуалізувати, як ми можемо використовувати цю модель зараз:

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

Спраді, непогано, але давайте зробимо це ще краще. Я пропоную імплементувати підхід, який називається “methods chaining”, щоб зробити код більш читабельним:

Для цього нам потрібно трохи модифікувати наші методи:

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
        }
      }
    })
  }
}

Як ви бачите, я додав return this до методів, які встановлюють дані. Це дозволяє нам чейнити методи таким чином:

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

Тепер код виглядає більш читабельним і зрозумілим.

Тепер давайте визначимо модель для тіла запиту для Google Maps API бронювань за аналогією:

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'
      }
    });
  }
}

І нарешті, давайте використаємо моделі в тестовому сценарії:

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'
        })
      })
    })
  })
})

Як на мене, тестовий сценарій виглядає набагато чистіше і зрозуміліше зараз. Давайте підсумуємо, що ми досягли, використовуючи моделі в тестовому сценарії:

  1. Дублювання коду - ми усунули дублювання коду, визначивши структуру даних в моделях.
  2. Читабельність - тестовий сценарій тепер більш читабельний і зрозумілий завдяки наявності моделей.
  3. Підтримка - якщо структура даних зміниться, є велика ймовірність, що нам доведеться оновити її тільки в моделях.
  4. Повторне використання - ми можемо використовувати моделі в інших тестових сценаріях не думаючи про структуру даних.
  5. Ми зекономили 40 рядків коду в тестовому сценарії, використовуючи моделі :)

Якщо ви задаєтесь питанням чи я працюю з датами та часом використовуючи стандартні методи JS, відповідь - ні. Мені просто було лінь встановлювати будь-яку бібліотеку для цього. Але я рекомендую вам використовувати якусь бібліотеку, наприклад, date-fns або moment, для роботи з датами та часом.

Я дуже ціную вашу увагу і сподіваюся, що цей пост був корисним. Якщо у вас є питання або пропозиції, не соромтеся зв’язатися зі мною.