Перейти к основному содержимому

Практикум — API-тестер на Groovy и JMeter

Разработчику

Практикум — API-тестер на Groovy и JMeter

Groovy API Tester — desktop-приложение для ручной проверки REST API. Вы вводите URL, HTTP-метод, заголовки и тело запроса — получаете статус, время ответа, заголовки и тело ответа. Под капотом Apache JMeter работает программно, без графического интерфейса JMeter и без .jmx-файлов.

Практикум показывает Groovy за пределами build.gradle и Jenkinsfile:

  • Gradle Groovy DSL — сборка, зависимости, задача run (23.md);
  • groovy-swing — декларативный UI через SwingBuilder, тот же приём delegate, что в Gradle (11.md);
  • groovy-json — автоформатирование JSON в ответе (103.md);
  • JMeter API — HTTP из Java/Groovy-кода (тестирование API);
  • SwingWorker — сеть в фоне, окно не "зависает".

Готовый проект можно взять как эталон — каталог GroovyAPITester рядом с другими JVM-проектами. Если Groovy-проект ещё не запускали — сначала первая программа и Gradle Groovy DSL. База по HTTP — HTTP как основа веб-интеграций.

Кому полезен практикум

Разработчику, который уже читает build.gradle, но хочет цельное JVM-приложение на Groovy. Тестировщику — как встроить JMeter в код, а не только в GUI. Архитектору — как отделить UI, модели и транспорт (слои без смешения ответственности).


Что получится

После прохождения практикума у вас будет:

  • Gradle-проект с плагинами groovy и application;
  • модели HttpRequestConfig и HttpResponseResult — DTO без привязки к Swing и JMeter;
  • класс JMeterHttpExecutor — один HTTP-запрос через StandardJMeterEngine;
  • окно ApiTesterFrame с вкладками "Заголовки", "Тело запроса", "Ответ";
  • smoke-тест gradle smokeTest для CI (Jenkins Pipeline);
  • команда gradle run — запуск GUI.
gradle run

Горячая клавиша Ctrl+Enter отправляет запрос без клика по кнопке.


Термины

ТерминПростыми словами
REST APIHTTP-интерфейс сервера — ресурсы по URL, методы GET/POST/PUT/PATCH/DELETE
HTTP-методГлагол запроса — что хотим сделать с ресурсом (прочитать, создать, изменить, удалить)
Заголовок (header)Пара "имя — значение" в HTTP-сообщении — Content-Type, Authorization, Accept
Тело запроса (body)Полезная нагрузка POST/PUT/PATCH — часто JSON
Статус-кодЧисло в ответе — 200 OK, 404 Not Found, 500 Internal Server Error
DTOData Transfer Object — класс только для передачи данных между слоями
POGOPlain Old Groovy Object — класс с полями без лишнего boilerplate
Apache JMeterИнструмент нагрузочного и функционального тестирования — HTTP, JDBC, JMS и др.
HTTPSamplerProxyJMeter-компонент, описывающий один HTTP-запрос (хост, путь, метод, тело)
SampleResultОбъект JMeter с результатом одного sampler — код, время, тело, заголовки
TestPlanКорень JMeter-сценария — контейнер всего плана
ThreadGroupГруппа виртуальных пользователей; у нас — один поток, одна итерация
StandardJMeterEngineПрограммный запуск JMeter без .jmx и без GUI
ListedHashTreeДерево элементов JMeter — иерархия TestPlan → ThreadGroup → Sampler
SwingBuilderGroovy-обёртка над Swing — UI описывают вложенными блоками, как mini-DSL
SwingWorkerФоновая задача Swing — doInBackground() не блокирует EDT
EDTEvent Dispatch Thread — поток, в котором рисуется и обрабатывается GUI
Smoke-тестБыстрая проверка "система жива" — один GET без GUI после сборки

Зачем JMeter, если есть Postman и curl

ИнструментСильная сторонаСлабое место для нашей задачи
PostmanУдобный GUI, коллекции, окруженияНужен отдельный продукт; сложнее встроить в свой код
curlМгновенно из терминалаНет своего GUI; парсинг ответа вручную
HttpURLConnection / HttpClientСтандарт JavaМного boilerplate; редиректы, таймауты, логирование — на вас
JMeter (embedded)Зрелый HTTP-стек, метрики времени, тот же движок, что в нагрузочных тестахТяжёлая зависимость; нужна инициализация JMETER_HOME

