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

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

.
50 оттенков нагрузочного тестирования
23.05.2022 00:00

Автор: Коршунова Александра

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

Меня зовут Саша, я работаю в команде тестирования Ozon Fintech и расскажу про разнообразный спектр вариантов НТ: как именно мы его применяем и какие инструменты используем. Статья будет полезна тем, кто уже что-то слышал про НТ и хочет добавить его в свой проект, но пока страшновато. Давайте разбираться!




Нефункциональное тестирование

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

Мне нравится подход Рекса Блэка, американского тестировщика, автора книг и учебников по тестированию. В своё время он был президентом ISTQB и соавтором программ подготовки к этой сертификации. Согласно его учебнику “Advanced Software Testing — Vol. 3”, неэффективность может выражаться по-разному:

  • в медленных ответах (slow responses times),

  • в крайне низкой пропускной способности (inadequate throughput),

  • в допущении ошибок под нагрузкой (reliability failures under conditions of load),

  • в чрезмерном потреблении ресурсов (excessive resource requirements). 

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

Блэк напоминает, что существует множество мифов о нагрузочном тестировании:

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

  2. НТ надо проводить только под конец разработки. Это в корне неверно. Как и в случае с остальным тестированием, НТ должно быть всеобъемлющим на протяжении всего жизненного цикла ПО.

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

Виды нагрузочного тестирования

Так что же это за зверь, это ваше нагрузочное тестирование?




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

Вот некоторые виды тестирования эффективности, которые перечисляет Блэк:

  1. Нагрузочное тестирование (load testing) оценивает поведение компонента или системы в условиях увеличивающейся нагрузки, например при увеличении количества параллельных пользователей и/или количества транзакций, чтобы понять, выдержит ли этот компонент или система подобную нагрузку.

    Упор делается на ожидаемую и реалистичную нагрузку, хотя в основу этого НТ включают разнообразные сочетания запросов и их количество. Запросы создаются таким образом, чтобы смоделировать одновременную работу набора пользователей. Это позволяет оценить время ответа и пропускную способность.
    Иногда разделяют многопользовательское НТ с разумным/реалистичным количеством пользователей и объёмное НТ с огромным количеством пользователей.

  1. Стрессовое тестирование (stress testing). Его как раз обычно представляют, когда говорят про НТ. Это разновидность тестирования с целью изучения поведения системы или компонента при пиковых объёмах нагрузки и в необычных условиях функционирования, например при нехватке таких ресурсов, как память или доступ к серверам. Стрессовое тестирование — это выкрученное на максимум НТ. Его цель — убедиться, что время ответа, надёжность и функциональность будут деградировать медленно и предсказуемо — и в конце концов отобразится сообщение типа «Я занят, перезвоните позже». Не должно быть асоциального поведения со стороны системы: повреждения данных, блокировки системы или её падения.

  2. Тестирование масштабируемости (scalability testing) позволяет обнаружить «бутылочные горлышки», а затем удостовериться, что увеличение мощностей поможет решить проблему. Например, если планируется добавить несколько процессоров для улучшения производительности, то тестирование масштабируемости позволит убедиться, что одних процессоров хватит. Также такое тестирование помогает определить пределы масштабируемости на проде.

  1. Тестирование утилизации ресурсов (resource utilization testing) оценивает производительности процессора под нагрузкой и использование ОЗУ и дисковой памяти.

  1. Тестирование стабильности (endurance or soak testing) исследует поведение системы при высоком уровне нагрузки в течение продолжительного промежутка времени. Обычно эта нагрузка в несколько раз превышает типичную на проде и позволяет обнаружить проблемы, которые могут возникнут после определённого количества транзакций, например утечку памяти. В отличие от обычного НТ тестирование стабильности позволяет найти такие проблемы благодаря своей продолжительности (при обычном НТ утечки могут попросту не начаться или не успеть себя проявить).

  1. Тестирование производительности при всплесках нагрузки (spike testing) моделирует резкий импульсный рост количества параллельных пользователей или процессов внутри системы и позволяет оценить её стабильность при таких скачках и между ними, убедившись, что между скачками система полностью вернулась в норму. 

  1. Тестирование надёжности (reliability testing) проверяет способность системы выполнять свои функции в определённых условиях в течение заданного промежутка времени или при заданном количестве операций.

  1. Фоновое тестирование (background testing) помогает удостовериться, что увеличение нагрузки на систему никак не сказывается на юзабилити для конечного пользователя, например что в самый ажиотаж распродажи у конкретного Васи все страницы нормально грузятся и корзина работает.

  1. Опрокидывающее тестирование (тестирование пресыщения) (tip-over testing) нацелено на насыщение системы нагрузкой и нахождение точки и места отказа. То звено, которое не выдержало нагрузки, помечается как самое слабое в системе. Исходя из этого можно считать, что изменение дизайна этого звена может привести к улучшению производительности и времени ответа при больших нагрузках.

О других типах НТ можно почитать в материалах для подготовки к сертификации (пункт 1.2). 




Инструментарий

Нагрузок много на Земле, и каждая важна. Решай, мой друг, что заюзать тебе, — не допусти падения прода.

НТ существует уже не первый год, и за это время накопилось много разных инструментов для его проведения

Мы используем Яндекс.Танк — это удобный инструмент для тестирования бэка.

Про него написано немало, не буду повторяться:

Мы в Ozon Fintech пишем на Go и работаем с gRPC, что надо учитывать при выборе пушек для Танка. Кроме того, наши пушки необходимо кастомизировать под свои нужды — выполнение сценариев, создание утилит для автогенерации пушки, автогенерация патронов — поэтому выбор пал именно на Pandora

Она сама написана на Go, обновляется и поддерживается. Создать кастомную пушку на ней довольно просто:

  1. В main() создаётся новый экземпляр пушки, подкладывается необходимый формат патронов, задаётся название.

  2. В Bind() непосредственно настраивается пушка: создаётся conn для адреса тестируемого сервиса, подкладываются аргументы.

  3. В shoot() прописывается сценарий стрельб.

В структуре патрона важную роль играет поле Tag, в зависимости от которого поведение пушки меняется по switch case’у. Пример есть ниже в практической части статьи.

Внутри выпавшего метода отправляем запрос по gRPC на ручку, получаем ответ, приводим код ответа на gRPC к HTTP-шному ответу, отдаём этот код агрегатору.

Создаём патроны для нашей пушки: в Tag кладём название ручки, а в тело — остальные изменяемые поля запросов, которые нужны для работы. Создаём побольше таких патронов, заполняем конфиг для Pandora. А дальше локально или удалённо запускаем обстрел и смотрим на результаты.

Сколько вешать в граммах?

Как рассчитать профиль нагрузки? Сколько патронов необходимо создать для проведения тестирования? Для этого нужны требования, которых часто нет, а авторам задачи нужно, «чтобы оно работало». 

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

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

Например, текущая нагрузка на ручку = 15 RPS, а тест, который я провожу, должен длиться десять минут. Соответственно, количество уникальных патронов, которые необходимо предгенерить, равно 15 RPS * 60s * 10m = 9000 патронов.

Что, если у меня нет возможности сделать каждый выстрел уникальным? Например, я в запросах обращаюсь к тестовым пользователям, а их в нужном количестве нет. Тогда патронов можно сделать меньше — и после первого прохода по файлу с ними Яндекс.Танк пойдёт на повтор. 

Получается, что вообще можно сгенерить по одному патрону на каждый тип запроса и успокоиться? Можно, но это будут нерепрезентативные запросы. Мы обычно рассчитываем минимальное количество запросов с учётом того, что нам необходимо, чтобы каждый запрос отправлялся на сервис не чаще, чем раз в пять секунд. Тогда с нашими условиями получится, что надо создать минимум 15 RPS * 5s = 75 патронов.

Профилирование пушки

Что делать, если у нас не только простая нагрузка «один запрос — один ответ», а целый сценарий? Например, в запрос нужно добавить свежий токен. Или для подтверждения запроса в одной ручке надо дёрнуть вторую ручку.

Разберём такой тестовый сервис, обладающий методами MyRegistrationHandle, MyAccountHandle, UpdateBalance и ComplyBalance. На каждую ручку свой кейс:

  1. MyRegistrationHandle — кейс с простой отправкой запроса.

  2. MyAccountHandle — кейс с запросом к стороннему сервису авторизации.

UpdateBalance — кейс с последовательным выполнением запросов к одному и тому же сервису сначала на UpdateBalance, потом — на ComplyBalance.



Тестовый сервис
package testClient

import (
	"context"
	"github.com/google/uuid"
	"google.golang.org/grpc"
)

type TestClient interface {
	MyRegistrationHandle(ctx context.Context, req *MyRegistrationHandleRequest) (*MyRegistrationHandleResponse, error)
	MyAccountHandle(ctx context.Context, req *MyAccountHandleRequest) (*MyAccountHandleResponse, error)
	UpdateBalance(ctx context.Context, req *UpdateBalanceRequest) (*UpdateBalanceResponse, error)
	ComplyBalance(ctx context.Context, req *ComplyBalanceRequest) (*ComplyBalanceResponse, error)
}

type testClient struct {
	cc grpc.ClientConnInterface
}

func NewTestClient(cc grpc.ClientConnInterface) TestClient {
	return &testClient{cc}
}

type MyRegistrationHandleRequest struct {
	ClientName string
}
type MyRegistrationHandleResponse struct {
	ClientID int
}

func (h *testClient) MyRegistrationHandle(ctx context.Context, req *MyRegistrationHandleRequest) (*MyRegistrationHandleResponse, error) {
	resp := new(MyRegistrationHandleResponse)
	err := h.cc.Invoke(ctx, "/MyRegistrationHandle", &req, resp)
	if err != nil {
		return nil, err
	}
	return resp, nil
}

type MyAccountHandleRequest struct {
	ClientID int
	Token    string
}
type MyAccountHandleResponse struct {
	AccountId int
}

func (h *testClient) MyAccountHandle(ctx context.Context, req *MyAccountHandleRequest) (*MyAccountHandleResponse, error) {
	resp := new(MyAccountHandleResponse)

	err := h.cc.Invoke(ctx, "/MyAccountHandle", &req, resp)
	if err != nil {
		return nil, err
	}

	return resp, err
}

type UpdateBalanceRequest struct {
	ClientID int
	Sum      int
}
type UpdateBalanceResponse struct {
	Was         int
	Now         int
	OperationId uuid.UUID
}

func (h *testClient) UpdateBalance(ctx context.Context, req *UpdateBalanceRequest) (*UpdateBalanceResponse, error) {

	resp := new(UpdateBalanceResponse)

	err := h.cc.Invoke(ctx, "/UpdateBalance", &req, resp)
	if err != nil {
		return nil, err
	}

	return resp, nil
}

type ComplyBalanceRequest struct {
	OperationId uuid.UUID
}

type ComplyBalanceResponse struct {
	CurrentBalance int
}

func (h *testClient) ComplyBalance(ctx context.Context, req *ComplyBalanceRequest) (*ComplyBalanceResponse, error) {
	resp := new(ComplyBalanceResponse)

	err := h.cc.Invoke(ctx, "/ComplyBalance", &req, resp)
	if err != nil {
		return nil, err
	}

	return resp, nil
}




Получается, что код shoot() в нашей пушке будет выглядеть так: 

func (g *Gun) shoot(ammo *Ammo) {
	var code int
	sample := netsample.Acquire(ammo.Tag)

	conn := g.client
	client := pb.NewArticleClient(&conn)

	switch ammo.Tag {
	case MyAccountHandle:
		sample.AddTag(MyAccountHandle)
		code = g.MyAccountHandle(client, ammo)
	case MyRegistrationHandle:
		sample.AddTag(MyRegistrationHandle)
		code = g.MyRegistrationHandle(client, ammo)
	case UpdateBalance:
		sample.AddTag(UpdateBalance)
		code = g.UpdateBalance(client, ammo	)
	default:
		code = 404
	}
	defer func() {
		sample.SetProtoCode(code)
		g.Aggr.Report(sample)
	}()
}

Под капотом первого кейса всё просто: отправляем запрос к тестовому сервису, получаем ответ, убеждаемся, что ClientID вернулся не пустой.

func (g *Gun) MyRegistrationHandle(client testClient.TestClient, ammo *Ammo) int {
	req := testClient.MyRegistrationHandleRequest{ClientName: ammo.ClientName}
	resp, err := client.MyRegistrationHandle(context.Background(), &req)
	code := checkNoErrAndNotNil(err, resp)

	if code == 200 {
		if resp.ClientID == 0 {
			return errorCode1
		}
	}
	return code
}

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

func (g *Gun) MyAccountHandle(client testClient.TestClient, ammo *Ammo) int {
	//получаем свежий токен
	tokenResp, err := tokenClient.GetToken(context.Background(), &tokenReq)
	code := checkNoErrAndNotNil(err, tokenResp)
	if code != 200 {
		return code
	}
	//убеждаемся, что токен нормальный
	if tokenResp.Token == "" {
		return errorCode2
	}

	//делаем запрос к тестовой ручке со свежим токеном
	req := testClient.MyAccountHandleRequest{ClientID: ammo.ClientId, Token: tokenResp.Token}
	resp, err := client.MyAccountHandle(context.Background(), &req)
	code = checkNoErrAndNotNil(err, resp)

	if code == 200 {
		if resp.GetAccounts() != ammo.AccountId {
			return errorCode3
		}
	}
	return code
}

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

func (g *Gun) UpdateBalance(client testClient.TestClient, ammo *Ammo) int {
	//создаём запись в базе об изменении баланса пользователя
	req := testClient.UpdateBalanceRequest{Sum: ammo.Sum}
	resp, err := client.UpdateBalance(context.Background(), &req)
	code := checkNoErrAndNotNil(err, resp)
	if code == 200 {
		//проверяем сумму изменения
		if resp.Now-resp.Was != ammo.Sum {
			return errorCode4
		}
	}

	//подтверждаем запрос на изменение баланса
	complyReq := testClient.ComplyBalanceRequest{resp.OperationId}
	complyResp, err := client.ComplyBalance(context.Background(), &complyReq)
	code = checkNoErrAndNotNil(err, complyResp)
	if code == 200 {
		//проверяем сумму изменения
		if complyResp.CurrentBalance != resp.Now {
			return errorCode5
		}
	}
	return code
}

Опять же, критично на каждом из шагов проверять коды ответа и приходящую информацию. Это важно, чтобы знать, на каком именно шаге что сломалось. Также необходимо все кастомные коды ошибок делать уникальными — чтобы было проще определять место поломки. Например, в третьем кейсе в обоих случаях проверяется сумма изменения баланса клиента, но при ошибке в работе ручки UpdateBalance вернётся errorCode4, а в ComplyBalance — errorCode5.

Дополнительно можно добавить логи на ошибки. Банальные фразы типа

log.Printf("\nwrong result amount for Handle1 for ClientId %+v.\nresp.GetResultAmount: %+v\nreq.Amount:%+v", ammo.User.ClientId, resp.GetResultAmount(), req.Amount) 

резко повышают читаемость кода.

Что сейчас и к чему стремимся

Pandora активно используется для проверки эффективности сервисов внутри Ozon Fintech. Сейчас мы активно развиваем качество пушек и увеличиваем их количество. Сервисов много, для каждого нужны уникальные запросы, патроны, поведение, сценарии. Для чего-то требуется предварительная авторизация, для чего-то — нет. Поведение из некоторых UI-тестов копируется в бэкендовых нагрузочных тестах и позволяет проверять пропускную способность сервиса на пользовательских сценариях. Нехитрыми способами, описанными выше, можно покрыть существенную часть тестов.

Сервисов много — сначала мы создавали пушки вручную, а теперь делаем это с помощью генератора. Параллельно наша команда работает над концепцией «Нагрузочное тестирование как сервис» — предоставлением полного цикла стрельбы от генерации и загрузки патронов на сервер и сборки пушки до проведения обстрела, сбора метрик и выведения результатов по нажатию на кнопочку в пайплайне CI.

Надеюсь, моя статья помогла понять возможности НТ и убедила вас в том, что это полезный и необходимый инструмент для любого проекта. Stay tuned!

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