Пирамида тестирования — как экономить на багах

Что это и как применяется в разработке ПО

Пирамида тестирования — как экономить на багах

Рассказываем про одну интересную штуку про тестирование. Если вы пока не знакомы с этой темой, вот что можно почитать для начала:

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

Что такое пирамида тестирования

Тестирование — это проверка программы на то, чтобы она работала без поломок и в соответствии с задумками архитекторов и бизнес-заказчиков.

Пирамида тестирования — это подход, который разделяет разные типы автотестов. Визуально она выглядит примерно так:

Источник: headspin.io

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

Основная концепция и история возникновения

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

  • Юнит-тесты.
  • Интеграционные тесты.
  • Системные (сквозные, или end-to-end-тесты).

Впервые про пирамиду тестирования написал американский программист Майк Кон. Первая концепция немного отличалась от современной, потому что задумка была просто в том, чтобы автоматизировать тесты. Сейчас ключевой смысл — в подходящих пропорциях разных уровней тестирования.

Бонус для читателей

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

Зачем нужна пирамида тестирования в разработке

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

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

Уровень интеграционных тестов дороже, потому что сложнее.

Системные тесты самые дорогие, поэтому их должно быть меньше всего.

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

Уровни тестирования программного обеспечения согласно пирамиде 

Тема тестов сложная, поэтому мы будем раскрывать её постепенно.

Сначала — общее описание тестов разных уровней.

Unit-тесты

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

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

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

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

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

Это проверка взаимодействия разных частей программы. 

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

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

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

E2E-тесты

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

Получается, что E2E-тесты — это что-то вроде склейки между тестами предыдущих уровней. Мы собираем всё, что уже отслеживается юнит- и интеграционными тестами, и смотрим, как все части работают вместе.

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

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

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

Программа такая:

# подключаем модуль для разбора аргументов командной строки
import argparse
# подключаем библиотеку для http-запросов
import requests

# функция получает url и возвращает поле "title" из json-ответа
def fetch_title(url: str) -> str:
   # выполняем get-запрос по указанному адресу с таймаутом
   r = requests.get(url, timeout=2)
   # выбрасываем исключение, если сервер вернул ошибку
   r.raise_for_status()
   # парсим json и возвращаем значение по ключу "title"
   return r.json()["title"]

# точка входа в программу
def main() -> None:
   # создаём парсер аргументов командной строки
   p = argparse.ArgumentParser()
   # объявляем обязательный позиционный аргумент url
   p.add_argument("url")
   # разбираем аргументы, переданные при запуске
   args = p.parse_args()
   # вызываем функцию и печатаем результат в консоль
   print(fetch_title(args.url))

# проверяем, что файл запущен напрямую, а не импортирован
if __name__ == "__main__":
   # запускаем основную функцию
   main()

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

Что делает наше мини-приложение:

  • Принимает URL из командной строки.
  • Делает запрос по этому адресу.
  • Ожидает, что ответ придёт в формате JSON.
  • Берёт из JSON поле "title" и печатает его в консоли.

При старте нужно обязательно указать URL-адрес. Поэтому запустить программу можно двумя способами. 

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

python3 -m pip install requests

После этого — запустить программу:

python3 app.py https://jsonplaceholder.typicode.com/posts/1

Иногда вместо python3 нужно написать просто python.

Если работаете в среде разработки IDE, эту же команду можно выполнить в терминале:

В обоих случаях должен прийти один ответ:

sunt aut facere repellat provident occaecati excepturi optio reprehenderit

Unit-тестирование: основа качества кода

Так выглядит юнит-тест, который проверяет одну логическую часть программы: функцию def fetch_title().

# импортируем mock для подмены зависимостей
from unittest.mock import Mock
# импортируем тестируемый модуль
import app

# тест проверяет корректность обработки json-ответа
def test_fetch_title_parses_json():
   # создаём фейковый объект ответа
   resp = Mock()
   # задаём, что метод json() вернёт заранее известные данные
   resp.json.return_value = {"title": "Hello"}
   # подменяем raise_for_status, чтобы он ничего не делал
   resp.raise_for_status.return_value = None

   # подменяем requests.get внутри модуля app
   app.requests.get = Mock(return_value=resp)

   # проверяем, что функция возвращает ожидаемое значение
   assert app.fetch_title("http://does-not-matter") == "Hello"

Что проверяет тест:

  • Функция действительно вызывает json().
  • Функция берёт значение по ключу "title".
  • Функция возвращает это значение.

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

Интеграционное тестирование: проверка взаимодействия компонентов

Интеграционный тест выглядит намного сложнее и длиннее.

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

