Как стать автором
Обновить
VK
Технологии, которые объединяют

Анализируем виды тестов для Frontend

Уровень сложностиПростой
Время на прочтение14 мин
Количество просмотров8.6K

С развитием веба сайты превратились в сложные приложения, которыми ежедневно пользуются десятки и сотни миллионов людей: почта, облачные хранилища, соцсети, маркетплейсы, стриминговые платформы и т. д. И каждое из них должно работать корректно. Как это сделать? Конечно писать хороший код, а потом и тестировать его. Хотя кто‑то обходится без тестов, тем не менее тестирование — важная часть инженерных практик наравне с мониторингом. Оно помогает нам заблаговременно находить и исправлять баги (или незапланированные фичи) в приложениях. Основная цель тестирования — получить гарантию корректной работы любого ПО .

При этом тестировать современный фронтенд сложно: неуправляемая асинхронность (событийная модель браузера), различие браузеров, тяжелое окружение — это лишь малая часть сложностей. Можно ли все возложить на ручных тестировщиков или исправлять баги после жалоб пользователей? Однозначно нет. В большинстве случаев такой подход в скором времени приведет к оттоку пользователей: не все пишут о багах, просто уходят к конкурентам. Безусловно, ручное тестирование остается важным элементом разработки, но тестировщики не могут держать сотни или тысячи сценариев, которые нужно пройти перед релизом или запуском новой фичи. Так где нам получить гарантии, что ключевые сценарии приложения работают корректно? Автоматическое тестирование.


Всем привет! Меня зовут Миша, работаю фронтэнд-разработчиком в VK в команде Облака Mail.ru, и я хочу разобрать различные виды тестов, дать их сравнительный анализ и применимость. Сразу скажу, тут не будет практики написания тестов. Потому что это нереально сделать внутри одной статьи, необходимо разобрать: теорию тестирования, классов эквивалентности, различие подходов/методов к тестированию, комбинаторику состояний, правильное использование моков и стабов, понимание чистых функций, знание архитектуры приложения. Поэтому предлагаю сконцентрироваться на видах тестирования и начать с «идеального теста».

Каким должен быть идеальный тест в вакууме

?️ Достоверным. Тест должен отражать текущее состоянии функциональности.

⚡️ Ускоряющим разработку (не значит что нужно использовать TDD). Его легко поддерживать, в случае необходимости просто написать новый тест.

? Давать быструю обратную связь. Быстро запускается в консоли/CI или любом другом окружении и возвращает понятный результат.

☝️ Покрывать только один случай использования. Важно выделить сценарии использования и тестировать их независимо.

? Проверять работоспособность, а не код (Black box). Такой подход позволяет тестировать именно работу функции. К тому же, минимизирует использование подмены реализации и, в идеальном случае, не затрагивает тесты при рефакторинге.

? Не зависеть от предыдущих тестов. Запуск теста должен давать один и тот же результат вне зависимости от порядка тестов и окружения.

Виды тестов

Тесты можно разделить на разные виды. В статье рассмотрю: модульные, интеграционные и e2e‑тесты. Это условная градация, но у них есть кое‑что общее. Они относятся к функциональным тестам.

Модульные тесты

Начнём с модульных тестов. Это самый доступный и быстрый вид с существенным недостатком — низкие гарантии работоспособности.

Некоторое время назад стали выделять еще один уровень тестирования: статическую типизацию. У неё, безусловно, есть свои плюсы. Она помогает найти опечатки, некоторые виды ошибок ( SyntaxError, TypeError, ReferenceError, и другие) при написании кода, при правильном подходе помогает выделять доменную область (исключая невозможные состояния приложения) но при этом никак в достаточно малой степени проверяет логику приложения (например, типами нельзя проверить сортировку или определить, что число является натуральным). А учитывая цели TypeScript типизация вообще не гарантирует работоспособность кода, в отличие от Rust с его системой типов. Хотя даже он не защищён от ошибок, достаточно посмотреть на issues популярной библиотеки tokio.

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

