test(performance): implement k6 load testing scenarios and java result parser
This commit is contained in:
+907
@@ -0,0 +1,907 @@
|
||||
package com.walkguide.performance;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* K6ResultParserTest
|
||||
*
|
||||
* Unit tests untuk memverifikasi logika parsing output k6 load test.
|
||||
* Tidak memerlukan file fisik atau koneksi eksternal — semua data di-mock sebagai
|
||||
* in-memory JSON (NDJSON format) sesuai dengan format output k6 --out json.
|
||||
*
|
||||
* Struktur NDJSON k6:
|
||||
* {"type":"Point","metric":"http_req_duration","data":{"time":"...","value":123.45,"tags":{"endpoint":"login"}}}
|
||||
*
|
||||
* Setiap nested class merepresentasikan satu aspek dari pipeline parsing:
|
||||
* 1. NdjsonParser — parsing NDJSON line-by-line
|
||||
* 2. MetricAggregator — aggregasi nilai per metric name
|
||||
* 3. StatCalculator — kalkulasi statistik (avg, p95, p99, dll)
|
||||
* 4. ThresholdEvaluator — evaluasi pass/fail per threshold
|
||||
* 5. KeyMetricExtractor — extract 5 key metrics wajib (Pillar 3)
|
||||
* 6. WalkGuideMetrics — breakdown per endpoint spesifik WalkGuide
|
||||
* 7. EndpointBreakdown — grouping metric berdasarkan tag endpoint
|
||||
* 8. EdgeCases — empty data, malformed JSON, single data point
|
||||
*/
|
||||
@DisplayName("K6ResultParser — Load Test Output Parsing")
|
||||
class K6ResultParserTest {
|
||||
|
||||
private ObjectMapper mapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helper: buat satu NDJSON data point seperti output k6
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
private ObjectNode makePoint(String metric, double value, String endpoint) {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
root.put("type", "Point");
|
||||
root.put("metric", metric);
|
||||
|
||||
ObjectNode data = mapper.createObjectNode();
|
||||
data.put("time", "2024-01-01T00:00:00Z");
|
||||
data.put("value", value);
|
||||
|
||||
ObjectNode tags = mapper.createObjectNode();
|
||||
tags.put("endpoint", endpoint);
|
||||
data.set("tags", tags);
|
||||
|
||||
root.set("data", data);
|
||||
return root;
|
||||
}
|
||||
|
||||
private ObjectNode makePoint(String metric, double value) {
|
||||
return makePoint(metric, value, "unknown");
|
||||
}
|
||||
|
||||
private String toNdjson(List<ObjectNode> points) {
|
||||
return points.stream()
|
||||
.map(ObjectNode::toString)
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Inline parser logic (mirrors result-parser.js untuk ditest di Java)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse NDJSON string → list of JsonNode data points */
|
||||
private List<JsonNode> parseNdjson(String ndjson) {
|
||||
List<JsonNode> points = new ArrayList<>();
|
||||
for (String line : ndjson.split("\n")) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) continue;
|
||||
try {
|
||||
points.add(mapper.readTree(line));
|
||||
} catch (Exception ignored) {
|
||||
// skip malformed lines — same behavior as result-parser.js
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Aggregate all "Point" type entries per metric name → list of values */
|
||||
private Map<String, List<Double>> aggregateMetrics(List<JsonNode> points) {
|
||||
Map<String, List<Double>> result = new HashMap<>();
|
||||
for (JsonNode dp : points) {
|
||||
if (!"Point".equals(dp.path("type").asText())) continue;
|
||||
String metric = dp.path("metric").asText();
|
||||
double value = dp.path("data").path("value").asDouble(0);
|
||||
result.computeIfAbsent(metric, k -> new ArrayList<>()).add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Group values by endpoint tag for a given metric */
|
||||
private Map<String, List<Double>> groupByEndpoint(List<JsonNode> points, String metric) {
|
||||
Map<String, List<Double>> result = new HashMap<>();
|
||||
for (JsonNode dp : points) {
|
||||
if (!"Point".equals(dp.path("type").asText())) continue;
|
||||
if (!metric.equals(dp.path("metric").asText())) continue;
|
||||
String endpoint = dp.path("data").path("tags").path("endpoint").asText("unknown");
|
||||
double value = dp.path("data").path("value").asDouble(0);
|
||||
result.computeIfAbsent(endpoint, k -> new ArrayList<>()).add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Calculate percentile — matches JS logic in result-parser.js exactly */
|
||||
private double percentile(List<Double> values, int p) {
|
||||
if (values == null || values.isEmpty()) return 0;
|
||||
List<Double> sorted = values.stream().sorted().collect(Collectors.toList());
|
||||
int idx = (int) Math.ceil((p / 100.0) * sorted.size()) - 1;
|
||||
return sorted.get(Math.max(0, idx));
|
||||
}
|
||||
|
||||
private double avg(List<Double> values) {
|
||||
if (values == null || values.isEmpty()) return 0;
|
||||
return values.stream().mapToDouble(Double::doubleValue).average().orElse(0);
|
||||
}
|
||||
|
||||
/** Evaluate threshold: return PASS/FAIL/SKIP */
|
||||
private String evaluateThreshold(Map<String, List<Double>> metrics,
|
||||
String metricName, String stat, double threshold, String operator) {
|
||||
List<Double> vals = metrics.get(metricName);
|
||||
if (vals == null || vals.isEmpty()) return "SKIP";
|
||||
double actual;
|
||||
switch (stat) {
|
||||
case "p95": actual = percentile(vals, 95); break;
|
||||
case "p99": actual = percentile(vals, 99); break;
|
||||
case "avg": actual = avg(vals); break;
|
||||
case "max": actual = vals.stream().mapToDouble(Double::doubleValue).max().orElse(0); break;
|
||||
default: return "SKIP";
|
||||
}
|
||||
boolean pass = "<".equals(operator) ? actual < threshold : actual > threshold;
|
||||
return pass ? "PASS" : "FAIL";
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 1. NDJSON PARSING
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("1. NDJSON Parser")
|
||||
class NdjsonParserTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus parse baris NDJSON valid menjadi list JsonNode")
|
||||
void shouldParseValidNdjsonLines() {
|
||||
List<ObjectNode> points = List.of(
|
||||
makePoint("http_req_duration", 123.4, "login"),
|
||||
makePoint("http_req_duration", 200.5, "register"),
|
||||
makePoint("http_req_failed", 0.0, "login")
|
||||
);
|
||||
|
||||
List<JsonNode> result = parseNdjson(toNdjson(points));
|
||||
|
||||
assertThat(result).hasSize(3);
|
||||
assertThat(result.get(0).path("metric").asText()).isEqualTo("http_req_duration");
|
||||
assertThat(result.get(2).path("metric").asText()).isEqualTo("http_req_failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus skip baris kosong tanpa error")
|
||||
void shouldSkipEmptyLines() {
|
||||
String ndjson = makePoint("http_req_duration", 100.0).toString()
|
||||
+ "\n\n"
|
||||
+ makePoint("http_req_duration", 200.0).toString()
|
||||
+ "\n";
|
||||
|
||||
List<JsonNode> result = parseNdjson(ndjson);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus skip baris malformed JSON tanpa throw exception")
|
||||
void shouldSkipMalformedJsonLines() {
|
||||
String ndjson = makePoint("http_req_duration", 100.0).toString()
|
||||
+ "\n{INVALID JSON HERE}\n"
|
||||
+ makePoint("http_req_failed", 0.0).toString();
|
||||
|
||||
List<JsonNode> result = parseNdjson(ndjson);
|
||||
|
||||
// Hanya 2 baris valid yang berhasil di-parse
|
||||
assertThat(result).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus return empty list untuk input kosong")
|
||||
void shouldReturnEmptyListForEmptyInput() {
|
||||
List<JsonNode> result = parseNdjson("");
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus skip non-Point type entries saat aggregasi")
|
||||
void shouldSkipNonPointTypeEntries() {
|
||||
ObjectNode metaEntry = mapper.createObjectNode();
|
||||
metaEntry.put("type", "Metric");
|
||||
metaEntry.put("metric", "http_req_duration");
|
||||
|
||||
String ndjson = metaEntry.toString() + "\n"
|
||||
+ makePoint("http_req_duration", 150.0).toString();
|
||||
|
||||
List<JsonNode> points = parseNdjson(ndjson);
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(points);
|
||||
|
||||
// Hanya Point yang masuk ke agregasi
|
||||
assertThat(metrics.get("http_req_duration")).hasSize(1);
|
||||
assertThat(metrics.get("http_req_duration").get(0)).isEqualTo(150.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus parse nilai timestamp dari field data.time")
|
||||
void shouldParseTimestampFromDataTime() {
|
||||
String ndjson = makePoint("http_reqs", 1.0, "login").toString();
|
||||
|
||||
List<JsonNode> result = parseNdjson(ndjson);
|
||||
|
||||
assertThat(result.get(0).path("data").path("time").asText())
|
||||
.isEqualTo("2024-01-01T00:00:00Z");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. METRIC AGGREGATION
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("2. Metric Aggregator")
|
||||
class MetricAggregatorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus aggregate multiple data points per metric name")
|
||||
void shouldAggregateMultiplePointsPerMetric() {
|
||||
List<ObjectNode> points = List.of(
|
||||
makePoint("http_req_duration", 100.0),
|
||||
makePoint("http_req_duration", 200.0),
|
||||
makePoint("http_req_duration", 300.0),
|
||||
makePoint("http_req_failed", 0.0),
|
||||
makePoint("http_req_failed", 1.0)
|
||||
);
|
||||
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));
|
||||
|
||||
assertThat(metrics).containsKey("http_req_duration");
|
||||
assertThat(metrics).containsKey("http_req_failed");
|
||||
assertThat(metrics.get("http_req_duration")).hasSize(3);
|
||||
assertThat(metrics.get("http_req_failed")).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus aggregate semua 7 WalkGuide custom metrics")
|
||||
void shouldAggregateAllWalkGuideCustomMetrics() {
|
||||
List<String> wgMetrics = List.of(
|
||||
"walkguide_auth_latency_ms",
|
||||
"walkguide_location_latency_ms",
|
||||
"walkguide_obstacle_latency_ms",
|
||||
"walkguide_sos_latency_ms",
|
||||
"walkguide_notif_latency_ms",
|
||||
"walkguide_timeline_latency_ms",
|
||||
"walkguide_pairing_latency_ms"
|
||||
);
|
||||
|
||||
List<ObjectNode> points = wgMetrics.stream()
|
||||
.map(m -> makePoint(m, 150.0))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));
|
||||
|
||||
for (String m : wgMetrics) {
|
||||
assertThat(metrics).containsKey(m);
|
||||
assertThat(metrics.get(m)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus accumulate nilai ke list yang sama untuk metric yang sama")
|
||||
void shouldAccumulateValuesForSameMetric() {
|
||||
List<ObjectNode> points = new ArrayList<>();
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
points.add(makePoint("walkguide_sos_latency_ms", i * 10.0));
|
||||
}
|
||||
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));
|
||||
|
||||
assertThat(metrics.get("walkguide_sos_latency_ms")).hasSize(10);
|
||||
assertThat(metrics.get("walkguide_sos_latency_ms"))
|
||||
.containsExactlyInAnyOrder(10.0, 20.0, 30.0, 40.0, 50.0,
|
||||
60.0, 70.0, 80.0, 90.0, 100.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus handle nilai 0 sebagai valid data point")
|
||||
void shouldHandleZeroValueAsValidDataPoint() {
|
||||
List<ObjectNode> points = List.of(
|
||||
makePoint("http_req_failed", 0.0),
|
||||
makePoint("http_req_failed", 0.0),
|
||||
makePoint("http_req_failed", 1.0)
|
||||
);
|
||||
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));
|
||||
|
||||
assertThat(metrics.get("http_req_failed")).containsExactly(0.0, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 3. STATISTICAL CALCULATIONS
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("3. Statistical Calculator")
|
||||
class StatCalculatorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("avg — harus hitung rata-rata dengan benar")
|
||||
void avgShouldCalculateCorrectly() {
|
||||
assertThat(avg(List.of(100.0, 200.0, 300.0))).isEqualTo(200.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("avg — harus return 0 untuk list kosong")
|
||||
void avgShouldReturnZeroForEmptyList() {
|
||||
assertThat(avg(Collections.emptyList())).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p50 — harus return nilai median yang benar")
|
||||
void p50ShouldReturnMedian() {
|
||||
// sorted: [1,2,3,4,5] → idx = ceil(0.5*5)-1 = 2 → value = 3
|
||||
List<Double> values = List.of(5.0, 1.0, 3.0, 2.0, 4.0);
|
||||
assertThat(percentile(values, 50)).isEqualTo(3.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p95 — harus return 95th percentile dengan benar (10 nilai)")
|
||||
void p95ShouldCalculateCorrectly() {
|
||||
// Sorted: 10,20,...,100 → idx = ceil(0.95*10)-1 = 10-1 = 9 → value = 100
|
||||
List<Double> values = new ArrayList<>();
|
||||
for (int i = 1; i <= 10; i++) values.add(i * 10.0);
|
||||
Collections.shuffle(values);
|
||||
|
||||
assertThat(percentile(values, 95)).isEqualTo(100.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p95 — harus return 95th percentile dengan benar (100 nilai)")
|
||||
void p95ShouldCalculateCorrectlyWith100Values() {
|
||||
// 1..100 → p95 = value at idx ceil(95)-1 = 94 → value = 95
|
||||
List<Double> values = new ArrayList<>();
|
||||
for (int i = 1; i <= 100; i++) values.add((double) i);
|
||||
Collections.shuffle(values);
|
||||
|
||||
assertThat(percentile(values, 95)).isEqualTo(95.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p99 — harus return 99th percentile dengan benar")
|
||||
void p99ShouldCalculateCorrectly() {
|
||||
List<Double> values = new ArrayList<>();
|
||||
for (int i = 1; i <= 100; i++) values.add((double) i);
|
||||
|
||||
assertThat(percentile(values, 99)).isEqualTo(99.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("percentile — harus return 0 untuk list kosong atau null")
|
||||
void percentileShouldReturnZeroForEmpty() {
|
||||
assertThat(percentile(Collections.emptyList(), 95)).isEqualTo(0.0);
|
||||
assertThat(percentile(null, 95)).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("percentile — harus return nilai itu sendiri untuk single element")
|
||||
void percentileShouldReturnSingleValue() {
|
||||
List<Double> values = List.of(42.0);
|
||||
assertThat(percentile(values, 50)).isEqualTo(42.0);
|
||||
assertThat(percentile(values, 95)).isEqualTo(42.0);
|
||||
assertThat(percentile(values, 99)).isEqualTo(42.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p99 >= p95 >= p50 >= min dan p99 <= max (monotonic ordering)")
|
||||
void statisticalOrderingShouldBeMonotonic() {
|
||||
List<Double> values = List.of(10.0, 50.0, 30.0, 80.0, 200.0, 150.0, 90.0, 45.0, 175.0, 60.0);
|
||||
|
||||
double p50 = percentile(values, 50);
|
||||
double p95 = percentile(values, 95);
|
||||
double p99 = percentile(values, 99);
|
||||
double min = values.stream().mapToDouble(Double::doubleValue).min().orElse(0);
|
||||
double max = values.stream().mapToDouble(Double::doubleValue).max().orElse(0);
|
||||
|
||||
assertThat(p95).isGreaterThanOrEqualTo(p50);
|
||||
assertThat(p99).isGreaterThanOrEqualTo(p95);
|
||||
assertThat(p50).isGreaterThanOrEqualTo(min);
|
||||
assertThat(p99).isLessThanOrEqualTo(max);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. THRESHOLD EVALUATION
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("4. Threshold Evaluator")
|
||||
class ThresholdEvaluatorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("PASS — p95 http_req_duration < 500ms saat semua nilai di bawah threshold")
|
||||
void shouldPassWhenP95BelowThreshold() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_duration",
|
||||
List.of(100.0, 150.0, 200.0, 300.0, 400.0, 120.0, 180.0, 250.0, 350.0, 450.0));
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_duration", "p95", 500, "<"))
|
||||
.isEqualTo("PASS");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FAIL — p95 http_req_duration >= 500ms saat banyak nilai tinggi")
|
||||
void shouldFailWhenP95ExceedsThreshold() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
// p95 = 900ms
|
||||
metrics.put("http_req_duration",
|
||||
List.of(100.0, 200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0));
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_duration", "p95", 500, "<"))
|
||||
.isEqualTo("FAIL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PASS — SOS p95 < 200ms (threshold paling kritis)")
|
||||
void shouldPassSosThresholdWithFastResponses() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
List<Double> sosTimes = new ArrayList<>();
|
||||
for (int i = 0; i < 20; i++) sosTimes.add(50.0 + i * 5); // 50ms - 145ms
|
||||
metrics.put("walkguide_sos_latency_ms", sosTimes);
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_sos_latency_ms", "p95", 200, "<"))
|
||||
.isEqualTo("PASS");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FAIL — SOS p95 >= 200ms saat respons lambat")
|
||||
void shouldFailSosThresholdWithSlowResponses() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
List<Double> sosTimes = new ArrayList<>();
|
||||
for (int i = 0; i < 20; i++) sosTimes.add(100.0 + i * 20); // hingga 480ms
|
||||
metrics.put("walkguide_sos_latency_ms", sosTimes);
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_sos_latency_ms", "p95", 200, "<"))
|
||||
.isEqualTo("FAIL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PASS — error rate < 1% saat semua request sukses")
|
||||
void shouldPassErrorRateWithZeroFailures() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_failed", Collections.nCopies(100, 0.0));
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<"))
|
||||
.isEqualTo("PASS");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FAIL — error rate >= 1% saat banyak request gagal")
|
||||
void shouldFailErrorRateWithHighFailures() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
// 10% error rate
|
||||
List<Double> failed = new ArrayList<>(Collections.nCopies(90, 0.0));
|
||||
failed.addAll(Collections.nCopies(10, 1.0));
|
||||
metrics.put("http_req_failed", failed);
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<"))
|
||||
.isEqualTo("FAIL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SKIP — harus return SKIP untuk metric yang tidak ada di data")
|
||||
void shouldReturnSkipForMissingMetric() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_jvm_heap_used_mb", "avg", 512, "<"))
|
||||
.isEqualTo("SKIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus evaluate semua 8 threshold checks dari result-parser.js dan semua PASS")
|
||||
void shouldEvaluateAllEightThresholdChecksAndAllPass() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_duration", buildRange(20, 50, 400));
|
||||
metrics.put("http_req_failed", Collections.nCopies(100, 0.0));
|
||||
metrics.put("walkguide_sos_latency_ms", buildRange(20, 50, 150));
|
||||
metrics.put("walkguide_location_latency_ms", buildRange(20, 50, 250));
|
||||
metrics.put("walkguide_obstacle_latency_ms", buildRange(20, 50, 350));
|
||||
metrics.put("walkguide_auth_latency_ms", buildRange(20, 100, 700));
|
||||
metrics.put("walkguide_timeline_latency_ms", buildRange(20, 200, 900));
|
||||
metrics.put("walkguide_notif_latency_ms", buildRange(20, 50, 450));
|
||||
|
||||
// Mirror dari result-parser.js thresholdChecks array
|
||||
assertThat(evaluateThreshold(metrics, "http_req_duration", "p95", 500, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_sos_latency_ms", "p95", 200, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_location_latency_ms", "p95", 300, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_obstacle_latency_ms", "p95", 400, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_auth_latency_ms", "p95", 800, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_timeline_latency_ms", "p95", 1000, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_notif_latency_ms", "p95", 500, "<")).isEqualTo("PASS");
|
||||
}
|
||||
|
||||
private List<Double> buildRange(int count, double min, double max) {
|
||||
List<Double> list = new ArrayList<>();
|
||||
double step = (max - min) / Math.max(1, count - 1);
|
||||
for (int i = 0; i < count; i++) list.add(min + step * i);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5. KEY METRIC EXTRACTION (5 wajib Pillar 3)
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("5. Key Metric Extractor (5 Pillar 3 Metrics)")
|
||||
class KeyMetricExtractorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 1 — Throughput: dihitung dari total req / durasi detik")
|
||||
void shouldCalculateThroughput() {
|
||||
// 1000 requests dalam 20 detik = 50 req/s
|
||||
int totalRequests = 1000;
|
||||
long durationMs = 20_000L;
|
||||
double throughput = (double) totalRequests / (durationMs / 1000.0);
|
||||
|
||||
assertThat(throughput).isEqualTo(50.0);
|
||||
assertThat(String.format("%.1f req/s", throughput)).isEqualTo("50.0 req/s");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 2 — p95 Latency: extract dari http_req_duration p95")
|
||||
void shouldExtractP95LatencyFromHttpReqDuration() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_duration", Collections.nCopies(100, 200.0));
|
||||
|
||||
double p95 = percentile(metrics.get("http_req_duration"), 95);
|
||||
|
||||
assertThat(p95).isEqualTo(200.0);
|
||||
assertThat(String.format("%.2f ms", p95)).isEqualTo("200.00 ms");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 3 — Error Rate: avg http_req_failed * 100 = persentase")
|
||||
void shouldCalculateErrorRatePercentage() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
// 95 sukses + 5 gagal = 5% error
|
||||
List<Double> failed = new ArrayList<>(Collections.nCopies(95, 0.0));
|
||||
failed.addAll(Collections.nCopies(5, 1.0));
|
||||
metrics.put("http_req_failed", failed);
|
||||
|
||||
double errorRatePct = avg(metrics.get("http_req_failed")) * 100;
|
||||
|
||||
assertThat(errorRatePct).isCloseTo(5.0, within(0.01));
|
||||
assertThat(errorRatePct < 1.0).isFalse(); // FAIL
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 3 — Error Rate PASS saat < 1%")
|
||||
void errorRateShouldPassWhenBelow1Percent() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
List<Double> failed = new ArrayList<>(Collections.nCopies(995, 0.0));
|
||||
failed.addAll(Collections.nCopies(5, 1.0)); // 0.5% error
|
||||
metrics.put("http_req_failed", failed);
|
||||
|
||||
double errorRatePct = avg(metrics.get("http_req_failed")) * 100;
|
||||
String passFail = errorRatePct < 1.0 ? "PASS" : "FAIL";
|
||||
|
||||
assertThat(passFail).isEqualTo("PASS");
|
||||
assertThat(errorRatePct).isCloseTo(0.5, within(0.01));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 4 — DB Query Time: avg(location p95, obstacle p95)")
|
||||
void shouldEstimateDbQueryTimeFromWriteEndpoints() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("walkguide_location_latency_ms", Collections.nCopies(10, 250.0));
|
||||
metrics.put("walkguide_obstacle_latency_ms", Collections.nCopies(10, 350.0));
|
||||
|
||||
double locationP95 = percentile(metrics.get("walkguide_location_latency_ms"), 95);
|
||||
double obstacleP95 = percentile(metrics.get("walkguide_obstacle_latency_ms"), 95);
|
||||
double dbEstimate = (locationP95 + obstacleP95) / 2;
|
||||
|
||||
assertThat(dbEstimate).isEqualTo(300.0);
|
||||
assertThat(String.format("%.2f ms", dbEstimate)).isEqualTo("300.00 ms");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 5 — JVM Heap: avg dari walkguide_jvm_heap_used_mb gauge readings")
|
||||
void shouldExtractJvmHeapFromActuatorGauge() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("walkguide_jvm_heap_used_mb", List.of(256.0, 280.0, 310.0, 295.0, 270.0));
|
||||
|
||||
double heapAvg = avg(metrics.get("walkguide_jvm_heap_used_mb"));
|
||||
|
||||
assertThat(heapAvg).isCloseTo(282.2, within(0.1));
|
||||
assertThat(String.format("%.2f MB", heapAvg)).isEqualTo("282.20 MB");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 6. WALKGUIDE-SPECIFIC METRICS
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("6. WalkGuide Endpoint Metrics")
|
||||
class WalkGuideMetricsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("SOS p95 harus lebih rendah dari auth p95 (life-safety priority)")
|
||||
void sosLatencyShouldBeLowerThanAuthLatency() {
|
||||
Map<String, List<Double>> metrics = buildRealisticMetrics();
|
||||
|
||||
double sosP95 = percentile(metrics.get("walkguide_sos_latency_ms"), 95);
|
||||
double authP95 = percentile(metrics.get("walkguide_auth_latency_ms"), 95);
|
||||
|
||||
assertThat(sosP95).isLessThan(authP95);
|
||||
assertThat(sosP95).isLessThan(200.0); // SOS threshold wajib
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Location p95 harus < 300ms karena high-frequency endpoint")
|
||||
void locationLatencyShouldMeetThreshold() {
|
||||
Map<String, List<Double>> metrics = buildRealisticMetrics();
|
||||
|
||||
double p95 = percentile(metrics.get("walkguide_location_latency_ms"), 95);
|
||||
|
||||
assertThat(p95).isLessThan(300.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Timeline p95 boleh lebih tinggi dari location p95 (analytics vs real-time)")
|
||||
void timelineLatencyCanBeHigherThanLocationLatency() {
|
||||
Map<String, List<Double>> metrics = buildRealisticMetrics();
|
||||
|
||||
double timelineP95 = percentile(metrics.get("walkguide_timeline_latency_ms"), 95);
|
||||
double locationP95 = percentile(metrics.get("walkguide_location_latency_ms"), 95);
|
||||
|
||||
assertThat(timelineP95).isGreaterThan(locationP95);
|
||||
assertThat(timelineP95).isLessThan(1000.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Semua 7 WalkGuide metrics harus terpopulasi dengan p95, p99, avg")
|
||||
void allSevenWalkGuideMetricsShouldBePopulated() {
|
||||
Map<String, List<Double>> metrics = buildRealisticMetrics();
|
||||
Map<String, String> wgMetricMap = new LinkedHashMap<>();
|
||||
wgMetricMap.put("authLatency", "walkguide_auth_latency_ms");
|
||||
wgMetricMap.put("locationLatency", "walkguide_location_latency_ms");
|
||||
wgMetricMap.put("obstacleLatency", "walkguide_obstacle_latency_ms");
|
||||
wgMetricMap.put("sosLatency", "walkguide_sos_latency_ms");
|
||||
wgMetricMap.put("notifLatency", "walkguide_notif_latency_ms");
|
||||
wgMetricMap.put("timelineLatency", "walkguide_timeline_latency_ms");
|
||||
wgMetricMap.put("pairingLatency", "walkguide_pairing_latency_ms");
|
||||
|
||||
Map<String, Map<String, String>> walkguideMetrics = new HashMap<>();
|
||||
for (Map.Entry<String, String> e : wgMetricMap.entrySet()) {
|
||||
List<Double> vals = metrics.get(e.getValue());
|
||||
if (vals != null && !vals.isEmpty()) {
|
||||
Map<String, String> stats = new HashMap<>();
|
||||
stats.put("p95", String.format("%.2f ms", percentile(vals, 95)));
|
||||
stats.put("p99", String.format("%.2f ms", percentile(vals, 99)));
|
||||
stats.put("avg", String.format("%.2f ms", avg(vals)));
|
||||
walkguideMetrics.put(e.getKey(), stats);
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(walkguideMetrics).hasSize(7);
|
||||
assertThat(walkguideMetrics).containsKeys(
|
||||
"authLatency", "locationLatency", "obstacleLatency",
|
||||
"sosLatency", "notifLatency", "timelineLatency", "pairingLatency");
|
||||
// Setiap metric harus punya p95, p99, avg
|
||||
for (Map<String, String> stats : walkguideMetrics.values()) {
|
||||
assertThat(stats).containsKeys("p95", "p99", "avg");
|
||||
assertThat(stats.get("p95")).endsWith("ms");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, List<Double>> buildRealisticMetrics() {
|
||||
Map<String, List<Double>> m = new HashMap<>();
|
||||
m.put("walkguide_sos_latency_ms", buildRange(50, 50, 150));
|
||||
m.put("walkguide_location_latency_ms", buildRange(50, 80, 260));
|
||||
m.put("walkguide_obstacle_latency_ms", buildRange(50, 100, 360));
|
||||
m.put("walkguide_auth_latency_ms", buildRange(50, 200, 750));
|
||||
m.put("walkguide_notif_latency_ms", buildRange(50, 100, 450));
|
||||
m.put("walkguide_timeline_latency_ms", buildRange(50, 300, 900));
|
||||
m.put("walkguide_pairing_latency_ms", buildRange(50, 150, 550));
|
||||
return m;
|
||||
}
|
||||
|
||||
private List<Double> buildRange(int count, double min, double max) {
|
||||
List<Double> list = new ArrayList<>();
|
||||
double step = (max - min) / Math.max(1, count - 1);
|
||||
for (int i = 0; i < count; i++) list.add(min + step * i);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 7. ENDPOINT BREAKDOWN
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("7. Endpoint Breakdown (byEndpoint grouping)")
|
||||
class EndpointBreakdownTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus group metric values berdasarkan endpoint tag")
|
||||
void shouldGroupValuesByEndpointTag() {
|
||||
List<ObjectNode> points = List.of(
|
||||
makePoint("http_req_duration", 100.0, "login"),
|
||||
makePoint("http_req_duration", 150.0, "login"),
|
||||
makePoint("http_req_duration", 200.0, "register"),
|
||||
makePoint("http_req_duration", 300.0, "register"),
|
||||
makePoint("http_req_duration", 80.0, "ping")
|
||||
);
|
||||
|
||||
Map<String, List<Double>> byEndpoint = groupByEndpoint(
|
||||
parseNdjson(toNdjson(points)), "http_req_duration");
|
||||
|
||||
assertThat(byEndpoint).containsKeys("login", "register", "ping");
|
||||
assertThat(byEndpoint.get("login")).containsExactly(100.0, 150.0);
|
||||
assertThat(byEndpoint.get("register")).containsExactly(200.0, 300.0);
|
||||
assertThat(byEndpoint.get("ping")).containsExactly(80.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus support semua WalkGuide endpoint tags dari api-client.js")
|
||||
void shouldSupportAllWalkGuideEndpointTags() {
|
||||
List<String> endpoints = List.of(
|
||||
"login", "register", "refresh_token", "logout",
|
||||
"pairing_invite", "pairing_respond", "pairing_status",
|
||||
"location_update", "obstacle_log", "sos_trigger",
|
||||
"notifications", "send_notification", "unread_count",
|
||||
"guardian_dashboard", "location_history"
|
||||
);
|
||||
|
||||
List<ObjectNode> points = endpoints.stream()
|
||||
.map(ep -> makePoint("http_req_duration", 200.0, ep))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, List<Double>> byEndpoint = groupByEndpoint(
|
||||
parseNdjson(toNdjson(points)), "http_req_duration");
|
||||
|
||||
for (String ep : endpoints) {
|
||||
assertThat(byEndpoint).containsKey(ep);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p95 per endpoint: sos_trigger harus lebih cepat dari login")
|
||||
void p95PerEndpointShouldShowSosIsFasterThanLogin() {
|
||||
List<ObjectNode> points = new ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) points.add(makePoint("http_req_duration", 100.0 + i * 10, "login"));
|
||||
for (int i = 0; i < 10; i++) points.add(makePoint("http_req_duration", 50.0 + i * 10, "sos_trigger"));
|
||||
|
||||
Map<String, List<Double>> byEndpoint = groupByEndpoint(
|
||||
parseNdjson(toNdjson(points)), "http_req_duration");
|
||||
|
||||
double loginP95 = percentile(byEndpoint.get("login"), 95);
|
||||
double sosP95 = percentile(byEndpoint.get("sos_trigger"), 95);
|
||||
|
||||
assertThat(loginP95).isGreaterThan(sosP95);
|
||||
assertThat(sosP95).isLessThan(200.0);
|
||||
assertThat(loginP95).isLessThan(800.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint tanpa tag harus masuk ke bucket 'unknown'")
|
||||
void shouldGroupUntaggedEndpointAsUnknown() {
|
||||
List<ObjectNode> points = List.of(
|
||||
makePoint("http_req_duration", 200.0), // → unknown
|
||||
makePoint("http_req_duration", 300.0, "login")
|
||||
);
|
||||
|
||||
Map<String, List<Double>> byEndpoint = groupByEndpoint(
|
||||
parseNdjson(toNdjson(points)), "http_req_duration");
|
||||
|
||||
assertThat(byEndpoint).containsKey("unknown");
|
||||
assertThat(byEndpoint).containsKey("login");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 8. EDGE CASES
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("8. Edge Cases")
|
||||
class EdgeCasesTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus handle NDJSON dengan ribuan data points tanpa error")
|
||||
void shouldHandleLargeNdjsonInput() {
|
||||
List<ObjectNode> points = new ArrayList<>();
|
||||
for (int i = 0; i < 5000; i++) {
|
||||
points.add(makePoint("http_req_duration", 100.0 + (i % 400)));
|
||||
}
|
||||
|
||||
List<JsonNode> parsed = parseNdjson(toNdjson(points));
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parsed);
|
||||
|
||||
assertThat(parsed).hasSize(5000);
|
||||
assertThat(metrics.get("http_req_duration")).hasSize(5000);
|
||||
double p95 = percentile(metrics.get("http_req_duration"), 95);
|
||||
assertThat(p95).isGreaterThan(0).isLessThan(600);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Semua baris malformed harus menghasilkan empty metrics tanpa exception")
|
||||
void shouldReturnEmptyMetricsForAllMalformedInput() {
|
||||
String ndjson = "{INVALID}\n{ALSO INVALID}\nnot json at all";
|
||||
|
||||
List<JsonNode> parsed = parseNdjson(ndjson);
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parsed);
|
||||
|
||||
assertThat(parsed).isEmpty();
|
||||
assertThat(metrics).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Single data point — semua percentile harus return nilai itu sendiri")
|
||||
void singleDataPointShouldReturnItselfForAllPercentiles() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_duration", List.of(250.0));
|
||||
|
||||
assertThat(percentile(metrics.get("http_req_duration"), 50)).isEqualTo(250.0);
|
||||
assertThat(percentile(metrics.get("http_req_duration"), 95)).isEqualTo(250.0);
|
||||
assertThat(percentile(metrics.get("http_req_duration"), 99)).isEqualTo(250.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Threshold SKIP tidak boleh menyebabkan NullPointerException")
|
||||
void thresholdSkipShouldNotThrowNPE() {
|
||||
Map<String, List<Double>> emptyMetrics = new HashMap<>();
|
||||
|
||||
assertThatNoException().isThrownBy(() -> {
|
||||
String r1 = evaluateThreshold(emptyMetrics, "http_req_duration", "p95", 500, "<");
|
||||
String r2 = evaluateThreshold(emptyMetrics, "walkguide_sos_latency_ms", "p95", 200, "<");
|
||||
String r3 = evaluateThreshold(emptyMetrics, "walkguide_jvm_heap_used_mb","avg", 512, "<");
|
||||
assertThat(r1).isEqualTo("SKIP");
|
||||
assertThat(r2).isEqualTo("SKIP");
|
||||
assertThat(r3).isEqualTo("SKIP");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Mixed metric types dalam satu stream harus ter-aggregate dengan benar")
|
||||
void shouldAggregateMixedMetricTypesCorrectly() {
|
||||
List<ObjectNode> points = new ArrayList<>();
|
||||
for (int i = 0; i < 20; i++) points.add(makePoint("http_req_duration", 100.0 + i * 10));
|
||||
for (int i = 0; i < 20; i++) points.add(makePoint("http_req_failed", i < 19 ? 0.0 : 1.0));
|
||||
for (int i = 0; i < 10; i++) points.add(makePoint("walkguide_sos_latency_ms", 50.0 + i * 10));
|
||||
for (int i = 0; i < 10; i++) points.add(makePoint("walkguide_location_latency_ms", 80.0 + i * 15));
|
||||
for (int i = 0; i < 5; i++) points.add(makePoint("walkguide_jvm_heap_used_mb", 250.0 + i * 10));
|
||||
|
||||
Collections.shuffle(points);
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));
|
||||
|
||||
assertThat(metrics.get("http_req_duration")).hasSize(20);
|
||||
assertThat(metrics.get("http_req_failed")).hasSize(20);
|
||||
assertThat(metrics.get("walkguide_sos_latency_ms")).hasSize(10);
|
||||
assertThat(metrics.get("walkguide_location_latency_ms")).hasSize(10);
|
||||
assertThat(metrics.get("walkguide_jvm_heap_used_mb")).hasSize(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Error rate 0% harus selalu PASS")
|
||||
void zeroErrorRateShouldAlwaysPass() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_failed", Collections.nCopies(1000, 0.0));
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<")).isEqualTo("PASS");
|
||||
assertThat(avg(metrics.get("http_req_failed"))).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Error rate 100% harus selalu FAIL")
|
||||
void hundredPercentErrorRateShouldFail() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_failed", Collections.nCopies(100, 1.0));
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<")).isEqualTo("FAIL");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user