By Stanislav Taran

Клієнти і контролери в автоматизації API


Вступ

Я маю досить непоганий досвід роботи з автоматизацією тестування API. І я помітив, що багато людей не знають про концепцію контролерів та клієнтів в автоматизації тестування API. Звісно, я маю на увазі світ JS/TS, оскільки люди, які працюють з Java або C#, можливо, знайомі з цими концепціями, оскільки вказані мови програмування побудовані навколо парадигми ООП.

Певний час тому я працював над проектом, де нам довелося написати багато API тестів. Це був мій перший досвід роботи в ролі QA Automation Engineer. Я дуже ціную цей досвід, оскільки багато навичок, які я отримав там, я і досі використовую у своїй щоденній роботі.

В цій статті я хочу поділитися з вами концепцією контролерів та клієнтів в автоматизації тестування API. Сподіваюсь, ви побачите користь в цьому.

Ми будемо використовувати фреймворк Playwright Test та його ApiRequestContext для цього прикладу, але концепція плюс-мінус однакова для будь-якого іншого фреймворку та HTTP клієнта, оскільки ми будемо говорити більше про архітектуру, а не про інструменти.

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

Часто люди використовують мобільні або веб-додатки, коли хочуть забронювати готельний номер, наприклад Booking.com або Airbnb та інші. Застосунки такого роду агрегують дані про готелі від різних провайдерів (таких як ваша компанія), і вони повинні мати можливість використовувати API цих провайдерів для:

  • синхронізації даних про доступні номери, ціни, щоб додаток міг показати їх користувачеві (потрібно отримати дані про доступність)
  • бронювання номеру
  • скасування бронювання

Звісно що сценаріїв використання API в таких додатках може бути багато, але нам достатньо і цього для цілей цієї статті.

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

Отже, ви маєте написати тести для API компанії, яке відповідає за надання даних про доступність, можливості бронювання та скасування бронювань клієнтам (клієнтам - додаткам для бронювання).

Simple API

Однофайловий тест

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

Давайте створимо псевдо-код для тесту, який бронює номер через 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'
        })
      })
    })
  });
});

А тепер створимо псевдо-код для тесту, який скасовує бронювання через 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'
      });
    })
  });
});

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

Давайте подивимося на проблеми, які ми маємо з цим підходом:

  1. Дублювання коду. Ми маємо повторюваний код для отримання списку готелів, доступних номерів та бронювання номера в кожному тесті. Як ви можете побачити, нам потрібно передавати ключ API в заголовках для кожного запиту. Якщо заголовок авторизації змінюється, нам потрібно оновлювати його в кожному тесті.
  2. Важко підтримувати. Якщо API змінюється, нам потрібно оновити всі тести. Повірте, це не дуже цікаво. Вам потрібно пройти через всі тести та оновити код.
  3. Важко читати. Тест важко читати через дублювання коду та API ендпоінти. Ви не можете зрозуміти логіку тесту з першого погляду. Вам потрібно скролити вгору та вниз, щоб зрозуміти, що робить кожна дія. Звісно, Playwright steps покращують ситуацію, але не рятують її.
  4. Важко масштабувати. Якщо ми хочемо додати більше тестів, нам потрібно буде знову дублювати код. Так, це не важко, але подивіться на пункт 2 ще раз. Ваші страждання подвояться. Більшість проектів, над якими я працював, мали як мінімум 200 API тестів. І це не межа. Один проект мав більше 1000 API тестів.

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

Без паніки, я маю рішення для вас.

Контроллери

Давайте візуалізуємо API, яке ми використовували в наших тестах:

Booking API

Отже, ми маємо 4 ендпоінти:

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

Зазвичай я схильний групувати ендпоінти API за сутністю, за яку вони відповідають, але часто вам не потрібно думати про це, якщо у вас є належна документація. У нашому випадку у нас є 2 сутності: hotels та bookings.

Тож давайте поговоримо про концепцію контролерів. Якщо ви коли-небудь писали UI або E2E тести, ви, можливо, знаєте, що таке Page Object.

Контроллери схожі на Page Objects, але для API тестів. Вони відповідають за взаємодію з ендпоінтами API, приховуючи деталі реалізації від тестів.

Оскільки у нас вже є 2 сутності, ми можемо створити 2 контролери: HotelsController та BookingsController.

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

Але насамперед давайте створимо клас PlaywrightController, який буде відповідальний за виконання HTTP запитів до 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;
  }
}

Спершу цей клас може здатися трохи складним, але ви побачите, як він спрощує контролери.

Тепер ми готові створити 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,
      }
    });
  }
}

Як ви могли помітити, ми додали заголовок X-Api-Key до базового контролера. Таким чином ми можемо бути впевнені, що кожний запит, який роблять контролери, матиме заголовок X-Api-Key, і нам не потрібно передавати його кожного разу, коли ми робимо запит.