# импортируем json для формирования ответа сервера
import json
# импортируем модуль для работы с сокетами
import socket
# импортируем модуль для запуска внешних процессов
import subprocess
# импортируем sys для получения пути к python
import sys
# импортируем threading для запуска сервера в отдельном потоке
import threading
# импортируем базовые классы http-сервера
from http.server import BaseHTTPRequestHandler, HTTPServer

# обработчик http-запросов для тестового сервера
class Handler(BaseHTTPRequestHandler):
   # обрабатываем get-запрос
   def do_GET(self):
       # формируем json-ответ
       body = json.dumps({"title": "From local server"}).encode("utf-8")
       # отправляем статус 200
       self.send_response(200)
       # отправляем заголовок content-type
       self.send_header("Content-Type", "application/json")
       # отправляем длину тела ответа
       self.send_header("Content-Length", str(len(body)))
       # завершаем заголовки
       self.end_headers()
       # отправляем тело ответа
       self.wfile.write(body)

   # отключаем логирование запросов в консоль
   def log_message(self, *_):
       pass

# функция для получения свободного tcp-порта
def _free_port():
   # создаём временный сокет
   s = socket.socket()
   # привязываемся к любому свободному порту
   s.bind(("127.0.0.1", 0))
   # получаем назначенный порт
   _, port = s.getsockname()
   # закрываем сокет
   s.close()
   # возвращаем номер порта
   return port

# integration test проверяет работу программы целиком
def test_cli_prints_title_from_local_server():
   # получаем свободный порт
   port = _free_port()
   # создаём http-сервер
   server = HTTPServer(("127.0.0.1", port), Handler)

   # запускаем сервер в отдельном потоке
   t = threading.Thread(target=server.serve_forever, daemon=True)
   # стартуем поток
   t.start()

   try:
       # формируем url для запроса
       url = f"http://127.0.0.1:{port}/data"
       # запускаем программу как отдельный software-процесс
       out = subprocess.check_output(
           [sys.executable, "app.py", url],
           text=True
       ).strip()
       # проверяем, что программа напечатала ожидаемый результат
       assert out == "From local server"
   finally:
       # останавливаем сервер после теста
       server.shutdown()

End-to-end-тестирование: имитация поведения пользователя

Найти ошибку, вызванную системным тестом, ещё сложнее. 

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

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

Чтобы представить E2E-тест, представьте, что к интеграционному добавились ещё несколько внешних действий, каждое из которых по трудозатратам равно примерно половине от того, сколько нужно потратить на код.

Полезный блок со скидкой

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

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

Запуск тестов

Если хотите запустить тесты, то название скриптов с ними должно начинаться со слова test_, а в директории проекта нужно установить ещё одну библиотеку — pytest. Команда для установки библиотеки: pip install pytest.

После этого в терминале командной строки или IDE нужно написать команду pytest.

Результат будет примерно таким:

Преимущества использования пирамиды тестирования

Пирамида тестирования улучшает качество всей разработки, и вот почему.

Экономия времени и ресурсов

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

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

Раннее обнаружение дефектов

Хотя идея пирамиды тестирования не связана с какими-то временными рамками, преимущество раннего выявления ошибок появляется естественным образом.

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

Упрощение поддержки тестов

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

Основной объём занимают простые тесты, которые почти не меняются с развитием и масштабированием основного приложения.

Оптимальное соотношение тестов разных уровней

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

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

Если говорить о примерных диапазонах, получается так:

  • Юнит-тестов от 60 до 90% от общего числа автотестов.
  • Интеграционных — от 10 до 30%.
  • Системных — от 1 до 10%.

Распространённые антипаттерны и ошибки

На практике пирамида тестирования может нарушиться с самого начала или через какое-то время. Вот две самые популярные ошибки.

Перевёрнутая пирамида, или Ice Cream Cone

По разным причинам разработчики могут подготовить большое количество сквозных тестов и минимальное — юнит- и интеграционных.

Иногда так происходит из-за того, что команда не доверяет кодовой базе и начинает проверять только результат, а не логику детально. Иногда потому, что автоматизируют то, что тестирует команда QA. 

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

Отсутствие баланса между уровнями тестирования

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

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

Современные адаптации пирамиды тестирования

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

Пирамида для микросервисной архитектуры

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

Из-за того, что всё приложение делится на части, одной пирамиды нет. Вместо этого для каждый микросервис имеет различный набор тестов.

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

Применение в agile-методологиях

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

Agile не означает, что структуры нет совсем. В этом методе:

  • Всё делится на короткие сроки работы, каждый из которых завершается готовой частью приложения или сервиса.
  • Изменения происходят часто.
  • Обратная связь приходит быстро.

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

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

Ищете работу в IT?

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

Бесплатно до 15 января!

Вам может быть интересно
medium
[anycomment]
Exit mobile version