Принципы написания автотестов
easy

Принципы написания автотестов

Идеального автотеста не существу...

Рассказываем и показываем на конкретных примерах, как писать хорошие автотесты. В основном эти принципы применимы к сквозным тестам на JavaScript или TypeScript и соответствующим фреймворкам, но большинство будет полезно всем, кто пишет автотесты независимо от языка программирования.

Никаких тестов без утверждений

Утверждения — это проверки, которые позволяют удостовериться, что результат работы кода соответствует ожиданиям. Тест без утверждений не может гарантировать, что проверяемая функциональность работает как нужно. Поэтому в тесте не должно быть этапов без проверок. 

❌ Здесь ничего не происходит:

test("Должно открыться меню", async () => {
  await page.locator('.button').click(); // после выполнения этой строки предполагается, что на странице должно открыться меню
});

После имитации клика по кнопке нет проверки состояния веб-страницы, чтобы подтвердить, что меню действительно открылось. Без утверждения тест не может проверить ожидаемый результат и поэтому не в состоянии дать информацию об успешности или неудаче проверяемого сценария. Такой тест не несёт ценности в текущем виде и требует дополнения проверками после выполнения действия клика.

✅ Вот как должно быть:

test("Должно открыться меню", async () => {
  await page.locator('.button').click();
  const locator = await page.locator('.dropdown-menu');
  await expect(locator).toBeVisible();
});

Никаких утверждений в хуках before и after

Хуки beforeAll, beforeEach, afterAll и afterEach позволяют подключаться к определённым точкам жизненного цикла тестов и выполнять код в эти моменты. Хук beforeAll выполняется один раз перед запуском в файле или блоке тестов, а afterAll — после запуска, например сначала для настройки, а затем для очистки глобальных ресурсов. Хук beforeEach выполняется перед каждым отдельным тестом, а afterEach — после, например сначала для настройки состояния или создания объектов, которые будут использоваться, а затем для их очистки или удаления. 

❌ Неправильно помещать в эти хуки какие-либо утверждения:

describe('Набор тестов', () => {
  beforeAll(() => {
    const setupResult = setupFunction();
    expect(setupResult).toBe(true);
  });

✅ Предусловия и постусловия должны содержать только чистые действия, например авторизацию. Проверки должны выполняться непосредственно внутри тестов. Если очень нужно проверить что-то в предусловиях или постусловиях, используют try…catch и/или throw:

describe('Набор тестов', () => {
  let setupResult;

  beforeAll(() => {
    try {
      setupResult = setupFunction();
    } catch (error) {
      throw new Error(`beforeAll hook failed: ${error.message}`);
    }
  });

  // какие-то тесты
});

Никаких действий без ожиданий

Клики, наведения, переходы и другие действия должны быть обёрнуты в утверждения для проверки того, что они были выполнены правильно. 

❌ Здесь ничего не проверяется:

test("Должно открыться меню", async () => {
  await page.locator('.button').click();
  await page.locator('.dropdown-menu').waitFor({ state: 'visible' });
});

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

test("Должно открыться меню", async () => {
  await page.locator('.button').click();
  const locator = await page.locator('.dropdown-menu');
  await expect(locator).toBeVisible();
});

Никаких безусловных ожиданий

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

❌ Проблема в том, что паузы замедляют тесты:

it('Должно открыться меню', async () => {
  const button = await $('.button');
  await button.click();
  await browser.pause(3000);
  const menu = await $('.dropdown-menu');
  await menu.isDisplayedInViewport();
});

✅ Лучше использовать ожидание для состояния какого-то элемента. Так тест будет выполняться быстрее, а если что-то пойдёт не так, то тест провалится по очевидным и однозначным причинам:

it('Должно открыться меню', async () => {
  const button = await $('.button');
  await button.click();
  const menu = await $('.dropdown-menu');
  await menu.waitForExist({timeout: 3000});
  await menu.isDisplayedInViewport();
});

Никаких закомментированных тестов

Комментарии нужны для пояснения кода, чтобы в нём было легче разобраться — другому разработчику или даже автору спустя время после написания. Но многие из нас не раз видели, что неиспользуемый код комментируется и остаётся в программе. Вот к чему это приводит:

  • становится труднее понять, что делает тест;
  • код сложнее поддерживать;
  • при актуализации кода могут вылезти ошибки, если раскомментировать неиспользуемые фрагменты;
  • увеличивается размер программы.

❌ Комментировать ненужный код в тесте неправильно:

// test("Должно быть меню", async () => {
//  const locator = await page.locator('.dropdown-menu');
//  await expect(locator).toBeVisible();
// });

✅ Если тест нужно отключить, следует пропустить его с помощью функции тестовой среды, например skip:

test.skip("Должно быть меню", async () => {
 const locator = await page.locator('.dropdown-menu');
 await expect(locator).toBeVisible();
});

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

Никаких подвисших локаторов

Код в тестах должен выполнять действия или утверждения либо и то и другое. Строк кода с «бессмысленными» локаторами быть не должно.

❌ В этом фрагменте кода локатор ничего не делает:

test("Что-то происходит", async () => {
 await page.locator('.button');
 …

Никаких операторов IF внутри тестов

Условные операторы в тестах приводят к пропуску ошибок. Выполнение разных проверок в зависимости от поведения программы усложняет тесты и делает их менее надёжными.

❌ Вот пример теста с проверками разных вещей в зависимости от условий всплывающего окна:

test("Должен быть попап", async () => {
  const button = await $('.button');
  await button.click();
  const popUpisVisible = await $('.pop-up').isVisible();
  if (popUpisVisible) {
     const checkedLocator = await $('.one-locator').isChecked();
     await expect(checkedLocator).toBeTruthy();
  } else {
     const checkedAnother = await $('.another-locator').isChecked();
     await expect(checkedAnother).toBeTruthy(); 
  }
});

В случае если этот тест завершится неудачно, придётся разбираться, какой именно условный оператор был запущен. Кроме того, можно пропустить ошибку, если не удалось проверить функциональность невызванной проверки.

✅ Если нужно провести А/B-эксперименты и интерфейс может меняться случайным образом, то лучше делать так:

  • Подготовить состояние интерфейса в предусловиях.
  • Разделить тесты для каждого состояния интерфейса.

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

Никаких утверждений внутри объектных моделей страниц

Объектная модель страницы, или Page Object Model, — это общий шаблон проектирования в автотестах, который упрощает обслуживание тестов и уменьшает дублирование кода. POM инкапсулирует общие операции с тестируемой страницей или хранит все веб-элементы. 

❌ Неправильно смешивать взаимодействия в объектных моделях страниц с проверками (утверждениями) в самих тестах. Это приводит к смешиванию логики взаимодействия с интерфейсом и логики проверки условий теста, ухудшает читаемость и поддерживаемость кода тестов. Когда утверждения перемешаны с методами объектной модели страниц, это делает классы страниц менее универсальными и усложняет их повторное использование в разных тестовых сценариях.

✅ Правильно — выполнять взаимодействия на странице через объектные модели, а затем проводить проверки внутри теста с помощью expect.

Каждый этап тестирования должен проверять что-то одно (а не всё сразу)

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

❌ Неправильно помещать более одного или двух утверждений в один шаг теста и пытаться сделать всё или проверить всё за раз.

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

Тесты с множеством утверждений в одном шаге труднее понимать и поддерживать. Шаги теста, проверяющие несколько условий, сложно повторно использовать в других тест-кейсах, так как они специфичны для конкретного сценария. А изменения в одном утверждении могут потребовать переработки всего теста.

✅ Правильно разбивать тесты на маленькие шаги. Чем больше атомарных этапов тестирования, тем понятнее отчёты и журналы испытаний.

Никаких await внутри expect

В некоторых фреймворках тесты пишутся с использованием expect, который предназначен для синхронной проверки утверждений. Но если тестируют асинхронный код, может понадобиться использовать await для ожидания результата промисов перед проверкой утверждения с expect. Получается, что синхронная проверка содержит асинхронную операцию. Это может привести к путанице и нестабильности теста.

❌ Одна операция внутри другой операции приводит к усложнению и путанице в тестах:

test("Должно быть название кнопки", async () => {
  expect(await page.locator('.button')).toHaveText(/Меню/);
});

// или:
test("Должно быть название кнопки", async () => {
  await expect(page.locator('.button')).toHaveText(/Меню/);
});

✅ Нужно сначала дождаться выполнения асинхронной функции с помощью await и после этого передать результат в expect для синхронной проверки:

test("Должно быть название кнопки", async () => {
  const button = await page.locator('.button');
  
  await expect(button).toHaveText(/Меню/);
});

Не перезагрузить страницу, а открыть заново

Если при тестировании нужно обновить страницу, то перезагрузка командами типа page.reload бывает хуже, чем открытие страницы заново. 

При перезагрузке страницы браузер может сохранить состояние, включая кэш, cookies, локальное хранилище и другие данные сессии, особенно если выполнялись какие-то действия, например заполнение форм или навигация по страницам. При открытии страницы заново на сервер посылаются новые запросы и можно получить более актуальное состояние страницы. Перезагрузка может длиться дольше открытия страницы заново, особенно если на ней много динамического контента или сложные скрипты. Открытие страницы заново часто быстрее и эффективнее.

❌ Перезагрузка страницы делает тест менее надёжным:

test("Что-то должно произойти после перезагрузки", async () => {
  await page.reload();
  …
});

✅ Для надёжности теста нужно получить адрес текущей страницы и просто открыть его:

test("Что-то должно произойти после загрузки", async () => {
  const uri = await page.url();
  await page.goto(uri);
  // …
});

Не проверять URL-адреса через включения

Если нужно проверить адрес страницы, лучше не использовать команду string.prototype.includes(), потому что include() возвращает true или false. Если проверка не будет пройдена, отчёт будет говорить только то, что false не соответствует действительности — без подробностей.

❌ Такой тест даст мало информации:

test("Должен быть соответствующий URL", async () => {
 const uri = await page.url();
 await expect(uri.includes('https://thecode.media/')).toBeTruthy();
});

✅ Лучше использовать такой метод:

test("Должен быть соответствующий URL", async () => {
 const uri = await page.url();
 await expect(uri).toHaveURL(/https://thecode.media/);
});

✅ Если нужно произвести необычные проверки, используют встроенные утверждения:

test("Должен быть соответствующий URL", async () => {
 const uri = await page.url();
 await expect(uri).toEqual(expect.stringContaining('https://thecode.media'));
});

Этот шаблон применяется для проверки любых строк и влияет на читаемость и ясность отчётов об испытаниях.

Избегать регулярных выражений в проверках

Проверки с помощью регулярных выражений делают тесты слишком чувствительными и не добавляют им особой надёжности, зато затрудняют анализ после сбоев. Например, тесты могут провалиться из-за неважных или незначительных изменений. При анализе будет нужно разбираться, действительно ли сбой вызван проблемой в коде или же это просто излишняя строгость регулярного выражения.

Есть два исключения:

  • регулярное выражение для проверки URL-адресов;
  • регулярное выражение для даты и времени.

Оба типа данных такого рода подходят для проверки регулярным выражением.

Бывает, что проект тестирования включает идентификаторы определённого домена, которые можно отнести к какому-то шаблону, например номера счетов, идентификаторы товаров, коды пользователей и так далее. В этом случае допустимо тестировать их с помощью регулярного выражения.

Клики и ожидания должны быть обёрнуты в промисы

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

❌ Этот код ожидает ответа до завершения действия клика:

await page.locator('.button').click();
const response = await page.waitForResponse('https://thecode.media/');
await expect(response.ok()).toBe(true);

✅ Здесь используется промис для параллельного выполнения клика и ожидания ответа. Это гарантирует, что ожидание ответа начинается одновременно с кликом:

const [response] = await Promise.all([
 page.waitForResponse('https://thecode.media/'),
 page.locator('.button').click(),
]);

await expect(response.ok()).toBe(true);

Не использовать глобальные переменные для методов Page Object

Если использовать глобальные переменные при тестировании интерфейса и использования паттерна Page Object, это может привести к проблемам. Глобальные переменные могут быть изменены в любом месте тестового набора, из-за чего сложно отслеживать изменения их состояния. Результат — ошибки, которые трудно обнаружить и исправить, поскольку они могут проявляться далеко от места, где переменная была изменена.

Глобальные переменные создают скрытые зависимости между тестами, что нарушает идею их изоляции. С ростом количества тестов поддержка кода с глобальными переменными становится более сложной. При попытке параллелизации тестов с глобальными переменными разные тесты могут одновременно пытаться изменить одну и ту же переменную.

❌ Глобальные переменные в PO — плохо:

const myPageObject = new MyPageObject(page);

test('Что-то должно быть сделано, async () => {
 await myPageObject.doSomething();
 …
});

test('Что-то должно быть', async () => {
 await myPageObject.haveSomething();
 …
});

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

test('Что-то должно быть сделано', async () => {
 const myPageObject = new MyPageObject(page);
 await myPageObject.doSomething();
 …
});

test('Что-то должно быть', async () => {
 const myPageObject = new MyPageObject(page);
 await myPageObject.haveSomething();
 …
});

Не разносить проверки по нескольким сценариям

Везде должна проверяться одинаковая функциональность. Например, вместо теста test-1.spec.ts на проверку баннера через «ожидание А» и теста test-2.spec.ts на проверку того же баннера через «ожидание Б», но на другой странице, его нужно тестировать по обоим ожиданиям A и Б в каждом из тестов.

❌ В этом коде проверки разнесены (и это печально):

test-1.spec.ts

test.describe('Баннер A', async () => {
 test('У баннера должен быть заголовок', async () => {
   …
 });
});

test-2.spec.ts

test.describe('Баннер Б', async () => {
 test('У баннера должна быть картинка', async () => {
   …
 });
});

✅ А здесь всё правильно:

test-1.spec.ts

test.describe('Баннер A', async () => {
 test('У баннера должен быть заголовок', async () => {
   …
 });

 test('У баннера должна быть картинка', async () => {
   …
 });
});

test-2.spec.ts

test.describe('Баннер Б', async () => {
 test('У баннера должен быть заголовок', async () => {
   …
 });

 test('У баннера должна быть картинка', async () => {
   …
 });
});

Не смешивать разные виды тестов

Если нужно проверить API и пользовательский интерфейс на одно действие, проводят два теста: тест API и тест UI.

Если вы нужно одновременно проверить работоспособность интерфейса и проверить макет по скриншоту, делают два теста: интерфейса и макета.

Если нужно одновременно проверить сценарии сквозного API и JSON-схемы, делают два интеграционных API-теста, каждый из которых выполняет определённые проверки.

Идентификаторы тестов должны быть уникальными

На тестовой странице должен находиться только один селектор с определённым атрибутом ID. Если используются тестовые идентификаторы типа data-testid, они должны быть уникальными для страницы, а ещё лучше — для всего сайта.

❌ Так делать неправильно:

<div class="block_element__modificator" role="banner" data-testid="banner-element"></div>
…
<div class="block_element__modificator view-more" data-testid="banner-element"></div>

✅ Надо делать так:

<div class="block_element__modificator" role="banner" data-testid="banner-element-one"></div>
…
<div class="block_element__modificator view-more" data-testid="banner-element-more"></div>

Использовать линтеры и форматтеры из тестируемого (родительского) проекта

Тесты должны наследовать линтер и правила форматирования от тестируемого (родительского) проекта. Неважно, находится ли каталог с тестами внутри проекта тестирования, или тесты лежат в отдельном репозитории; неважно также, написаны ли тесты инженерами по автотестированию или разработчиками. Тесты будут ближе к коду, а тестировщики будут ближе к разработчикам, если все будут использовать одинаковые правила.

Автор:

Андрей Енин

Обложка:

Алексей Сухов

Корректор:

Ирина Михеева

Вёрстка:

Маша Климентьева

Соцсети:

Юлия Зубарева

Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию
Еще по теме
easy