Ці два контролери є основою для наших контролерів готелів та бронювань.

Давайте створимо 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)
  }
}

Завдяки тому що ми описали всю логіку, пов’язану з викликами API в PlaywrightController та BaseBookingAPIController, ми можемо використовувати методи з цих класів в HotelsController. Це дозволяє нам писати менше коду та робить код більш зрозумілим.

Тепер 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}`);
  }
}

Вау, поки що все. Давайте подивимося, як ми можемо використовувати ці контролери в наших тестах. Ми перепишемо тести, які ми написали раніше, використовуючи контролери.

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

і другий тест:

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

Пора подивитися чого ми досягли:

  1. Дублювання коду. Ми видалили дублювання коду. У нас є одне місце, де ми робимо HTTP запити до API. Якщо заголовок авторизації змінюється, нам потрібно оновити його в одному місці.
  2. Важко підтримувати. Якщо ендпоінти API змінюються, нам потрібно оновити контролери. Нам не потрібно оновлювати тести. У нас є одне місце, де ми взаємодіємо з API.
  3. Важко читати. Тест став більш зрозумілим. Ви можете побачити логіку тесту з першого погляду, оскільки методи контролерів названі належним чином.
  4. Важко масштабувати. Якщо ми хочемо додати більше тестів, нам не потрібно дублювати код. Ми просто створюємо новий тест та використовуємо контролери.

Але чи є якісь виклики які виникають з цим підходом? Так, безсумнівно:

  1. Ваше API може мати багато сутностей та ендпоінтів. Вам потрібно імпортувати всі контролери, які вам потрібні, в файл тесту. На додачу вам потрібно ініціалізувати їх у beforeAll або beforeEach хуці. Це може бути трохи надокучливо та виглядати наляписто.

  2. Ваша компанія може мати багато API. Вам потрібно створити контролери для кожного API. І є велика ймовірність, що ви матимете однаковий функціонал на різних API. Кожне API ймовірно працюватиме з трохи різними DTO. Давайте додамо трохи складності.

Поки що ми мали одне API, яке має використовуватися абстрактними застосунками для бронювання.

Simple API

Але тепер ми додамо ще декілька:

Multiple APIs

Обговоримо канали, показані на зображенні:

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

Уявімо, що нам потрібно написати тест, який підтверджує, що працівники готелю можуть скасувати бронювання, зроблені через Booking API або Google Maps API.

Для цього нам треба створити контроллери для нових API. Спочатку Hotel API:

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

Тепер 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);
  }
}

Це солідний обсяг, чи не так? Подивимося, як ми можемо використовувати ці контролери в наших тестах.

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

Мушу визнати, що це краще, ніж ми мали б без контролерів. Але все ще трохи невпорядковано.

Які проблеми ми маємо з цим підходом:

  1. Багато імпортів та ініціалізацій. Нам потрібно імпортувати всі контролери, які ми використовуємо в файл тесту. Крім того, нам потрібно їх ініціалізувати. Це багато коду, якого добре було б позбутися.
  2. Оскільки наші контролери мають схожі методи та назви, нам потрібно бути обережними при їх використанні. Легко зробити помилку та використати неправильний контролер.
  3. Нам потрібно передавати заголовок Authorization контролерам. Це не велика проблема, але все одно трохи надокучливо. І знову дубльований код.

Я думаю, що ми готові ввести концепцію клієнтів.

Клієнти

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

Ви можете думати про клієнтів як про контейнер для контролерів або як про реального клієнта, який взаємодіє з API. Кожен клієнт відповідає за певне API. Кожен клієнт має набір функцій, які він може виконувати.

Тож, у нашому випадку ми можемо створити BookingAPIClient, GoogleMapsAPIClient та 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);
  }
}

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

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

Робимо рефакторинг знову:

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

От і все. Ми переписали тест використовуючи клієнти. Подивимося, що ми досягли:

  1. Багато імпортів та ініціалізацій. Ми видалили багато імпортів та ініціалізацій. У нас є одне місце, де ми створюємо екземпляри контролерів. Нам не потрібно імпортувати контролери в файл тесту. Ми не повинні їх ініціалізувати. У нас є одне місце, де ми взаємодіємо з API.
  2. Схожі методи та назви. Ми видалили можливість використання неправильного контролера. Оскільки тепер кожен виклик API буде виконуватися за допомогою клієнта, який відповідає за це API.
  3. Передача заголовка Authorization. Ми позбулися необхідності передавати заголовок Authorization контролерам.

Це все що я хотів розповісти про контролери та клієнти. Сподіваюсь, вам було цікаво та корисно.

Вихідний код можна знайти тут.

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