Тестировать свой код — правильная инженерная практика. Приведу пример, библиотека react‑use хорошо протестирована, её ~20 раз чаще скачивают с NPM, чем её аналог без тестов @uidotdev/usehooks. Главное тут не увлечься. В одной из прошлых команд мы соревновались в проценте покрытия кода тестами, включая даже синтетические случаи и активно пользовались директивой istanbul ignore. Покрытие кода доходило до ~ 90%, что не мешало нам ловить баги в проде.

Среда исполнения: 

  • Nodejs 

  • Deno

Инструменты:

  • Testing library + jsdom

  • Jest 

Когда использовать:

  1. Сложная логика.

  2. Библиотечный код.

  3. Важная бизнес-логика, от которой зависит работоспособность всего приложения.

Примеры

Код
/**
 * Тест на функцию
 */
declare function sortNumbersAscending<T>(data: Array<T>): Array<T>;

test("должен сохранить уже отсортированный массив", () => {
	expect(sortNumbersAscending([1, 2, 3])).toEqual([1, 2, 3]);
});
test("должен отсортировать случайно упорядоченный массив по возрастанию", () => {
	expect(sortNumbersAscending([2, 1, 3])).toEqual([1, 2, 3]);
});
test("должен отсортировать упорядоченный по убыванию массив в порядке возрастания", () => {
	expect(sortNumbersAscending([3, 2, 1])).toEqual([1, 2, 3]);
});

/**
 * Тест на основе свойств.
 * Пример взят из официальной документации fast-check.
 */
import fc from "fast-check";

declare function sortNumbersAscending<T>(data: Array<T>): Array<T>;

test("должен отсортировать числа в порядке возрастания", () => {
	fc.assert(
		fc.property(fc.array(fc.integer()), (data) => {
			const sortedData = sortNumbersAscending(data);
			for (let i = 1; i < data.length; ++i) {
				expect(sortedData[i - 1]).toBeLessThanOrEqual(sortedData[i]);
			}
		}),
	);
});
/**
 * Тесты на компонент
 */
import { render, screen } from "@testing-library/react";
import React from "react";
import { Avatar } from "@UI/avatar/component";

describe("Компонент Avatar: ", () => {
	it("Не должен появляться на странице, если не переданы поля email или name", () => {
		render(<Avatar />);

		expect(screen.queryByRole("img")).not.toBeInTheDocument();
	});

	it("Должен появляться на странице с атрибутом alt равный email", () => {
		const email = "asd@mail.ru";

		render(<Avatar title="avatar" email={email} name={email} />);

		expect(screen.queryByRole("img", { name: email })).toBeInTheDocument();
	});
});

/**
 * Тест на хук
 */
import { renderHook } from "@testing-library/react-hooks";
import useAvatar from "@UI/avatar/hook";
import { DEFAULT_URL } from "@UI/avatar/constants";

test("Должна обновляться ссылка на аватар", () => {
	const { result } = renderHook(() => useAvatar());

	expect(result.current.uri).toBe(DEFAULT_URL);
	expect(typeof result.current.updateUri).toBe("function");

	const newUri = "https://someUrl.svg";

	result.current.updateUri(newUri);

	expect(result.current.uri).toBe(newUri);
});

Заблуждения:

  1. Модульные тесты гарантируют полную функциональность.
    Окружение тестирования не защищено от багов и помимо этого сложно предсказать как функцию может использовать сторонний код — вариантов может быть множество.

  2. 100% 146% кодовой базы должно покрыто модульными тестами.
    Модульные тесты нацелены на проверку ключевой функциональности. Если же воспринимать их иначе, начинается погоня за достижением покрытия. Целью становится проверка синтетических случаев или написание большого количества моков/стабов, а не качество кода. CI начинает тормозить, приходится поддерживать большое количество тестов, теряется ключевое преимущество в виде скорости.

  3. Всегда использовать подмену окружения.
    Подмена через моки/стабы помогает нам реализовать часть внешнего окружения, чтобы проверить работает ли функциональность. Но при чрезмерном увлечении таким подходом код начинает обрастать «мусорными» функциями, теряя все плюсы тестирования. Намного проще сделать функцию максимально «чистой» или использовать внедрение зависимостей.

  4. Полный отказ от тестирования ошибок.
    Тестирование ошибок — важная часть процесса. Нужно проверять устойчивость новой фичи к различного рода воздействиям. Отказ от тестирования ошибок приводит к тому, что мы не знаем как ведет себя приложение при ошибках и потом с «радостью» наблюдаем их рост в Sentry.

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

