Работа с данными API-ответов в Cypress

В этой статье мы обсудим, как в Cypress выполняется код, как использовать алиасы, создавать хранилища и пользовательские команды для вызова API.

Подпишитесь на наш ТЕЛЕГРАМ КАНАЛ ПО АВТОМАТИЗАЦИИ ТЕСТИРОВАНИЯ

Содержание:

Итак, представим, что в начале теста вы вызываете конечную точку API. Она выдаст вам ответ, который вы хотите использовать в дальнейшем в своем тесте. Что делать дальше?

Очевидный соблазн – хранить ответ в переменной, примерно так:

beforeEach( () => {

  cy
    .log('starting test')

})

it('creates a new board', () => {

  let res
  cy
    .request('POST', '/api/boards', { name: 'new board' })
    .then( ({ body }) => {
      res = body
    })

  console.log(res)

})

Однако это не будет работать корректно. В console.log вернется undefined. Основная причина этого заключается в том, что команды Cypress являются асинхронными. Но что это означает на простом языке?

Как выполняется код в Cypress?

Интуиция подсказывает, что наш код читается сверху вниз. Это отчасти верно, но не совсем. На самом деле он выполняется блоками. В нашем тесте есть три отдельных блока кода (или функции). Наш beforeEach() блок, it() блок и .then() блок. Это означает, что при выполнении кода сначала будет выполняться первый блок:

beforeEach( () => {

  cy
    .log('starting test')

})

it('creates a new board', () => {

  let res
  cy
    .request('POST', '/api/boards', { name: 'new board' })
    .then( ({ body }) => {
      res = body
    })

  console.log(res)

})

Затем будет выполнен it() блок (посмотрите, что происходит с res переменной):

beforeEach( () => {

  cy
    .log('starting test')

})

it('creates a new board', () => {

  let res

  cy
    .request('POST', '/api/boards', { name: 'new board' })
    .then( ({ body }) => {
      res = body
    })

  console.log(res)

})

И, наконец, .then() блок:

beforeEach( () => {

  cy
    .log('starting test')

})

it('creates a new board', () => {

  let res
  cy
    .request('POST', '/api/boards', { name: 'new board' })
    .then( ({ body }) => {
      res = body
    })

  console.log(res)

})

Теперь становится ясно, почему console.log() не возвращает нужное нам значение.

Использование команды .then()

Если мы хотим работать с тем, что возвращает наша .request() команда, то нам нужно написать этот код внутри .then() функции. Таким образом, если мы хотим создать новый список внутри board, нам нужно написать код следующим образом:

it('creates a new list within a board', () => {

  cy
    .request('POST', '/api/boards', { name: 'new board' })
    .then((board) => {

      cy
        .request('POST', '/api/lists', {
          title: 'new list',
          boardId: board.body.id
        })

    })

})

Допустим, мы хотим создать задачу, которая находится внутри списка (list), расположенного внутри board. Код будет выглядеть примерно так:

it('creates a new task on a list within a board', () => {

  cy
    .request('POST', '/api/boards', { name: 'new board' })
    .then((board) => {

      cy
        .request('POST', '/api/lists', {
          title: 'new list',
          boardId: board.body.id
        })
        .then((list) => {

          cy
            .request('POST', '/api/tasks', {
              title: 'new task',
              listId: list.body.id,
              boardId: board.body.id
            })

        })

    })

})

Использование алиасов

Вы уже видите, как код, приведенный выше, становится все труднее читать. Одним из способов избежать ада колбеков (callback hell) в Cypress является использование алиасов Mocha. Это позволяет хранить данные и обращаться к ним во время тестирования. По сути мы переносим все на один уровень:

it('creates a new task on a list within a board', function() {

  cy
    .request('POST', '/api/boards', { name: 'new board' })
    .as('board')

  cy
    .then(() => {

      cy
        .request('POST', '/api/lists', {
          title: 'new list',
          boardId: this.board.body.id
        })
        .as('list')

    })

  cy
    .then(() => {

      cy
        .request('POST', '/api/tasks', {
          title: 'new task',
          listId: this.list.body.id,
          boardId: this.board.body.id
        })

    })

})

