Источник: Nuances of Programming
В создании автоматизированных e2e-тестов для сложных систем есть много трудностей. Одна из них — “идеальное” окружение. Это окружение должно быть полностью под вашим контролем и включать многие (если не все) характеристики окружения разработки или продакшн-окружения.
На практике это часто не так. Окружение требует регулярного контроля, сброса данных и настройки требований для каждого теста “на лету”.
В случае онлайн-приложений, основанных на REST-архитектуре и запросах API, мы можем обойти эту проблему и приводить систему в необходимое состояние перед каждым тестом или набором тестов.
В этом примере я воспользуюсь Playwright с модулем запросов, который он предоставляет для отправки вызовов API.
Допустим, приложение, которое мы тестируем, представляет собой простой магазин с товарами. Для авторизации используется технология JWT (веб-токен в формате JSON). Следовательно, каждый последующий вызов после входа в систему должен включать данный токен.
Чтобы некоторые тесты сработали, на складе должен быть определенный продукт. Следовательно, пользователь должен сначала пройти аутентификацию, получить токен и воспользоваться им для пополнения запасов этого определенного продукта.
Подход в стиле “дешево и сердито”
Очевидным кажется такой способ действий: отправка вызовов внутри блока beforeAll, который выполняется перед всеми тестами в тестовом файле или наборе тестов. Вот как это сделать:
test.beforeAll(async (request) => {
let response = await request.post(`https://someapp.com/api/login`, {
headers: this.standardHeaders,
data: {
username: "USERNAME",
password: "PASSWORD",
},
})
let { accessToken, refreshToken } = JSON.parse(await response.text())
let setupCall = await request.post(`https://someapp.com/product/00001`, {
headers: {
authorization: `Bearer ${accessToken}`,
},
data: {
stock: 10,
},
})
if (setupCall.status() == 200) {
console.log("Product is now setup correctly")
}
})
test("Test that requires product with id 00001 in stock", async (request) => {
// ЗДЕСЬ ОТРАБАТЫВАЕТ САМ ТЕСТ
})
У этой стратегии есть несколько недостатков. Наиболее очевидный из них — неудобочитаемость, поскольку тест наполнен ненужным кодом, который имеет мало общего с его основной задачей. Вторая проблема — избыточность кода, поскольку функциональность добавления продукта потребует повторять данный код для каждого теста. Для проведения десяти тестов пользователь должен войти в систему десять раз, что приводит к потере времени и ресурсов и вызывает проблемы с производительностью.
Классический ООП-подход и проблемы с асинхронностью
Как и многие другие архитектурные проблемы, эту можно решить с помощью концепций ООП. Класс, который предоставляет данные для теста, обеспечивает стандартизированный, повторяемый и масштабируемый способ доступа к API.
Класс будет выглядеть примерно так:
class SetupCalls {
constructor(request) {
this.baseUrl = "https://someapp.com"
this.accessToken = null
this.refreshToken = null
}
async getToken(username, password) {
this.request = await request.newContext()
let response = await this.request.post(`${this.baseUrl}/api/login`, {
headers: this.standardHeaders,
data: {
username: username,
password: password,
},
})
let { accessToken, refreshToken } = JSON.parse(await response.text())
this.accessToken = accessToken
this.refreshToken = refreshToken
}
async setStock(productId) {
let setupCall = await this.request.post(`${this.baseUrl}/product/${productId}`, {
headers: {
authorization: `Bearer ${this.accessToken}`,
},
data: {
stock: 10,
},
})
if (setupCall.status() == 200) {
console.log("Product is now setup correctly")
}
}
}
Теперь, когда функции эффективно инкапсулированы, довольно просто понять, что делает каждая из них. Несколько вызовов можно выполнить с использованием одного и того же токена, поскольку он является общим для всего класса, что экономит время входа в систему. Посмотрим, как теперь выглядит тест:
test.beforeAll(async () => {
let setupCalls = new SetupCalls()
await setupCalls.getToken('John', 'john123')
await setupCalls.setStock("00001")
await setupCalls.setStock("00002")
await setupCalls.setStock("00003")
})
test("Test that requires product with id 00001 in stock", async (request) => {
// ЗДЕСЬ БУДЕТ ТЕСТ
})
Несмотря на то что реализация кажется превосходной, остаются некоторые проблемы. Пользователь должен понимать специфику работы класса setup, прежде чем использовать его. Если метод getToken не будет вызван раньше всех остальных, тесты завершатся неудачей, поскольку токен не будет заполнен.
Вызов токена getToken в идеале должен выполняться в конструкторе, однако конструктор не может быть асинхронной функцией. Таким образом, решением скорее всего может стать использование Promises.
constructor() {
this.baseUrl = "https://someapp.com"
let {accessToken, refreshToken} = Promise.resolve(getToken()).then(res => {
// сделать что-то с токеном
});
}j
Однако это изменяет нормальное поведение конструктора и требует тщательного сохранения контекста. Такой подход подрывает цель использования async-await, которая заключается в том, чтобы код был читабельным и простым.
Решение — ООП с фабричными функциями
Вместо традиционного конструктора мы можем инициализировать объект с помощью стандартной статической функции (метода) и использовать асинхронные методы внутри этой функции, не теряя ни одного из преимуществ последней реализации.
Статические методы вызываются не для экземпляров класса, а для самого класса. Превращение конструктора в приватный гарантирует, что пользователь инициализирует объект с помощью init, а не конструктора по умолчанию. С помощью метода init классу предоставляется только необходимая информация, такая как токен доступа, и с этой информацией создается новый экземпляр класса.
Взглянем на код:
class SetupCalls {
/**
* @private
*/
constructor(config) {
this.baseUrl = "https://someapp.com"
this.accessToken = config.accessToken
this.refreshToken = config.refreshToken
}
static async init(username, password) {
let config = await this.getToken(username, password)
return new SetupCalls(config)
}
async getToken(username, password) {
this.request = await request.newContext()
let response = await this.request.post(`https://someapp.com/api/login`, {
data: {
username: username,
password: password,
},
})
return JSON.parse(await response.text())
}
async setStock(productId) {....}
}
Теперь тест значительно лучше оптимизирован и использует асинхронность в любой ситуации:
test.beforeAll(async () => {
let setupCalls = await SetupCalls.init("John", "John1213!")
await setupCalls.setStock("00001")
})
test("Test that requires product with id 00001 in stock", async (request) => {
// TEST ITSELF BEGINS
})
Последние штрихи и улучшения
Часто можно настроить данные еще до запуска тестов. Флаг globalSetup в playwright.config.js позволяет указать местоположение для файла, который обрабатывает глобальную настройку:
const config = {
globalSetup: require.resolve("./globalSetup"),
testDir: "./tests",
timeout: 80 * 1000,
expect: {
timeout: 5000,
},
...
}
Теперь, когда мы создали класс, файл может вызвать его и настроить весь набор данных в одной функции. Мы даже можем настроить параллельный запуск всех вызовов, чтобы ускорить тесты, поскольку вызовы для продуктов не связаны между собой. Вот как будет выглядеть файл globalConfig.js:
module.exports = async (config) => {
let setupCalls = await SetupCalls.init(config.username, config.password)
let dataset = ["00001", "00002", "00003", "00004"]
const responses = await Promise.all(
dataset.map(async (id) => {
const res = await setupCalls.setStock(id)
})
)
console.log(responses)
}
Заключение
Если вы проводите тестирование API или просто используете API для подготовки данных или другой автоматизации, всегда полезно организовать вызовы таким образом, чтобы обеспечить гибкость при сохранении удобочитаемости и структуры.
Из-за своей асинхронной природы JavaScript иногда может сбивать с толку. Таким образом, простота выполнения при сохранении производительности может означать разницу между просто хорошим и отличным кодом.
Читайте также:
Перевод статьи Nikola Dimic: How To Structure API Calls for Automation Tests in Playwright and JavaScript