Плюсы:

  1. Скорость. Модульные тесты покрывают небольшие или изолированные функциональности продукта. Поэтому можно запускать несколько десятков тестов в секунду.

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

  3. Минимальная инфраструктура. Для этого вида тестирования достаточно среды исполнения и тест раннера. Это позволяет достаточно безболезненно встраивать прогон тестов в CI.

  4. Документация. Если типизация помогает нам увидеть типы входных и выходных параметров функции, то тесты предоставляют возможность увидеть использование функции и их входных и выходных параметров в реальных сценариях.

  5. Высокая детерминированность. При использовании чистых функций, мы снижаем зависимость от окружения. Тем самым гарантируем воспроизводимость результата теста при многократном повторении.

Минусы:

  1. Низкие гарантии отсутствия ошибок. Даже при тестировании очень простого приложения нет гарантий, что все ошибки были найдены.

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

Интеграционные тесты

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

Давайте разбираться на примере. Возьмем форму авторизации. Применяя интеграционные тесты мы можем эмулировать поведение API и проверить необходимые нам детали: как интерфейс ведет себя в положительных/отрицательных сценариях. Для сравнения: если рассмотреть e2e-тесты (о них чуть позже), то для тестового сценария нам пришлось бы проделать немало шагов: поднять сервер (неважно локальный или удаленный), базу данных, позаботиться о безопасности нашего окружения (чтобы сервер не был доступен за пределами компании), добавить сертификаты, открыть нужные порты и т. д. Но создание полноценного окружения не является первостепенной задачей. Нам достаточно лишь небольшой части реального окружения — браузера. Важно помнить, чем меньше заглушек тем лучше.

Среда исполнения:

  • Nodejs 

  • Deno

  • Browser (в редких случаях)

Инструменты:

  • Jest

  • Testing library + jsdom

  • Playwright

  • Cypress

Когда использовать:

  1. Тестирование доменной области

  2. Тестирование UI и пользовательского сценария в изоляции бэкенда

Примеры

Код
/**
 * Использование @testing-library
 */
import * as React from "react";
import { render, screen, waitForElementToBeRemoved } from "test/app-test-utils";
import userEvent from "@testing-library/user-event";
import { create, setupServer } from "test/helpers";
import SingIn from "../sign-in";

const createUser = create((fake) => ({
	username: fake((f) => f.userName()),
	password: fake((f) => f.password()),
}));

const server = setupServer();

test("Компонент SignIn", () => {
	beforeAll(() => server.listen());
	afterAll(() => server.close());
	afterEach(() => server.resetHandlers());

	it("Должен проверять авторизацию пользователя", async () => {
		await render(<SignIn />);

		const { username, password } = createUser();

		userEvent.type(screen.getByLabelText(/username/i), username);
		userEvent.type(screen.getByLabelText(/password/i), password);

		userEvent.click(screen.getByRole("button", { name: /submit/i }));

		await waitForElementToBeRemoved(() =>
			screen.getByLabelText(/loading/i),
		);

		expect(screen.getByText(username)).toBeInTheDocument();
	});
});

/**
 * Использование cypress + @testing-library
 */
import { create, setupServer } from "test/helpers";
import SingIn from "../sign-in";

const server = createServer();

const createUser = create((fake) => ({
	username: fake((f) => f.userName()),
	password: fake((f) => f.password()),
}));

