Практикум — 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 API | HTTP-интерфейс сервера — ресурсы по 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 |
| DTO | Data Transfer Object — класс только для передачи данных между слоями |
| POGO | Plain Old Groovy Object — класс с полями без лишнего boilerplate |
| Apache JMeter | Инструмент нагрузочного и функционального тестирования — HTTP, JDBC, JMS и др. |
| HTTPSamplerProxy | JMeter-компонент, описывающий один HTTP-запрос (хост, путь, метод, тело) |
| SampleResult | Объект JMeter с результатом одного sampler — код, время, тело, заголовки |
| TestPlan | Корень JMeter-сценария — контейнер всего плана |
| ThreadGroup | Группа виртуальных пользователей; у нас — один поток, одна итерация |
| StandardJMeterEngine | Программный запуск JMeter без .jmx и без GUI |
| ListedHashTree | Дерево элементов JMeter — иерархия TestPlan → ThreadGroup → Sampler |
| SwingBuilder | Groovy-обёртка над Swing — UI описывают вложенными блоками, как mini-DSL |
| SwingWorker | Фоновая задача Swing — doInBackground() не блокирует EDT |
| EDT | Event 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 или хотите единый стек "ручной запрос → нагрузочный сценарий".
Архитектура приложения
| Слой | Класс | Ответственность |
|---|---|---|
| Точка входа | Main | Look-and-feel ОС, запуск окна на EDT |
| UI | ApiTesterFrame | Форма, парсинг заголовков, отображение ответа |
| HTTP | JMeterHttpExecutor | Инициализация JMeter, сборка TestPlan, выполнение |
| Модели | HttpRequestConfig, HttpResponseResult | DTO запроса и ответа |
Живая теория. Разделение на слои — тот же принцип, что в ООП и тестировании:
- UI не знает про
HTTPSamplerProxy— только проHttpRequestConfig/HttpResponseResult; JMeterHttpExecutorне знает про Swing — его можно вызвать изSmokeTest, Spock или Jenkins;- модели не зависят ни от Swing, ни от JMeter — удобная точка для unit-тестов.
Поток данных при нажатии "Отправить":
ApiTesterFrameчитает поля формы → собираетHttpRequestConfig.SwingWorkerвызываетexecutor.execute(config)не в EDT.JMeterHttpExecutorстроит дерево JMeter, запускает engine, возвращаетHttpResponseResult.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 разбирает на части:
protocol—https;domain—api.example.com;port—8443(или 443/80 по умолчанию);path—/v1/users?page=2.
Если передать только api.example.com/users без схемы — URI.create не даст валидный запрос; мы явно проверяем scheme и host в execute().
Требования
- JDK 17+ — как для современного Gradle (Java — первая программа);
- Gradle или wrapper (
gradlew) — см. 23.md; - сеть — для примеров с httpbin.org;
- опционально IntelliJ IDEA с плагином Groovy — 2.md.
Шаг 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-swing | SwingBuilder для UI |
groovy-json | JsonSlurper, JsonOutput.prettyPrint |
ApacheJMeter_core | Движок, утилиты, JMeterUtils |
ApacheJMeter_http | HTTPSamplerProxy, HTTP-протокол |
ApacheJMeter_components | HeaderManager, listeners |
log4j + slf4j | JMeter пишет через 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— флаг JMetersample.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), кнопка "Отправить";
- CENTER —
tabbedPaneс вкладками "Заголовки", "Тело запроса", "Ответ"; - 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-запроса
- URL —
https://httpbin.org/post - Метод —
POST - Тело:
{"name": "test"}
- Кнопка "Отправить" или Ctrl+Enter
Ожидайте статус 200 и JSON, где поле json повторяет отправленное тело — httpbin эхо-сервис для отладки клиентов.
Сборка дистрибутива
gradle installDist
Исполняемые скрипты — build/install/GroovyAPITester/bin/GroovyAPITester (.bat на Windows). Дистрибутив включает JAR и classpath — JVM на целевой машине обязательна (JVM и байт-код).
Groovy-идиомы в проекте
| Место в коде | Идиома | Раздел |
|---|---|---|
HttpRequestConfig | POGO, defaults, [:] | 12.md |
new HttpResponseResult(success: false, ...) | Named args конструктора | 15.md |
.tap { addArgument(...) } | Настройка объекта in-place | 18.md |
method in ['POST', 'PUT'] | in для коллекций | 13.md |
SwingBuilder nested blocks | Delegate / DSL | 11.md |
JsonSlurper / JsonOutput | JSON без Jackson | 103.md |
urlField.text?.trim() | Safe navigation | 20.md |
body ?: '' | Elvis-оператор | 13.md |
| последнее выражение метода | Неявный return | 16.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=false | jmeter.properties |
| SSL / certificate errors | Самоподписанный сертификат | Trust store JMeter или тестовый стенд |
MissingPropertyException в UI | Забыли urlField = textField(...) | Присвоение полям в SwingBuilder |
| POST без тела на сервере | Нет Content-Type | buildHeaderManager добавляет JSON |
mainClass not found | Расхождение package и Gradle | application.mainClass (23.md) |
| Кракозябры в UI | Неверная кодировка | -Dfile.encoding=UTF-8 в JavaExec |
Расширения по желанию
- Spock-тест на
parseHeadersс таблицейwhere:— 21.md. - История запросов — последние URL в
Preferencesили JSON-файле (работа с файлами). - Экспорт в .jmx — сохранить sampler как JMeter-план для нагрузочного теста.
- Basic Auth — поля login/password → заголовок
Authorization: Basic .... - Stage
smokeTestв CI — Shared Library, DevOps. - @CompileStatic на
parseHeaders— если профилируете hot path (16.md).
Связь с разделом Groovy
| Задача | Материал |
|---|---|
Gradle, wrapper, run | Gradle Groovy DSL |
| Замыкания, delegate | Основы — §11 |
| JSON в скриптах | Простые приложения |
| Тесты моделей | Spock |
| CI со smoke-тестом | Jenkins Pipeline |
| Игра на Groovy | FastJ |
| HTTP-теория | HTTP — основа |
| Тестирование API | API-тестирование |
| Нагрузка и JMeter | Виды тестирования |
Что попробовать
- Метод HEAD — тело ответа пустое, заголовки присутствуют.
- Spock-тест —
parseHeaders('A: 1\n# comment\nB: 2')→[A:'1', B:'2']. - Stage
gradle smokeTestв Jenkinsfile репозитория. - Сравнить время ответа с curl —
curl -w '%{time_total}\n' -o NUL -s URL(Windows) или-o /dev/null(Linux/macOS). - Запрос с намеренно неверным URL — убедиться, что UI показывает
HttpResponseResult.error, а не stack trace.
Дальше
Spock — первая спецификация · Jenkins Pipeline · Gradle Groovy DSL · Итоги