Разделы портала

Онлайн-тренинги

.
Работа с ответами API в Cypress
14.09.2021 00:00

Автор: Филип Рик (Filip Hric)
Оригинал статьи
Перевод: Ольга Алифанова

Краткое содержание: код Cypress выполняется блоками. Чтобы использовать данные оттуда, можно использовать команду then(), mocha-алиасы, объекты окна или переменные окружения. Я создал паттерн с использованием переменных окружения, и покажу его во второй части этой статьи. Мое приложение и этот паттерн можно найти на GitHub. Для обсуждения присоединяйтесь к серверу Discord.

Дело обстоит так: в начале вашего теста вы вызываете конечную точку 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)
 
})

Затем запустится эта часть (посмотрите, что происходит с переменной 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)
 
})

And finally this part:

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(). Поэтому, если мы хотим создать новый список внутри доски, нам нужно написать следующий код:

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


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

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


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

Можно видеть, что код становится тяжелее читать. Один из способов избежать ада коллбэков в 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 мы используем обычный синтаксис вместо стрелочной функции. Это связано с тем, что это ключевое слово нельзя использовать со стрелочными функциями.

Объект окна

Другой способ передавать данные – это воспользоваться объектом окна вашего браузера. Это позволяет делиться данными между тестами:

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


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

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

Этот подход схож с тем, что часто делается в Postman. Там вы часто используете окружение для хранения данных из запросов. Лично я пользуюсь 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. Добавить типы для хранилища.

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

Идея создания "хранилища данных" пришла ко мне в ходе работы над моим приложением-клоном Trello. Оно создано на Vue, использующем объект данных, в котором хранятся все данные приложения. Данные можно считывать или получать, но суть в том, что у вас есть единое хранилище. В этом хранилище вы определяете, куда разместить ваши данные. Все доски хранятся в массиве boards, списки – в lists, и т. д. Для определения хранилища в приложении я создал хук beforeEach() в файле support/index.ts, и задал атрибуты для Cypress.env() и их начальных значений:

support/index.js

beforeEach(() => {
 
     Cypress.env('boards', []);
     Cypress.env('lists', []);
 
});

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

Затем я добавил свой запрос как кастомную команду.

support/commands/addBoardApi.ts

Cypress.Commands.add('addBoardApi', (name) => {
 
    cy
        .request('POST', '/api/boards', { name })
        .then(({ body }) => {
 
            Cypress.env('boards').push(body)
 
        })
 
})
 

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

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

Это даст мне доступ к id моей доски. Однако это неполное решение проблемы ада коллбэков, потому что я не смогу получить 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 будет все еще не определено. Однако использование кастомных команд похоже на использование функции .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);
    });
 
});

В этом случае мы можем ссылаться на нашу доску, используя указатель. Мы можем создать две доски в ходе теста, и добавить список во вторую из них.

it('creates a list', () => {
 
     cy
         .addBoardApi('first board')
        .addBoardApi('second board')
        .addListApi({ title: 'new list', boardIndex: 1})
 
});

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

Добавление типов к кастомным командам

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

support/@types/commands.d.ts
declare namespace Cypress {
     interface Chainable {
          /**
          * создает новую доску через API
           */
           addBoardApi(name: string): Chainable<Element>
 
            /**
            * Добавляет новый список через API
             */
           addListApi(options: {
               title: string;
               boardIndex?: string;
            }): Chainable<Element>
 
          }
}

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

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

support/@types/env.d.ts

export { };
 
declare global {
    namespace Cypress {
 
         export interface Cypress {
 
                  /**
                 * Возвращает все переменные окружения, заданные с префиксом CYPRESS_ или в объекте "env" в "cypress.json"
                 *
                 * @see https://on.cypress.io/env
                 */
                 env(): Partial<EnvKeys>;
                 /**
                 * Возвращает конкретную переменную окружения или 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];
                /**
               * Задает значение для переменной.
               * Любое значение, которое вы измените, будет изменено навсегда для всех остальных тестов
              * @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;
 
              /**
            * Одновременно задает значения нескольким переменным. Значения сливаются с существующими значениями.
            * @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(() => {
 
     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');
 
});

Я подготовил тестовое состояние в хуке beforeEach(), а все остальное находится в блоке it(). Это позволяет ясно видеть, что происходит до теста, а что – внутри него. Возможно, я создам кастомную команду и для .visit(), потому что открытие доски – частое действие, для которого мне нужен id доски. Но об этом – в другой раз.

Обсудить в форуме