test("Компонент SignIn", () => {
	before(() => server.listen());
	after(() => server.close());
	afterEach(() => server.resetHandlers());

	it("Должен проверять авторизацию пользователя", () => {
		mount(<SingIn />);
		const { username, password } = createUser();

		// Функции доступные при подключении '@testing-library/cypress'
		cy.findByLabelText(/username/i).type(username);
		cy.findByLabelText(/password/i).type(password);

		cy.get("button[type=submit]").click();

		expect(cy.findByLabelText("loading")).not.to.be.visible;

		findByText(username).should("be.visible");
	});
});

Заблуждения:

  1. Всё окружение изолировано. Современные инструменты тестирования можно использовать с частично реальным окружением, а «тяжелые» части (бэкенд сервиса) подменять заглушками.

  2. Полагаться на внутреннюю реализацию. Раньше стандартом интеграционных тестов был enzyme с возможностью посмотреть на внутреннее состояние компонентов. Но после рефакторинга или простого переименования внутренних переменных зачастую приходилось тесты переписывать.

  3. Полагаться на интеграционные тесты как на 100% гарант. Этот вид тестов может создать ложное чувство безопасности, если исключить e2e-тестирование поскольку мы используем моки/стабы. В итоге рискуем получить ситуацию когда тесты зеленые, а функциональность не работает. Поэтому ключевые сценарии лучше положить на откуп e2e‑тестам.

Плюсы:

  1. Баланс между скоростью и трудозатратами. Благодаря тому, что эти тесты используют часть реального окружения, мы можем получить ответ быстрее, чем в e2e‑тестах, но медленнее чем в модульных тестах.

  2. Обнаружение ошибок интеграции. Этап интеграции охват работу нескольких функций в комплексе, что помогает удостовериться в том, что основная функциональность приложения работает корректно.

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

Минусы:

  1. Более сложная инфраструктура. Для интеграционных тестов необходима более тонкая настройка окружения: изоляция запросов на CI, настройка моков/стабов, выделение дополнительной памяти и настройка инструментов тестирования.

  2. Сложный поиск ошибок. Если в модульных тестах легко определить функциональность, в которой произошла ошибка, то тут дело обстоит иначе. Ошибку вида «Элемент отсутствует в списке» сложнее найти, чем «undefined is not a function» при вызове единственной функции.

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

  4. Низкая детерминированность. Таймеры, событийная модель, асинхронность — всё это источники недетерминированности. Для предоставление гарантий работоспособности мы вынуждены надстраивать различные моки/стабы и возможность перезапуска тестов.

E2e-тесты

Е2е-тест использует реальное окружение и максимально приближен к готовому пользовательскому сценарию: никаких заглушек. Этот вид тестов дает максимальные гарантии, но у него есть и недостатки. Сейчас в моем проекте достаточно много e2e‑тестов, даже если их запускать в несколько потоков они выполняются больше 30 минут.

Если разобрать предыдущий пример, описанный в интеграционных тестах. То для его реализации, нам нужно выделить отдельные тестовые стенды со всей инфраструктурой и еще это все надо поддерживать. После прогона тестов, нужно разбираться, что пошло не так, это код «сломался» или часть стенда не работает. Но самая большая проблема это ошибки: очень сложно понять что пошло не так, не всегда достаточно посмотреть на результат теста и понять: «ага, это проблема точно в функции валидации пользователя». E2е-тесты используют в самых важных сценариях. 

Среда исполнения: 

  • Browser (возможно headless)

Инструменты:

  • Playwright

  • Cypress

  • Puppeteer

Когда использовать:

  1. Критическая функциональность.

  2. Интеграция внешних сервисов.

Примеры

Код
import { createUser } from "@/api/user";

describe("Авторизация", async () => {
	const { username, password } = await createUser();

	before(() => {
		cy.visit("https://example.com/signup");
	});

	it("Должен проверять авторизацию пользователя", () => {
		cy.findByLabelText(/username/i).type(username);
		cy.findByLabelText(/password/i).type(password);

		cy.get("button[type=submit]").click();

		expect(cy.findByLabelText("loading")).not.to.be.visible;

		findByText(username).should("be.visible");
	});
});