Живая теория. Мы собираем не замену Postman для команды QA, а учебный проект на Groovy, где HTTP-движок совпадает с инструментом нагрузочного тестирования. Один и тот же HTTPSamplerProxy потом можно масштабировать до тысяч потоков в .jmx-плане — логика запроса уже знакома.

Альтернатива на чистом Groovy — HttpURLConnection или Java 11+ HttpClient (103.md). JMeter оправдан, если вы уже работаете с JMeter в CI или хотите единый стек "ручной запрос → нагрузочный сценарий".


Архитектура приложения

СлойКлассОтветственность
Точка входаMainLook-and-feel ОС, запуск окна на EDT
UIApiTesterFrameФорма, парсинг заголовков, отображение ответа
HTTPJMeterHttpExecutorИнициализация JMeter, сборка TestPlan, выполнение
МоделиHttpRequestConfig, HttpResponseResultDTO запроса и ответа

Живая теория. Разделение на слои — тот же принцип, что в ООП и тестировании:

  • UI не знает про HTTPSamplerProxy — только про HttpRequestConfig / HttpResponseResult;
  • JMeterHttpExecutor не знает про Swing — его можно вызвать из SmokeTest, Spock или Jenkins;
  • модели не зависят ни от Swing, ни от JMeter — удобная точка для unit-тестов.

Поток данных при нажатии "Отправить":

  1. ApiTesterFrame читает поля формы → собирает HttpRequestConfig.
  2. SwingWorker вызывает executor.execute(config) не в EDT.
  3. JMeterHttpExecutor строит дерево JMeter, запускает engine, возвращает HttpResponseResult.
  4. done() на EDT обновляет метки и текстовые области.

Анатомия HTTP-запроса

Перед кодом полезно держать в голове структуру сообщения (HTTP — основа):

POST /users HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json

{"name": "Alice"}
ЧастьГде в нашем приложении
Метод + путьmethodCombo + URL (путь и query из URI)
Заголовкивкладка "Заголовки" → HeaderManager
Теловкладка "Тело запроса" → HTTPSamplerProxy.postBodyRaw
Статус и тело ответавкладка "Ответ" ← SampleResult

Живая теория. URL https://api.example.com:8443/v1/users?page=2 JMeter разбирает на части:

  • protocolhttps;
  • domainapi.example.com;
  • port8443 (или 443/80 по умолчанию);
  • path/v1/users?page=2.

Если передать только api.example.com/users без схемы — URI.create не даст валидный запрос; мы явно проверяем scheme и host в execute().


Требования


Шаг 0 · Структура проекта

Создайте каталог GroovyAPITester и дерево файлов:

GroovyAPITester/
├── build.gradle
├── settings.gradle
├── gradlew / gradlew.bat # после gradle wrapper
└── src/main/
├── groovy/apitester/
│ ├── Main.groovy
│ ├── SmokeTest.groovy
│ ├── model/
│ │ ├── HttpRequestConfig.groovy
│ │ └── HttpResponseResult.groovy
│ ├── jmeter/
│ │ └── JMeterHttpExecutor.groovy
│ └── ui/
│ └── ApiTesterFrame.groovy
└── resources/jmeter/bin/
└── jmeter.properties

Файл settings.gradle:

rootProject.name = 'GroovyAPITester'

Разбор:

  • rootProject.name — имя артефакта в build/libs/ и в installDist;
  • каталог src/main/groovy — стандарт Gradle для Groovy (23.md), аналог src/main/java;
  • пакет apitester.model, apitester.ui, apitester.jmeterразные причины изменения (данные / интерфейс / транспорт);
  • resources/jmeter/bin/jmeter.properties — попадает в classpath; JMeter находит "домашний каталог" без установки JMeter на машину.

Шаг 1 · build.gradle

plugins {
id 'groovy'
id 'application'
}

group = 'apitester'
version = '1.0.0'

repositories {
mavenCentral()
}

def groovyVersion = '3.0.20'
def jmeterVersion = '5.6.3'

dependencies {
implementation "org.codehaus.groovy:groovy:${groovyVersion}"
implementation "org.codehaus.groovy:groovy-swing:${groovyVersion}"
implementation "org.codehaus.groovy:groovy-json:${groovyVersion}"

implementation "org.apache.jmeter:ApacheJMeter_core:${jmeterVersion}"
implementation "org.apache.jmeter:ApacheJMeter_http:${jmeterVersion}"
implementation "org.apache.jmeter:ApacheJMeter_components:${jmeterVersion}"
implementation "org.apache.jmeter:ApacheJMeter_java:${jmeterVersion}"

implementation 'org.apache.logging.log4j:log4j-core:2.22.1'
implementation 'org.apache.logging.log4j:log4j-api:2.22.1'
implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1'
implementation 'org.slf4j:slf4j-api:2.0.9'
}

application {
mainClass = 'apitester.Main'
}

tasks.withType(JavaExec).configureEach {
jvmArgs '-Dfile.encoding=UTF-8'
}

tasks.register('smokeTest', JavaExec) {
group = 'verification'
description = 'Smoke test HTTP request via JMeter'
classpath = sourceSets.main.runtimeClasspath
mainClass = 'apitester.SmokeTest'
}

Разбор build.gradle

БлокНазначение
id 'groovy'Компиляция .groovy из src/main/groovy
id 'application'Задачи run, installDist, mainClass
groovy-swingSwingBuilder для UI
groovy-jsonJsonSlurper, JsonOutput.prettyPrint
ApacheJMeter_coreДвижок, утилиты, JMeterUtils
ApacheJMeter_httpHTTPSamplerProxy, HTTP-протокол
ApacheJMeter_componentsHeaderManager, listeners
log4j + slf4jJMeter пишет через SLF4J; без binding — No SLF4J providers
smokeTestОтдельная задача для CI — один GET без GUI
-Dfile.encoding=UTF-8Кириллица в UI и в JSON без "кракозябр"

Разбор фрагмента:

  • def groovyVersion = '3.0.20' — локальная переменная в скрипте Gradle; GString "${groovyVersion}" подставляет версию в координаты Maven (GString).
  • Блок application { mainClass = ... } — плагин application создаёт задачу run, которая запускает static void main указанного класса.
  • tasks.withType(JavaExec).configureEach — правило для всех JavaExec-задач, включая run и smokeTest.
  • tasks.register('smokeTest', JavaExec) — кастомная задача без отдельного .gradle-файла; удобно вызывать из Jenkins или Shared Library.

Живая теория. Файл build.gradle — Groovy-скрипт с делегированием замыканий (11.md). Когда вы пишете dependencies { implementation ... }, Gradle передаёт closure объекту DependencyHandler, и вызов implementation резолвится у делегата — не в глобальной области. Тот же механизм в application { } и tasks.register(...) { }.


Шаг 2 · jmeter.properties

Файл src/main/resources/jmeter/bin/jmeter.properties:

# Minimal JMeter configuration for embedded use
jmeter.save.saveservice.output_format=xml
jmeter.save.saveservice.response_data=true
jmeter.save.saveservice.samplerData=true
jmeter.save.saveservice.requestHeaders=true
jmeter.save.saveservice.response_headers=true

Разбор:

  • output_format=xml — формат сохранения результата sampler (для embedded достаточно xml);
  • response_data=true — в SampleResult попадает тело ответа; без флага UI покажет пустое поле;
  • samplerData=true — данные исходящего запроса (удобно для отладки);
  • requestHeaders / response_headers — заголовки в обе стороны.

Живая теория. У desktop-JMeter "дом" — каталог JMETER_HOME с bin/jmeter.properties. В embedded-режиме мы подменяем его минимальным деревом в resources:

classpath:jmeter/bin/jmeter.properties → JMETER_HOME = classpath:jmeter

Код в ensureInitialized() вычисляет путь: propsFile.parentFile.parentFile — поднимается от bin/ к корню jmeter/. Поэтому структура каталогов жёсткая — не кладите jmeter.properties в корень resources без bin/.


Шаг 3 · Модели запроса и ответа

HttpRequestConfig

package apitester.model

class HttpRequestConfig {

String url
String method = 'GET'
Map<String, String> headers = [:]
String body = ''
int connectTimeoutMs = 10_000
int responseTimeoutMs = 30_000

}

Разбор:

  • String url — полный URL со схемой; парсинг — в JMeterHttpExecutor, не в модели;
  • method = 'GET' — значение по умолчанию для Groovy POGO (ООП);
  • headers = [:] — пустая map-литерала (типы — Map);
  • 10_000 — numeric separator, как в Java 7+;
  • таймауты в миллисекундах — connectTimeout (TCP) и responseTimeout (ожидание ответа) на sampler.

HttpResponseResult

package apitester.model

class HttpResponseResult {

boolean success
int statusCode
long responseTimeMs
long responseSizeBytes
String responseBody
String responseHeaders
String requestHeaders
String contentType
String errorMessage

static HttpResponseResult error(String message) {
new HttpResponseResult(
success: false,
statusCode: 0,
responseTimeMs: 0,
responseSizeBytes: 0,
errorMessage: message
)
}

}

Разбор:

  • success — флаг JMeter sample.successful (HTTP 4xx/5xx могут быть successful=false в зависимости от настроек);
  • statusCode == 0 — договорённость "HTTP-ответа не было" (ошибка URL, JMeter, сеть);
  • именованные аргументы конструктора — new HttpResponseResult(success: false, ...) без порядка параметров;
  • фабрика error(String) — единый способ вернуть ошибку из execute() без дублирования полей.

Живая теория. DTO намеренно без методов бизнес-логики. parseHeaders живёт в UI, сбор sampler — в JMeterHttpExecutor. Так SmokeTest и Spock-тесты не тянут Swing. Это тот же приём, что Java-record или Kotlin data class для границ слоёв (Groovy и Java).


Шаг 4 · JMeterHttpExecutor

Создайте src/main/groovy/apitester/jmeter/JMeterHttpExecutor.groovy.

4.1 Инициализация JMeter

class JMeterHttpExecutor {

private static volatile boolean initialized = false

static synchronized void ensureInitialized() {
if (initialized) {
return
}

def jmeterHomeUrl = JMeterHttpExecutor.classLoader.getResource('jmeter/bin/jmeter.properties')
if (!jmeterHomeUrl) {
throw new IllegalStateException('Не найден jmeter.properties в classpath')
}

def propsFile = new File(jmeterHomeUrl.toURI())
def jmeterHome = propsFile.parentFile.parentFile.absolutePath

JMeterUtils.setJMeterHome(jmeterHome)
JMeterUtils.loadJMeterProperties(propsFile.absolutePath)
JMeterUtils.setProperty('language', 'en')
JMeterUtils.setProperty('country', 'US')
JMeterUtils.initLocale()
initialized = true
}

Разбор:

  • volatile boolean initialized — видимость флага между потоками после инициализации;
  • synchronized на методе — только один поток выполняет инициализацию JMeter;
  • classLoader.getResource(...) — файл из src/main/resources, упакованный в JAR;
  • new File(jmeterHomeUrl.toURI()) — корректный путь и для файловой системы IDE, и для JAR;
  • setJMeterHome + loadJMeterProperties — обязательная пара перед первым engine.run().

4.2 Метод execute

HttpResponseResult execute(HttpRequestConfig config) {
ensureInitialized()

URI uri
try {
uri = URI.create(config.url.trim())
} catch (Exception e) {
return HttpResponseResult.error("Некорректный URL: ${e.message}")
}

if (!uri.scheme || !uri.host) {
return HttpResponseResult.error('URL должен содержать схему и хост, например https://api.example.com/users')
}

def capture = new CaptureResultCollector()

def loopController = new LoopController()
loopController.loops = 1
loopController.first = true
loopController.initialize()

def threadGroup = new ThreadGroup()
threadGroup.name = 'API Tester Thread Group'
threadGroup.numThreads = 1
threadGroup.rampUp = 1
threadGroup.samplerController = loopController

def testPlan = new TestPlan('REST API Test')
testPlan.userDefinedVariables = (Arguments) new Arguments().tap { addArgument('API_TEST', 'true') }

def tree = new ListedHashTree()
def testPlanTree = tree.add(testPlan)
def threadGroupTree = testPlanTree.add(threadGroup)
threadGroupTree.add(loopController)

def sampler = buildSampler(uri, config)
def samplerTree = threadGroupTree.add(sampler)

def headerManager = buildHeaderManager(config.headers, config.body)
if (headerManager) {
samplerTree.add(headerManager)
}

samplerTree.add(capture)

def engine = new StandardJMeterEngine()
engine.configure(tree)

try {
engine.run()
waitForEngine(engine)
} catch (Exception e) {
return HttpResponseResult.error("Ошибка JMeter: ${e.message}")
} finally {
engine.exit()
}

def sample = capture.capturedResult
if (!sample) {
return HttpResponseResult.error('JMeter не вернул результат запроса')
}

if (!sample.successful && sample.responseCode == null) {
return HttpResponseResult.error(sample.responseMessage ?: 'Запрос завершился с ошибкой')
}

new HttpResponseResult(
success: sample.successful,
statusCode: parseStatusCode(sample.responseCode),
responseTimeMs: sample.time,
responseSizeBytes: sample.bytesAsLong,
responseBody: sample.responseDataAsString ?: '',
responseHeaders: sample.responseHeaders ?: '',
requestHeaders: formatRequestHeaders(sample),
contentType: sample.contentType ?: '',
errorMessage: sample.successful ? null : (sample.responseMessage ?: 'HTTP ошибка')
)
}

Разбор:

  • ранний return HttpResponseResult.error(...)Railway-oriented стиль без глубокой вложенности if/else;
  • LoopController с loops = 1 — ровно одна итерация sampler;
  • numThreads = 1 — один виртуальный пользователь; для ручного тестера достаточно;
  • .tap { addArgument('API_TEST', 'true') } — Groovy-идиома (18.md) — настроить Arguments и вернуть тот же объект;
  • engine.exit() в finally — освободить ресурсы engine после каждого запроса;
  • sample.time — время ответа в миллисекундах; bytesAsLong — размер тела.

Живая теория — дерево JMeter. ListedHashTree — не абстрактное дерево, а контракт "кто чьим родителем является". Минимальный сценарий одного запроса:

TestPlan
└── ThreadGroup
└── LoopController
└── HTTPSamplerProxy
├── HeaderManager (опционально)
└── CaptureResultCollector

В GUI JMeter вы кликаете правой кнопкой и "Add" — здесь то же самое вызовами tree.add(...). Для нагрузочного теста увеличивают numThreads и loops; код buildSampler остаётся.

4.3 HTTPSampler и заголовки

private static HTTPSamplerProxy buildSampler(URI uri, HttpRequestConfig config) {
def sampler = new HTTPSamplerProxy()
sampler.name = 'REST Request'
sampler.protocol = uri.scheme
sampler.domain = uri.host
sampler.port = uri.port > 0 ? uri.port : (uri.scheme == 'https' ? 443 : 80)

def path = uri.rawPath ?: '/'
if (uri.rawQuery) {
path += "?${uri.rawQuery}"
}
sampler.path = path
sampler.method = config.method.toUpperCase()
sampler.connectTimeout = config.connectTimeoutMs
sampler.responseTimeout = config.responseTimeoutMs
sampler.followRedirects = true
sampler.useKeepAlive = true

if (config.body && config.method.toUpperCase() in ['POST', 'PUT', 'PATCH']) {
sampler.postBodyRaw = true
def args = new Arguments()
args.addArgument('', config.body)
sampler.arguments = args
}

sampler
}

private static HeaderManager buildHeaderManager(Map<String, String> headers, String body) {
def manager = new HeaderManager()
manager.name = 'HTTP Header Manager'

headers.each { name, value ->
if (name?.trim()) {
manager.add(new Header(name.trim(), value ?: ''))
}
}

if (body && !headers.keySet().any { it.equalsIgnoreCase('Content-Type') }) {
manager.add(new Header('Content-Type', 'application/json; charset=UTF-8'))
}

manager.headers.size() > 0 ? manager : null
}

Разбор:

  • тернарный оператор для порта — явный порт в URL или 443/80 по схеме;
  • rawPath / rawQuery — path и query без перекодирования; JMeter сам собирает строку запроса;
  • method.toUpperCase() in ['POST', 'PUT', 'PATCH'] — оператор in для списка (операторы);
  • postBodyRaw = true — тело "как есть", не application/x-www-form-urlencoded;
  • addArgument('', config.body) — JMeter-к convention для raw body;
  • equalsIgnoreCase('Content-Type') — не дублировать Content-Type, если пользователь задал вручную;
  • последняя строка buildHeaderManager — Groovy возвращает значение последнего выражения (особенности).

4.4 Перехват результата

private static class CaptureResultCollector extends ResultCollector {

volatile SampleResult capturedResult

@Override
void sampleOccurred(SampleEvent event) {
capturedResult = event.result
super.sampleOccurred(event)
}
}
}

Разбор:

  • ResultCollector — listener JMeter; по умолчанию пишет в файл или агрегатор;
  • переопределение sampleOccurred — перехватить SampleResult в память для UI;
  • volatile — другой поток (worker Swing) прочитает результат после engine.run().

Вспомогательные методы parseStatusCode, formatRequestHeaders, waitForEngine — в полном файле эталонного проекта; waitForEngine опрашивает engine.isActive() с паузой 50 ms до 60 s, чтобы не вернуть пустой результат раньше времени.


Шаг 5 · ApiTesterFrame

Файл src/main/groovy/apitester/ui/ApiTesterFrame.groovy.

5.1 Каркас окна

package apitester.ui

import apitester.jmeter.JMeterHttpExecutor
import apitester.model.HttpRequestConfig
import apitester.model.HttpResponseResult
import groovy.swing.SwingBuilder

import javax.swing.*
import java.awt.*
import java.awt.event.KeyEvent

class ApiTesterFrame extends JFrame {

private final JMeterHttpExecutor executor = new JMeterHttpExecutor()

private JTextField urlField
private JComboBox<String> methodCombo
private JTextArea headersArea
private JTextArea bodyArea
private JTextArea responseBodyArea
private JTextArea responseHeadersArea
private JLabel statusLabel
private JLabel metaLabel
private JButton sendButton
private JProgressBar progressBar

ApiTesterFrame() {
title = 'Groovy API Tester (JMeter)'
defaultCloseOperation = EXIT_ON_CLOSE
minimumSize = new Dimension(900, 650)
setSize(1000, 720)
setLocationRelativeTo(null)
contentPane = buildContent()
setupShortcuts()
}

Разбор:

  • extends JFrame — главное окно Swing; Groovy генерирует вызовы сеттеров (title =, minimumSize =);
  • поля виджетов объявлены заранее — в SwingBuilder им присваивают ссылку (urlField = textField(...));
  • setLocationRelativeTo(null) — центр экрана;
  • один экземпляр JMeterHttpExecutor на окно — инициализация JMeter переиспользуется.

Живая теория — SwingBuilder как DSL. Вложенный блок:

panel {
gridBagLayout()
label(text: 'URL:', gridx: 0, gridy: 0)
urlField = textField(text: 'https://httpbin.org/get', gridx: 1, gridy: 0)
}

SwingBuilder ставит closure.delegate = builder; вызов label(...) уходит в builder — тот же delegate, что dependencies { implementation ... } в Gradle (11.md). Именованные параметры gridx, fill — Map-аргументы Groovy.

5.2 Компоновка экрана

В buildContent():

  • NORTH — URL, комбобокс метода (GET … OPTIONS), кнопка "Отправить";
  • CENTERtabbedPane с вкладками "Заголовки", "Тело запроса", "Ответ";
  • SOUTH — indeterminate progressBar на время запроса.

Заголовки по умолчанию:

Accept: application/json
User-Agent: GroovyAPITester/1.0

Правила ввода заголовков:

  • формат Имя: значение, одна пара на строку;
  • строки, начинающиеся с #, — комментарии;
  • пустые строки пропускаются.

5.3 Отправка запроса в фоне

private void sendRequest() {
def url = urlField.text?.trim()
if (!url) {
JOptionPane.showMessageDialog(this, 'Введите URL', 'Ошибка', JOptionPane.WARNING_MESSAGE)
return
}

setUiBusy(true)
statusLabel.text = 'Статус: выполняется запрос...'
metaLabel.text = 'Время: — Размер: —'
responseBodyArea.text = ''
responseHeadersArea.text = ''

def config = new HttpRequestConfig(
url: url,
method: methodCombo.selectedItem as String,
headers: parseHeaders(headersArea.text),
body: bodyArea.text ?: ''
)

SwingWorker<HttpResponseResult, Void> worker = new SwingWorker<HttpResponseResult, Void>() {
@Override
protected HttpResponseResult doInBackground() {
executor.execute(config)
}

@Override
protected void done() {
try {
showResult(get())
} catch (Exception e) {
showResult(HttpResponseResult.error(e.message ?: e.class.simpleName))
} finally {
setUiBusy(false)
}
}
}
worker.execute()
}

Разбор:

  • urlField.text?.trim() — safe navigation (Groovy и Java); null если виджет пуст;
  • selectedItem as String — явное приведение из Object комбобокса;
  • bodyArea.text ?: '' — Elvis-оператор: null → пустая строка (операторы);
  • doInBackground()запрещено трогать Swing; только executor.execute;
  • get() в done() — результат worker или исключение;
  • setUiBusy(false) в finally — кнопка снова активна даже при ошибке.

Живая теория — EDT. Swing однопоточный для UI: Event Dispatch Thread рисует кнопки и обрабатывает клики. Долгий HTTP на EDT блокирует отрисовку — окно "не отвечает". SwingWorker — стандартный паттерн Java/Swing:

МетодПотокМожно трогать UI
doInBackground()workerнет
done()EDTда

JMeter внутри execute() тоже создаёт потоки — поэтому не вызывайте execute() из EDT напрямую.

5.4 Парсинг заголовков и JSON

private static Map<String, String> parseHeaders(String text) {
if (!text?.trim()) {
return [:]
}

def headers = [:]
text.readLines().each { line ->
def trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
return
}
def separatorIndex = trimmed.indexOf(':')
if (separatorIndex > 0) {
def name = trimmed.substring(0, separatorIndex).trim()
def value = trimmed.substring(separatorIndex + 1).trim()
headers[name] = value
}
}
headers
}

private static String formatBody(String body) {
if (!body) {
return ''
}
def trimmed = body.trim()
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
def json = new groovy.json.JsonSlurper().parseText(trimmed)
return groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(json))
} catch (Exception ignored) {
}
}
body
}

Разбор:

  • readLines().each — GDK на String (172.md);
  • return внутри each — выход только из текущей итерации, не из метода parseHeaders;
  • separatorIndex > 0 — строка без : или с : в начале игнорируется;
  • JsonSlurper.parseText — разбор JSON в Map/List без Jackson (103.md);
  • prettyPrint(toJson(json)) — читаемый отступ в textarea; при ошибке парсинга возвращаем сырое тело.

Шаг 6 · Main.groovy

package apitester

import apitester.ui.ApiTesterFrame
import javax.swing.SwingUtilities
import javax.swing.UIManager

class Main {

static void main(String[] args) {
SwingUtilities.invokeLater {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
} catch (Exception ignored) {
}
new ApiTesterFrame().visible = true
}
}
}

Разбор:

  • SwingUtilities.invokeLater { } — постановка задачи на EDT; main может быть вызван не из EDT;
  • getSystemLookAndFeelClassName() — нативный вид Windows/macOS/Linux вместо Metal;
  • new ApiTesterFrame().visible = true — Groovy-сеттер для setVisible(true) (особенности);
  • пустой catch — если L&F недоступен, остаётся дефолтный; приложение всё равно стартует.

Шаг 7 · SmokeTest

package apitester

import apitester.jmeter.JMeterHttpExecutor
import apitester.model.HttpRequestConfig

class SmokeTest {

static void main(String[] args) {
def executor = new JMeterHttpExecutor()
def config = new HttpRequestConfig(
url: args ? args[0] : 'https://httpbin.org/get',
method: 'GET'
)

def result = executor.execute(config)
println "status=${result.statusCode} time=${result.responseTimeMs}ms success=${result.success}"

if (result.errorMessage) {
println "error=${result.errorMessage}"
}

if (!result.success || result.statusCode != 200) {
System.exit(1)
}
}
}

Разбор:

  • args ? args[0] : '...' — Groovy truthiness: пустой массив аргументов → дефолтный URL;
  • GString в println — подстановка полей результата;
  • System.exit(1) — ненулевой код для CI; Jenkins/GitHub Actions помечают stage как failed (22.md).

Запуск:

gradle smokeTest
gradle smokeTest --args="https://httpbin.org/post"

Живая теория. Smoke-тест проверяет цепочку целиком — classpath, JMeter init, сеть, парсинг ответа — без поднятия GUI. Его ставят после gradle build в pipeline рядом с unit-тестами (уровни тестирования).


Запуск и проверка

Первый запуск

cd GroovyAPITester
gradle wrapper
gradle run

Windows:

.\gradlew.bat run

Пример POST-запроса

  1. URL — https://httpbin.org/post
  2. Метод — POST
  3. Тело:
{"name": "test"}
  1. Кнопка "Отправить" или Ctrl+Enter

Ожидайте статус 200 и JSON, где поле json повторяет отправленное тело — httpbin эхо-сервис для отладки клиентов.

Сборка дистрибутива

gradle installDist

Исполняемые скрипты — build/install/GroovyAPITester/bin/GroovyAPITester (.bat на Windows). Дистрибутив включает JAR и classpath — JVM на целевой машине обязательна (JVM и байт-код).


Groovy-идиомы в проекте

Место в кодеИдиомаРаздел
HttpRequestConfigPOGO, defaults, [:]12.md
new HttpResponseResult(success: false, ...)Named args конструктора15.md
.tap { addArgument(...) }Настройка объекта in-place18.md
method in ['POST', 'PUT']in для коллекций13.md
SwingBuilder nested blocksDelegate / DSL11.md
JsonSlurper / JsonOutputJSON без Jackson103.md
urlField.text?.trim()Safe navigation20.md
body ?: ''Elvis-оператор13.md
последнее выражение методаНеявный return16.md

Частые ошибки

СимптомВероятная причинаЧто проверить
Не найден jmeter.propertiesФайл не в src/main/resources/jmeter/bin/Путь и задача processResources
No SLF4J providersНет log4j-slf4j2-implБлок logging в build.gradle
UI "зависает" на запросеHTTP выполняется в EDTОбернуть в SwingWorker
Пустое тело ответаsaveservice.response_data=falsejmeter.properties
SSL / certificate errorsСамоподписанный сертификатTrust store JMeter или тестовый стенд
MissingPropertyException в UIЗабыли urlField = textField(...)Присвоение полям в SwingBuilder
POST без тела на сервереНет Content-TypebuildHeaderManager добавляет JSON
mainClass not foundРасхождение package и Gradleapplication.mainClass (23.md)
Кракозябры в UIНеверная кодировка-Dfile.encoding=UTF-8 в JavaExec

Расширения по желанию

  1. Spock-тест на parseHeaders с таблицей where:21.md.
  2. История запросов — последние URL в Preferences или JSON-файле (работа с файлами).
  3. Экспорт в .jmx — сохранить sampler как JMeter-план для нагрузочного теста.
  4. Basic Auth — поля login/password → заголовок Authorization: Basic ....
  5. Stage smokeTest в CI — Shared Library, DevOps.
  6. @CompileStatic на parseHeaders — если профилируете hot path (16.md).

Связь с разделом Groovy

ЗадачаМатериал
Gradle, wrapper, runGradle Groovy DSL
Замыкания, delegateОсновы — §11
JSON в скриптахПростые приложения
Тесты моделейSpock
CI со smoke-тестомJenkins Pipeline
Игра на GroovyFastJ
HTTP-теорияHTTP — основа
Тестирование APIAPI-тестирование
Нагрузка и JMeterВиды тестирования

Что попробовать

  1. Метод HEAD — тело ответа пустое, заголовки присутствуют.
  2. Spock-тест — parseHeaders('A: 1\n# comment\nB: 2')[A:'1', B:'2'].
  3. Stage gradle smokeTest в Jenkinsfile репозитория.
  4. Сравнить время ответа с curl — curl -w '%{time_total}\n' -o NUL -s URL (Windows) или -o /dev/null (Linux/macOS).
  5. Запрос с намеренно неверным URL — убедиться, что UI показывает HttpResponseResult.error, а не stack trace.

Дальше

Spock — первая спецификация · Jenkins Pipeline · Gradle Groovy DSL · Итоги