JMeter: Hâlâ Doğru Araç Olduğu Yerler, Test Plan, Thread Group, CLI Modu ve HTML Dashboard
JMeter, JDBC, JMS ve LDAP gibi geniş protokol desteği, mevcut test planları ve kod yazmayan test ekipleri için hâlâ doğru araç; HTTP API için k6 daha uygun. Bookstore senaryosunu JMeter 5.6.3'te Open Model Thread Group, CSV Data Set, JSON Extractor ve timer'larla kurup CLI modunda koşturuyor, HTML dashboard'u okuyor ve timer scope'u, APDEX eşikleri ile exit code gibi tuzakları gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; yük testleri Apache JMeter 5.6.3 (Java 21 üzerinde) ile, Bookstore.Api ve SQL Server 2025 Docker Compose üzerinde çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kurumsal müşteri tarafında Hakan'ın ekibi, okul ve kütüphanelerle yıllardır JMeter test planlarıyla çalışan bir QA (Quality Assurance; kalite güvencesi) ekibiyle konuşuyor. "k6'ya geçin" demek kolay; peki JMeter'ın hâlâ doğru araç olduğu durumlar var mı? İki teknik soru da şunlar. Test planında thread group seviyesine 2-5 saniyelik bir timer koyarsan bu bekleme oturum başına bir kez mi uygulanır, yoksa her istekten önce mi? JMeter'ın klasik Thread Group'u closed model'dir; 01'de gördüğümüz sorunun JMeter'daki karşılığı ne ve bundan nasıl kaçınılır? Bu yazıda Bookstore senaryosunu JMeter'da kurup CLI'da koşturuyor ve HTML dashboard'u okuyoruz.
JMeter Ne Zaman Hâlâ Doğru Araç?
JMeter, Apache Software Foundation'ın Java ile yazılmış, 1998'den beri geliştirilen load test aracı. Test planları XML (.jmx) dosyası olarak saklanıyor ve genellikle bir GUI'de (grafik kullanıcı arayüzü) tasarlanıyor. k6 ile karşılaştırınca güçlü olduğu yerler belli. Birincisi protokol desteği: JMeter, eklenti kurmadan JDBC (Java Database Connectivity; Java uygulamalarının veritabanına bağlanma API'si) ile doğrudan veritabanına, JMS (Java Message Service; Java'nın mesajlaşma API'si) ile mesaj broker'larına, LDAP (Lightweight Directory Access Protocol; kullanıcı ve kurum dizinlerine erişim protokolü), FTP (File Transfer Protocol; dosya aktarım protokolü), SMTP (Simple Mail Transfer Protocol; e-posta gönderim protokolü), TCP gibi protokollere istek gönderebiliyor. Bir message queue'nun ya da bir LDAP dizininin kapasitesini ölçmen gerekiyorsa JMeter'da hazır bir sampler var. İkincisi mevcut yatırım: yıllar içinde biriken yüzlerce .jmx dosyası ve bunları bilen bir ekip, yeni bir araca geçmenin maliyetini büyütür. Üçüncüsü kod yazmayan test ekipleri: GUI ile senaryo kurmak, JavaScript bilmeyen bir QA mühendisi için daha erişilebilir.
k6'yı seçeceğin durumlar da belli: test kodunun uygulama koduyla aynı repoda, code review'dan geçerek yaşaması; CI'da hafif bir binary ile koşmak; open model'in varsayılan olması ve threshold'ların test içinde tanımlanması. Bookstore'un HTTP API'si için k6'yı tercih ediyoruz. Kurumsal tarafta JDBC ile doğrudan raporlama veritabanına yük vermek gerekirse JMeter'a döneriz.
Sürüm durumu şu: son kararlı sürüm Ocak 2024'te çıkan 5.6.3. Java 8 ve üstünde çalışıyor, Java 17 ve üstü öneriliyor. Projenin sıradaki ana sürümü 6.0, minimum Java sürümünü 17'ye çıkarmayı hedefliyor ve geliştirme sürüyor. Bu yazı yazılırken 6.0 için yayımlanmış bir sürüm yok. Ben 5.6.3'ü Java 21 ile kullandım.
Test Planının Anatomisi
Bir JMeter test planı ağaç yapısındadır ve bir elementin etkisi ağaçtaki yerine bağlıdır. Bookstore planı şu parçalardan oluşuyor: tüm isteklerin host ve port'unu tek yerde tutan HTTP Request Defaults, Content-Type header'ı için bir Header Manager, ISBN'leri (International Standard Book Number; kitabın uluslararası kimlik numarası) okuyan bir CSV (virgülle ayrılmış düz metin) Data Set Config ve oturumları üreten bir thread group. Thread group'un içinde de sampler'lar (HTTP istekleri), onların altında assertion'lar ve extractor'lar, aralarda timer'lar ve bir controller var. CSV ve thread group şöyle:
<CSVDataSet guiclass="TestBeanGUI" testclass="CSVDataSet" testname="ISBNs">
<stringProp name="filename">../data/isbns.txt</stringProp>
<stringProp name="fileEncoding">UTF-8</stringProp>
<stringProp name="variableNames">isbn</stringProp>
<boolProp name="ignoreFirstLine">false</boolProp>
<stringProp name="delimiter">,</stringProp>
<boolProp name="quotedData">false</boolProp>
<boolProp name="recycle">true</boolProp>
<boolProp name="stopThread">false</boolProp>
<stringProp name="shareMode">shareMode.all</stringProp>
</CSVDataSet>
<OpenModelThreadGroup guiclass="OpenModelThreadGroupGui" testclass="OpenModelThreadGroup" testname="Sessions (open model)">
<stringProp name="OpenModelThreadGroup.schedule">rate(${__P(rate,10)}/sec) random_arrivals(${__P(duration,60)} sec)</stringProp>
<stringProp name="OpenModelThreadGroup.random_seed"></stringProp>
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
</OpenModelThreadGroup>
CSV Data Set Config, 02'de ürettiğimiz isbns.txt'yi satır satır okuyor ve her iterasyonda isbn değişkenine sıradaki değeri koyuyor. shareMode.all bütün thread'lerin aynı dosya imlecini paylaştığı anlamına geliyor. Bu k6'daki Zipf benzeri dağılım değil, sıralı okuma. Popülerlik dağılımı istiyorsan JSR223 (Java'da Groovy gibi betik dillerini çalıştırma standardı) PreProcessor ile Groovy'de bir seçim yazman gerekir. ${__P(host,localhost)} gibi ifadeler JMeter property'leri: komut satırında -Jhost=... ile verilir, verilmezse varsayılan değer kullanılır. Bu sayede aynı plan farklı ortamlarda değiştirilmeden koşar.
Thread group olarak klasik Thread Group yerine 5.5 ile gelen Open Model Thread Group'u kullandım. Schedule ifadesi rate(10/sec) random_arrivals(120 sec) saniyede ortalama 10 yeni oturum başlatır ve oturumların gelişini rastgele dağıtır. Her oturum ağacı bir kez çalıştırıp biter. Bu k6'nın constant-arrival-rate'ine karşılık geliyor. JMeter dokümantasyonu bu thread group'u hâlâ deneysel olarak işaretliyor. Daha eski sürümlerde ya da daha esnek profiller için jmeter-plugins.org'daki Concurrency Thread Group ve Arrivals Thread Group gibi eklentiler kullanılıyor.
Timer ve controller'lar:
<UniformRandomTimer guiclass="UniformRandomTimerGui" testclass="UniformRandomTimer" testname="Think 2-5 s">
<stringProp name="ConstantTimer.delay">2000</stringProp>
<stringProp name="RandomTimer.range">3000</stringProp>
</UniformRandomTimer>
<ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="10% checkout">
<intProp name="ThroughputController.style">1</intProp>
<boolProp name="ThroughputController.perThread">false</boolProp>
<intProp name="ThroughputController.maxThroughput">1</intProp>
<FloatProperty>
<name>ThroughputController.percentThroughput</name>
<value>10.0</value>
</FloatProperty>
</ThroughputController>
İlk tahmin sorusunun cevabı JMeter'ın scope kuralında. Timer, bulunduğu seviyedeki ve altındaki her sampler'dan önce çalışır. Thread group seviyesindeki bu Uniform Random Timer bir oturumda bir kez değil, her istekten önce 2-5 saniye bekletir. Bookstore senaryosunda istediğim davranış bu, ama bir timer'ı yanlış seviyeye koyup testin beklenenden çok daha yavaş yük ürettiğini fark etmemek yaygın bir hata. Throughput Controller ise içindeki sampler'ları oturumların yüzde 10'unda çalıştırıyor; checkout akışı bu yüzden oturumların onda birinde var.
Checkout akışında bir istekten dönen değeri bir sonrakine taşımak gerekiyor. JSON Extractor, sepet oluşturma cevabındaki id'yi cartId değişkenine yazıyor. Response Assertion da status code'u kontrol ediyor:
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="cartId">
<stringProp name="JSONPostProcessor.referenceNames">cartId</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.id</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND</stringProp>
</JSONPostProcessor>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="PUT /api/carts/{id}/items/{isbn}">
<stringProp name="HTTPSampler.path">/api/carts/${cartId}/items/${isbn}</stringProp>
<stringProp name="HTTPSampler.method">PUT</stringProp>
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{ "quantity": 1 }</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</HTTPSamplerProxy>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Status 201">
<collectionProp name="Asserion.test_strings">
<stringProp name="49587">201</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
JSON Extractor eşleşme bulamazsa NOT_FOUND yazar; sonraki istek /api/carts/NOT_FOUND/items/...'a gider ve 404 alır. Bu yüzden zincirdeki her kritik isteğe bir assertion koymak gerekir. Assertion başarısız olan sample, sonuçta hata olarak sayılır.
GUI Tasarım İçin, Koşum CLI'da
JMeter'ın GUI'si planı kurmak ve tek bir thread ile hata ayıklamak için. Yük testi GUI'den koşturulmaz: GUI ve özellikle View Results Tree gibi listener'lar her sonucu bellekte tutar ve ekrana çizer; yük üreten makinenin CPU ve belleğini sonuçlar yerine arayüz harcar. JMeter dokümantasyonunun best practices bölümü de yükü CLI modunda koşturmayı ve listener'ları kapatmayı önerir. Koşum şöyle:
# PowerShell (Windows'ta jmeter.bat)
jmeter -n -t load/jmeter/bookstore.jmx -Jrate=10 -Jduration=120 `
-l results/results.jtl -j results/jmeter.log -e -o results/report
-n GUI'siz mod, -t test planı, -l her sample'ın yazıldığı sonuç dosyası (JTL), -j JMeter'ın kendi log dosyası, -e -o ise test bitince HTML dashboard üretir. -j'yi verme alışkanlığı edin: verilmezse jmeter.log komutu çalıştırdığın klasöre, genellikle reponun köküne düşer. Koşu sırasında JMeter her 30 saniyede bir özet basar:
summary + 696 in 00:00:30 = 23.1/s Avg: 13 Min: 1 Max: 55 Err: 0 (0.00%) Active: 76 Started: 603 Finished: 527
summary = 1247 in 00:00:59 = 21.0/s Avg: 14 Min: 1 Max: 126 Err: 0 (0.00%)
summary + 703 in 00:00:30 = 23.5/s Avg: 14 Min: 1 Max: 138 Err: 0 (0.00%) Active: 76 Started: 906 Finished: 830
summary = 1950 in 00:01:29 = 21.8/s Avg: 14 Min: 1 Max: 138 Err: 0 (0.00%)
summary + 670 in 00:00:30 = 22.4/s Avg: 13 Min: 1 Max: 132 Err: 0 (0.00%) Active: 71 Started: 1196 Finished: 1125
summary = 2620 in 00:01:59 = 22.0/s Avg: 14 Min: 1 Max: 138 Err: 0 (0.00%)
summary + 6 in 00:00:01 = 9.4/s Avg: 13 Min: 3 Max: 42 Err: 0 (0.00%) Active: 0 Started: 1200 Finished: 1200
summary = 2626 in 00:02:00 = 21.9/s Avg: 14 Min: 1 Max: 138 Err: 0 (0.00%)
+ satırları son 30 saniyeyi, = satırları başlangıçtan bu yana toplamı gösteriyor. 120 saniyede 1.200 oturum başladı, yani saniyede tam 10. Active sütunu aynı anda açık oturum sayısı: 71-76. Little's Law ile kontrol edelim: bir oturum ortalama 2,3 istek atıyor ve her istekten önce ortalama 3,5 saniye bekliyor, yani yaklaşık 8 saniye sürüyor. Saniyede 10 oturumla aynı anda 80 civarı oturum beklenir; ölçülen 71-76 buna yakın. Son satırlarda dikkat edilecek bir ayrıntı daha var: schedule bitince JMeter henüz bitmemiş oturumları da durduruyor. Bu yüzden 1.200 oturumda liste isteği 1.166 kez gitti. Son oturumların tamamlanmasını istiyorsan schedule'ın sonuna bir pause eklemen gerekir.
HTML Dashboard'u Okumak
-e -o ile üretilen klasörde index.html açıldığında ilk sayfada APDEX (Application Performance Index; cevap sürelerini "memnun, tolere edilebilir, memnun değil" diye sınıflayan bir skor) tablosu, istek özeti ve istatistik tablosu var. Charts menüsünde zaman serileri bulunuyor: zamana göre cevap süresi, saniyedeki istek, aktif thread sayısı ve percentile dağılımı. Aynı istatistikler statistics.json dosyasında da duruyor:
| Sampler | Adet | Hata | Ortalama | Medyan | p90 | p95 | p99 |
|---|---|---|---|---|---|---|---|
| GET /api/books | 1.166 | 0 | 25,1 ms | 22 ms | 34 ms | 40 ms | 63 ms |
| GET /api/books/{isbn} | 1.140 | 0 | 4,2 ms | 3 ms | 6 ms | 8 ms | 13 ms |
| POST /api/carts | 110 | 0 | 6,3 ms | 6 ms | 9 ms | 13 ms | 31 ms |
| PUT /api/carts/{id}/items/{isbn} | 106 | 0 | 10,4 ms | 9 ms | 15 ms | 21 ms | 48 ms |
| POST /api/orders | 104 | 0 | 14,0 ms | 11 ms | 21 ms | 37 ms | 70 ms |
| Toplam | 2.626 | 0 | 14,2 ms | 10 ms | 28 ms | 34 ms | 52 ms |
Tabloyu okurken üç şeye dikkat et. Percentile sütunlarının adı dashboard'da "90th pct", "95th pct", "99th pct"; JSON'da pct1ResTime, pct2ResTime, pct3ResTime. Hangi percentile'ların hesaplanacağı aggregate_rpt_pct1..3 property'leriyle değişir. JMeter süreleri milisaniye tamsayısı olarak kaydediyor; 1 ms'nin altındaki istekler için k6'nın mikrosaniye çözünürlüğü yok. "Toplam" satırı farklı endpoint'leri tek bir dağılıma topluyor; 03'te k6'nın genel satırı için söylediğimiz gibi asıl bilgi endpoint satırlarında. Sipariş p95'i burada 37 ms, çünkü bu koşuda ödeme stub'ı çalışmıyordu.
APDEX'in varsayılan eşikleri 500 ms (memnun) ve 1.500 ms (tolere edilebilir). Bookstore'un SLO'su (Service Level Objective; servisin hedeflenen güvenilirlik seviyesi) kitap detayı için 100 ms gibi daha sıkı hedefler koyuyorsa bu eşikleri jmeter.reportgenerator.apdex_satisfied_threshold ve apdex_tolerated_threshold property'leriyle SLO'ya göre ayarla; yoksa her şey yeşil görünür. Son olarak JMeter'da k6'nın threshold'larının birebir karşılığı yok. CLI koşusu, assertion'lar başarısız olsa bile 0 exit code ile biter. Planı kapalı bir porta yönlendirip denedim: özet Err: 4 (100.00%) gösterdi, exit code yine 0'dı. CI'da gate istiyorsan JTL ya da statistics.json üzerinden eşik kontrolünü ayrı bir adımda yazman gerekir.
Üretimde Dikkat
Heap ayarını kontrol et. JMeter'ın başlatma script'i varsayılan olarak 1 GB heap ile açılıyor (-Xms1g -Xmx1g). Binlerce thread, büyük cevaplar ya da bellekte sonuç tutan listener'lar bunu hızla doldurur ve GC (garbage collector; kullanılmayan object'leri temizleyen runtime bileşeni) duraklamaları yük üreticisinin gecikmesi olarak sonuçlara yansır. Heap'i HEAP environment variable'ı ile büyütebilirsin, ama önce listener'ları ve gereksiz cevap kaydını kapat. JTL'e yalnız gereken alanları yazmak (jmeter.save.saveservice.* property'leri) disk ve bellek kullanımını azaltır.
Yük üreticinin kendisini izle. Bu koşuda JMeter 3 dakikalık süre boyunca yalnız 7 saniye kadar CPU zamanı harcadı, çünkü yük düşüktü. Thread sayısı binlere çıktığında JVM'in (Java Virtual Machine; Java kodunu çalıştıran sanal makine) bir thread'i ayrı bir işletim sistemi thread'i olarak çalıştırdığını unutma; k6'nın goroutine'lerine göre bellek ve context switch maliyeti daha yüksek. Bu yazıdaki sayılar tek bir makinede alındı ve mutlak değerleri makineye özel.
İkinci tahmin sorusunun cevabı: klasik Thread Group sabit sayıda thread'le çalışır; her thread cevabı bekler, sonra sıradaki isteği gönderir. Bu 01'deki closed model ve sistem yavaşladığında JMeter da k6 gibi yükü kendiliğinden azaltır. Yavaş isteklerin ölçümden düşmesi literatürde coordinated omission olarak biliniyor. Dışarıdan gelen trafiği modellemek için Open Model Thread Group ya da Constant Throughput Timer gibi hız odaklı bileşenleri kullan ve "aktif thread" sayısının hedef hızla uyumlu olup olmadığını izle. Coordinated omission'ın percentile'ları nasıl bozduğunu bir sonraki yazıda ölçüyoruz.
Sık Düşülen Tuzaklar
- Yükü GUI'den, View Results Tree açıkken koşturmak.
- Timer'ı yanlış seviyeye koyup her istekten önce beklediğini fark etmemek.
- JSON Extractor'ın
NOT_FOUNDdeğerini assertion'sız bir sonraki isteğe taşımak. - CLI koşusunun exit code'unu gate sanmak; assertion hatası exit code'u değiştirmez.
- APDEX eşiklerini varsayılan bırakıp SLO'dan gevşek bir skorla yetinmek.
- Klasik Thread Group ile dışarıdan gelen trafiği modellemek ve yavaşlamayı ölçümden düşürmek.
-jve-lvermeden çalıştırıp log ve sonuç dosyalarını reponun içine bırakmak.
Küçük Bir Deneme
★☆☆ Timer'ı thread group seviyesinden yalnız ikinci sampler'ın altına taşı ve aynı schedule ile koştur. Active sütunu nasıl değişti? Little's Law ile açıkla.
★★☆ apdex_satisfied_threshold'u 100 ms, apdex_tolerated_threshold'u 300 ms yap ve dashboard'u jmeter -g results.jtl -o report2 ile yeniden üret. Hangi endpoint "memnun değil" bölgesine düştü?
★★★ Plana bir JDBC Connection Configuration ve JDBC Request ekleyerek BookstoreDb'de katalog arama sorgusunu doğrudan çalıştır. API üzerinden yapılan aramanın süresinin ne kadarı veritabanında geçiyor?
k6 ve JMeter'la sayı üretmeyi öğrendik. Bir sonraki yazıda bu sayıları doğru okumaya geçiyoruz: ortalamanın neden yanılttığı, percentile'ların ortalamasının neden alınamayacağı, coordinated omission'ı gerçek bir duraklamayla ölçmek, throughput-latency eğrisi ve darboğazı USE yöntemiyle bulmak.