Заблуждения:

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

  2. Полный отказ от регрессионного тестирования тестировщиком. Из‑за большой комбинаторики состояний приложения покрытие 100% этих случаев практически невозможно. В таком случае не помощь приходят регрессионное тестирование. Оно позволяет выявлять критические сценарии использования и вручную тестировать именно их.

Плюсы:

  1. Самые высокие гарантии работоспособности продукта. Запуск в условиях, приближенных к реальным, и с учетом различных браузеров — всё это повышает гарантии работоспособности, когда приложение выйдет в прод.

  2. Сокращение общего времени на тестирование. Е2е‑тесты помогают тестировщикам при добавлении новой функциональности автоматизировать часть пользовательских сценариев.

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

Минусы:

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

  2. Медленные. При е2е-тестах мы должны проверять работу новой функциональности в среде, которая максимально приближена к реальной. Подготовка окружения, вызовы к API и другим элементам, ответы сервера — всё это затягивает процесс тестирования.

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

  4. Низкая детерминированность. Выше я уже писал о низкой детерминированности в интеграционных тестах. Хочу отметить, что в е2е-тестах мы выходим уровень взаимодействия бэкенда и фронтенда в максимально приближенных к боевым условиям. Несложно догадаться, что уровень детерминированности снижается многократно: сбои сети, некорректное создание пользователя, проблемы в инфраструктуре и это только малый пласт возможных проблем. 

Сравнительная таблица разных видов тестирования

Параметры сравнения

Модульные 

Интеграционные 

e2e

Цель 

Изолированные функции 

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

Пользовательский сценарий

Среда исполнения 

NodeJS 

NodeJS и браузер

Браузер

Гарантии (прямо пропорциональная зависимость)

⚖️

⚖️⚖️

⚖️⚖️⚖️

Скорость (прямо пропорциональная зависимость)

⚡️⚡️⚡️

⚡️⚡️

⚡️

Детерминированность (прямо пропорциональная зависимость)

🎯🎯🎯

🎯🎯

🎯

Стабильность (прямо пропорциональная зависимость)

🦾🦾🦾

🦾🦾

🦾

Время (обратно пропорциональная зависимость

⌛️

⌛️⌛️

⌛️⌛️⌛️

Сложность нахождения ошибок (обратно пропорциональная зависимость)

🎡

🎡🎡

🎡🎡🎡

Изоляция (прямо пропорциональная зависимость)

😷😷😷

😷😷

😷

Необходимая инфраструктура (прямо пропорциональная зависимость)

🏗️

🏗️🏗️

🏗️🏗️🏗️

Поддерживаемость (прямо пропорциональная зависимость)

🧶🧶🧶

🧶🧶

🧶

Резюме

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

Что пропустил

Скриншотные тесты стоят особняком и их нужно использовать на этапе модульного тестирования компонента/виджета или интеграционного тестирования страниц. Они нацелены проверку визуальной составляющей. Например, есть важная особенность — различие браузеров. У каждого крупного продукта есть список поддерживаемых браузерных версий, но выходят новые версии и движки рендеринга обновляются. Даже несмотря на наличие различных web‑стандартов, нельзя на 100% быть уверенным, что все продолжит отображаться идентично.

Снепшотные тесты применяются в основном на этапе модульного тестирования функции или компонента. Основная их цель предоставить слепок текущего результирующего состояния. Например, есть очень большой слепок результата выполнения функции и вы изменили его. Ваши коллеги на ревью увидели помимо вашего исправления много лишних строчек кода, хотя реальное изменение занимает 4.В современных инструментах для этого есть API, например функция toHaveProperty в Jest.

Полезные ссылки:

Теги:
Хабы:
Всего голосов 33: ↑33 и ↓0+33
Комментарии2

Публикации

Информация

Сайт
team.vk.company
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия