Как стать автором
Обновить

Начинать тестирование раньше и уйти от релизов

Время на прочтение11 мин
Количество просмотров1.8K
image

Раньше нагрузочное тестирование проводилось сервисной командой на последнем этапе. Это дорого и долго: дорогой стенд, который надо собрать, продлайк, много железа, много интеграций, дорогие и редкие на рынке инженеры с уникальными знаниями вроде JMeter, LoadRunner, Gatling и так далее.

Как обычно выглядит схема нагрузочного тестирования:
  • Спроектировали.
  • Разработали.
  • Протестировали.
  • Начинаем проверять работающий продукт на перформанс.
  • Традиционный итог — перформанса не хватает, надо всё переделывать.

Классический водопад — это долго, дорого, и вообще он морально устарел. Сейчас нам важно минимизировать время от идеи до реализации и выхода на рынок, и для этого подходит концепция shift-left. То есть мы смещаемся влево по таймлайну и начинаем тестирование АСАП.

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

Меня зовут Кирилл, я QA-инженер в Газпромбанке. 20 лет я работаю в тестировании, и мне до сих пор не надоело. Последние 15 лет я фокусируюсь на нагрузочном тестировании и его интеграции в юнит-тесты, а также на исследовании производительности.

К чему идём


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

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

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

Тестировать только тот код, который менялся


Мы изучили, какие типы тестов могут делать разработчики, потом поискали и нашли не очень новое, но малоизвестное решение. Это JMH-библиотека от Open JDK. Она позволяет исследовать перформанс Java-приложений на уровне кода так же, как и юнит-тесты. И мы предложили разработчикам использовать эту библиотеку, поскольку порог вхождения в неё достаточно низкий. Это тот же Junit и low-code за счёт небольшого набора аннотаций.

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

Чтобы сделать первый простой тест, необходимо:

1. Обеспечить наличие в проекте пяти библиотек JMH и одного плагина для Gradle. Версия Gradle в проекте требуется не ниже 6.8.

2. Добавить в build.gradle зависимости и этап сборки с названием JMH c минимальными настройками.

Зависимости

plugins{
	id "me.champeau.jmh" version "0.6.8"
}

dependencies {    
	//JMH Benchmark       
	implementation 'org.openjdk.jmh:jmh-core:1.36'
    implementation 'org.openjdk.jmh:jmh-generator-annprocess:1.36'
    implementation 'org.openjdk.jmh:jmh-generator-reflection:1.36'
  }

jmh {
	//Настройки в этом блоке необходимы для тестов на станции разработчика. В teamcity meta-runner эти настройки переопределяются.
    failOnError = true
    includeTests = true
	//includes = ['ClassName']	//Фильтр. Позволяет запустить только те тесты, в имени которых есть указанный текст.
	zip64 = true // Use ZIP64 format for bigger archives
    jmhVersion = '1.36' 		// Specifies JMH version, depend on jmh-generator-bytecode-1.36.jar
    profilers = ['stack']
    resultFormat = 'JSON' 
}


3. Выбрать класс и несколько методов, которые хотим протестировать. Мы сделаем тест производительности из JUnit-теста, который покрывает ключевые методы. Класс теста и тестируемые методы этого класса должны быть public.

Пример, как сделать benchmark из JUnit

...
@Warmup(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)			//Необходим прогрев, чтобы три раза в течение пяти секунд методы прогрелись, не попадая в зачётный результат.
@OutputTimeUnit(TimeUnit.MILLISECONDS)									//В отчёте время выполнения будет в миллисекундах.
@BenchmarkMode(Mode.SampleTime)										 	//Тип теста — SampleTime, только по этому типу есть сравнение в тимсити и потому что в нём время разбивается по перцентилям.
@State(Scope.Benchmark)													//Тип Benchmark — все экземпляры объектов одного типа будут использоваться во всех потоках. Варианты: Benchmark, Group, Thread.
@Fork(1)																//Количество отдельных JVM-потоков, в которых ВСЕ задачи выполняются последовательно. Это НЕ может ускорить прохождения тестов.
@Timeout(time = 10, timeUnit = TimeUnit.SECONDS)						//Тайм-аут на каждую итерацию измерений по умолчанию — 10 m. Используем, чтобы тест не повис надолго.
@Measurement(iterations = 3,  time = 5, timeUnit = TimeUnit.SECONDS)	//У меня будет пять итераций измерения длительностью пять секунд каждая, которые пойдут в отчёт. 
																		//В рамках неё методы вызовутся столько раз, сколько успеют.
public class MobileGateServiceControllerTest {							//Класс теста и тестируемые методы этого класса должны быть public.

	...
    @BeforeEach			//Это остаётся и нужно для JUnit. Просто как пример. 
    @Setup				//Эта нотация от jmh говорит, что данный метод один раз выполнится перед тестом. Детальнее — ниже.
    public void initService() {
		//Здесь может быть код, который надо выполнить перед измерениями. Например, тут нужно читать тестовые данные из ресурсов или создавать моки.
	}

 	@Test			  	//Это JUnit. Просто как пример.  
    @Benchmark   	  	//!!! Этот метод будет проходить benchmark-тестирование. Метод должен быть public.
	@Threads(3)
    public void getCreditRequestOk() {
        performOk(getCreditRequestXmlRequest(EXPIRE_DATE_OK), GET_CREDIT_REQUEST_URL);
    }

    @Test		  	  	//Класс может иметь другие методы, но без аннотации @Benchmark они не будут проходить benchmark-тестирования. 
    void getCreditRequestParametersTimeOut() {
        performTimeOut(getCreditRequestParametersXmlRequest(EXPIRE_DATE_TIMEOUT), GET_CREDIT_REQUEST_PARAMETERS_URL);
    }   

    @TearDown
    void clearService() {
		//Здесь — код, который чистит объекты после теста, если это нужно. Тут можно удалить тестовые данные или удалить моки.
		//@TearDown выполняется один раз после тестирования каждого метода.
	} 
...
}


4. Запуск сборки:
  • Вариант локально запускать так:
  • gradle clean jmh
  • Вариант CICD. В дальнейшем вам потребуется джоб в CI\CD.

5. Анализ результата. Инструмент выводит результаты в нескольких форматах (raw, csv, json). Текстовый отчёт можно смотреть в логе, а также есть возможность формировать графический отчёт.

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

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

А вот этот подход с библиотекой и конвертацией юнит-тестов в бенчмарк-тесты позволяет интегрировать тестирование в конвейер сборки. Тесты содержатся внутри самого предложения, являясь исходным кодом. Когда приложение собирается на конвейере в CI/CD, эти тесты автоматически запускаются в том же окружении.

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

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

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

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

Проведя два теста — до и после — и сравнив результаты, можно понять, стало лучше или хуже. Выяснить это и есть наша цель.

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

Выставляя трешхолды, мы замеряем, насколько изменилось время при изменениях кода. Если оно увеличилось на 25 %, то тест не пройден. При этом нормальная погрешность — 20 %, так как производительность зависит от очень многих факторов.

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

От чего зависит погрешность измерения: текущая загруженность машины, какие процессы параллельно запущены и т. д. То есть отклонение на 2 % нельзя считать доказательством ухудшения производительности. Скорее всего, это погрешность. Мы проводили серию тестов на одной и той же версии кода, и погрешности инфраструктуры и оборудования составляли не более 20 %, поэтому трешхолд мы поставили 25 %. Это порог, при котором мы приостанавливаем сборку и начинаем анализировать причины ухудшения.

При внедрении любых подобных инструментов часто говорят: «Ну, классно! Но как нам это использовать? Там неудобно и не автоматизировано».

На самом деле существует множество плагинов к TeamCity, к Jenkins. Есть автоматизированные репорты, где можно встраивать в пайплайн свой отчёт. Он всегда есть в артефактах сборки. Можно две сборки сравнить между собой и узнать, как меняется перформанс.

Ещё есть функция профилирования и инструментации. Благодаря ей сразу можно узнать:
  • Какие методы тормозят.
  • Где и как ведут себя память и потоки.

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

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

Вот как выглядит отчёт. Он позволяет измерить метрики у целого списка различных инструментов, в том числе память — тоже.

1. В текстовом логе результаты выглядят так:

Результат stack

Result "controller.MobileGateServiceControllerTest.getCreditRequestOk":
23:23:11     N = 16957
23:23:11     mean =      2.944 ±(99.9%) 0.183 ms/op
23:23:11   
23:23:11     Histogram, ms/op:
23:23:11       [  0.000,  12.500) = 15770
23:23:11       [ 12.500,  25.000) = 743
23:23:11       [ 25.000,  37.500) = 292
23:23:11       [ 37.500,  50.000) = 88
23:23:11       [ 50.000,  62.500) = 38
23:23:11       [ 62.500,  75.000) = 13
23:23:11       [ 75.000,  87.500) = 9
23:23:11       [ 87.500, 100.000) = 3
23:23:11       [100.000, 112.500) = 0
23:23:11       [112.500, 125.000) = 0
23:23:11       [125.000, 137.500) = 1
23:23:11       [137.500, 150.000) = 0
23:23:11       [150.000, 162.500) = 0
23:23:11       [162.500, 175.000) = 0
23:23:11       [175.000, 187.500) = 0
23:23:11   
23:23:11     Percentiles, ms/op:
23:23:11         p(0.0000) =      0.414 ms/op
23:23:11        p(50.0000) =      0.575 ms/op
23:23:11        p(90.0000) =      8.638 ms/op
23:23:11        p(95.0000) =     16.302 ms/op
23:23:11        p(99.0000) =     35.979 ms/op
23:23:11        p(99.9000) =     68.508 ms/op
23:23:11        p(99.9900) =    108.745 ms/op
23:23:11        p(99.9990) =    129.630 ms/op
23:23:11        p(99.9999) =    129.630 ms/op
23:23:11       p(100.0000) =    129.630 ms/op
23:23:11   
23:23:11   Secondary result "controller.MobileGateServiceControllerTest.getCreditRequestOk:·stack":
23:23:11   Stack profiler:
23:23:11   
23:23:11   ....[Thread state distributions]....................................................................
23:23:11    49.3%         RUNNABLE
23:23:11    25.1%         BLOCKED
23:23:11    14.3%         TIMED_WAITING
23:23:11    11.4%         WAITING
23:23:11   
23:23:11   ....[Thread state: RUNNABLE]........................................................................
23:23:11    14.3%  29.0% <stack is empty, everything is filtered?>
23:23:11    11.0%  22.4% java.io.PrintStream.write
23:23:11     2.2%   4.4% sun.nio.ch.FileDispatcherImpl.write0
23:23:11     0.9%   1.9% java.lang.StackTraceElement.initStackTraceElements
23:23:11     0.9%   1.7% java.lang.Throwable.fillInStackTrace
23:23:11     0.7%   1.4% java.io.InputStream.read
23:23:11     0.6%   1.1% java.util.zip.Inflater.inflateBytesBytes
23:23:11     0.5%   1.0% java.util.HashMap.hash
23:23:11     0.4%   0.9% java.io.PrintStream.flush
23:23:11     0.4%   0.7% java.util.regex.Pattern$BmpCharProperty.match
23:23:11    17.4%  35.4% <other>
23:23:11   
23:23:11   ....[Thread state: BLOCKED].........................................................................
23:23:11    23.2%  92.4% java.io.PrintStream.write
23:23:11     1.3%   5.1% java.io.PrintStream.flush
23:23:11     0.3%   1.2% java.util.zip.ZipFile.getEntry
23:23:11     0.2%   0.8% javax.persistence.spi.PersistenceProviderResolverHolder$PersistenceProviderResolverPerClassLoader$CachingPersistenceProviderResolver.getPersistenceProviders
23:23:11     0.1%   0.3% org.mockito.internal.progress.SequenceNumber.next
23:23:11     0.1%   0.3% ch.qos.logback.core.util.CachingDateFormatter.format
23:23:11   
23:23:11   ....[Thread state: TIMED_WAITING]...................................................................
23:23:11    14.3% 100.0% java.lang.Object.wait
23:23:11   
23:23:11   ....[Thread state: WAITING].........................................................................
23:23:11    11.4% 100.0% jdk.internal.misc.Unsafe.park
23:23:11   
23:23:11   
23:23:11   
23:23:11   # Run complete. Total time: 00:00:16
23:23:11   
23:23:11   REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
23:23:11   why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
23:23:11   experiments, perform baseline and negative tests that provide experimental control, make sure
23:23:11   the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
23:23:11   Do not assume the numbers tell you what you want them to tell.
23:23:11   
23:23:11   Benchmark                                                                        Mode    Cnt    Score   Error  Units
23:23:11   MobileGateServiceControllerTest.getCreditRequestOk                             sample  16957    2.944 ± 0.183  ms/op
23:23:11   MobileGateServiceControllerTest.getCreditRequestOk:getCreditRequestOk·p0.00    sample           0.414          ms/op
23:23:11   MobileGateServiceControllerTest.getCreditRequestOk:getCreditRequestOk·p0.50    sample           0.575          ms/op
23:23:11   MobileGateServiceControllerTest.getCreditRequestOk:getCreditRequestOk·p0.90    sample           8.638          ms/op
23:23:11   MobileGateServiceControllerTest.getCreditRequestOk:getCreditRequestOk·p0.95    sample          16.302          ms/op
23:23:11   MobileGateServiceControllerTest.getCreditRequestOk:getCreditRequestOk·p0.99    sample          35.979          ms/op
23:23:11   MobileGateServiceControllerTest.getCreditRequestOk:getCreditRequestOk·p0.999   sample          68.508          ms/op
23:23:11   MobileGateServiceControllerTest.getCreditRequestOk:getCreditRequestOk·p0.9999  sample         108.745          ms/op
23:23:11   MobileGateServiceControllerTest.getCreditRequestOk:getCreditRequestOk·p1.00    sample         129.630          ms/op
23:23:11   MobileGateServiceControllerTest.getCreditRequestOk:·stack                      sample             NaN            ---
23:23:11   
23:23:11   Benchmark result is saved to /opt/teamcity-ci-cdl-agent/dos-p1ail-51-04/work/a317c6a95b41089a/benchmarks.json


2. При сборке в TeamCity будет формироваться отчёт, примерный его вид — ниже.

Результаты одного теста:

image

Пример сравнения двух тестов:

image

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

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

А ещё тесты можно группировать.

Группировка тестов


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

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

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

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

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

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

То есть это история про движение влево и про практически моментальную обратную связь, которую разработчик получает в течение нескольких минут. Мы можем прогнать перформанс-тест за две минуты плюс две-три — на прогрев, и узнать, например, что новый код работает не хуже старого.
Теги:
Хабы:
Всего голосов 12: ↑10 и ↓2+11
Комментарии1

Публикации

Информация

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