Однако обратите внимание, что в строке 1 вместо стрелочной функции мы используем синтаксис обычной функции. Это связано с тем, что в стрелочных функциях нельзя использовать ключевое слово this.

Объект window

Другим способом передачи данных является использование объекта window браузера. Это позволяет обменивать данные между тестами:

it('creates a board', () => {

  cy
    .request('POST', '/api/boards', { name: 'new board' })
    .then((board) => {
      window.board = board.body;
    })

})

it('creates a list', () => {

  cy
    .request('POST', '/api/lists', {
      title: 'new list',
      boardId: window.board.id
    })

});

В целом, лучше избегать этого подхода, так как он подразумевает создание зависимостей тестов друг от друга. Если первый тест не сработает, то автоматически не сработает и второй, даже если теоретически он мог бы пройти успешно. Однако использование window контекста может оказаться полезным, когда вы пытаетесь собрать данные из всей спецификации и затем использовать их в after() хуке.

Использование окружения

Этот подход похож на то, что часто делается в Postman. В Postman для хранения данных из запросов часто используется окружение. В Cypress можно использовать Cypress.env() для хранения любых данных, которые возвращает сервер. Вкратце, это выглядит следующим образом:

 cy
   .request('POST', '/api/boards', { name: 'new board' })
   .then(({ body }) => {

     Cypress.env('board', body)

   })

Пока это не слишком отличается от всего остального. На самом деле для использования Cypress.env() следует сделать еще несколько вещей:

  1. Создайте место для хранения в support/index.ts файле
  2. Создайте пользовательскую команду для вызова API
  3. Добавьте типы для пользовательских команд
  4. Добавьте типы для хранения.

Создание хранилища

Данные можно читать или извлекать, но главное здесь то, что у вас есть единое хранилище. В этом хранилище вы определяете, где должны быть размещены ваши данные. Так, все boards хранятся в boards массиве, lists находятся в lists массиве и т.д. Чтобы определить хранилище для вашего приложения, следует создать beforeEach() хук в support/index.ts файле и определить атрибуты Cypress.env() и их начальные значения:

beforeEach(() => {

  Cypress.env('boards', []);
  Cypress.env('lists', []);

});

Создание пользовательской команды для вызова API

Далее необходимо добавить запрос в качестве пользовательской команды:

Cypress.Commands.add('addBoardApi', (name) => {

  cy
    .request('POST', '/api/boards', { name })
    .then(({ body }) => {

      Cypress.env('boards').push(body)

    })

})

Теперь при каждом вызове пользовательской команды ответ на запрос будет сохраняться в boards массиве. Когда вам понадобится обратиться к этому хранилищу, вы сможете просто использовать его в своем коде следующим образом:

Cypress.env('boards')[0].id

Это позволит эффективно получить доступ к идентификатору форума. Однако это не полностью решает проблему ада колбеков, поскольку нельзя получить доступ к boards id только таким образом:

it('creates a list', () => {

  cy
    .addBoardApi('new board')

  cy
    .request('POST', '/api/lists', { title: 'new list', boardId: Cypress.env('boards')[0].id })

});

Это приведет к ошибке, поскольку наш Cypress.env('boards')[0].id по-прежнему будет undefined. Но использование пользовательской команды аналогично использованию .then() функции. Поэтому мы можем написать пользовательскую команду и для второго запроса. Поскольку у нас теперь есть хранилище, мы можем использовать его и искать в нашем хранилище нужный uuid:

Cypress.Commands.add('addListApi', ({ title, boardIndex = 0 }) => {

  cy
    .request('POST', '/api/lists', {
      boardId: Cypress.env('boards')[boardIndex].id,
      title,
    }).then(({ body }) => {
      Cypress.env('lists').push(body);
    });

});

Таким образом, можно ссылаться на board, используя индекс. Мы можем создать два boards в нашем тесте и добавить список непосредственно внутри второго board.

it('creates a list', () => {

  cy
    .addBoardApi('first board')
    .addBoardApi('second board')
    .addListApi({ title: 'new list', boardIndex: 1})

});

Наша пользовательская .addListApi() команда по умолчанию устанавливает boardIndex опцию в 0, нам даже не нужно добавлять ее вручную, если мы создаем только один board. По сравнению со всеми .then() функциями, это гораздо проще для восприятия.

Добавление типов для пользовательских команд

Возможно, вы уже заметили, что в большинстве тестов используется TypeScript. Вы можете ознакомиться с документацией по TypeScript, чтобы начать работу. Одним из приятных преимуществ использования TypeScript является то, что вы можете легко добавить определение типа команды. Это позволяет использовать автозаполнение Intellisense и помогает всем, кто будет использовать ваши пользовательские команды в будущем. Чтобы добавить их, нужно создать commands.d.ts файл:

declare namespace Cypress {
  interface Chainable {
    /**
     * creates a new board via API
    */
    addBoardApi(name: string): Chainable<Element>

    /**
     * Adds new list via API
    */
    addListApi(options: {
      title: string;
      boardIndex?: string;
    }): Chainable<Element>

  }
}

Добавление типов для хранилищ

В качестве завершающего штриха можно написать код, который позволяет добавлять собственные ключи окружения, которые будут появляться всякий раз, когда вы ссылаетесь на один из элементов хранилища в Cypress.env(). Этот код, по сути, расширяет типы для функции Cypress.env():

export { };

declare global {
  namespace Cypress {

    export interface Cypress {

      /**
       * Returns all environment variables set with CYPRESS_ prefix or in "env" object in "cypress.json"
       *
       * @see https://on.cypress.io/env
       */
      env(): Partial<EnvKeys>;
      /**
       * Returns specific environment variable or undefined
       * @see https://on.cypress.io/env
       * @example
       *    // cypress.json
       *    { "env": { "foo": "bar" } }
       *    Cypress.env("foo") // => bar
       */
      env<T extends keyof EnvKeys>(key: T): EnvKeys[T];
      /**
       * Set value for a variable.
       * Any value you change will be permanently changed for the remainder of your tests.
       * @see https://on.cypress.io/env
       * @example
       *    Cypress.env("host", "http://server.dev.local")
       */
      env<T extends keyof EnvKeys>(key: T, value: EnvKeys[T]): void;

      /**
       * Set values for multiple variables at once. Values are merged with existing values.
       * @see https://on.cypress.io/env
       * @example
       *    Cypress.env({ host: "http://server.dev.local", foo: "foo" })
       */
      env(object: Partial<EnvKeys>): void;

    }

  }
}

interface EnvKeys {
  'boards': Array<{
    created: string;
    id: number;
    name: string;
    starred: boolean;
    user: number;
  }>;
  'lists': Array<{
    boardId: number
    title: string
    id: number
    created: string
  }>;
}

Итоги

Этот паттерн фактически создает библиотеку тестирования, в которой все конечные точки API имеют пользовательскую команду, а ответы хранятся в Cypress.env() хранилище. Состояние теста описывается в beforeEach() хуке, а все остальное – в it() блоке. Это помогает получить четкое представление о том, что происходит перед тестом, а также внутри теста. В итоге получается код, который выглядит примерно так:

beforeEach(() => {

    cy
      .addBoardApi('hello board')
      .addListApi({ title: 'hello list' });

  });

  it('create a task', () => {

    cy
      .visit(`/board/${Cypress.env('boards')[0].id}`);

    cy
      .get('.List_addTask')
      .click();

    cy
      .get('.ListContainer .TextArea')
      .should('be.visible')
      .type('new task{enter}');

    cy
      .get('.Task')
      .should('be.visible');

  });

Перевод статьи «Working with API response data in Cypress».

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *