fix: Fix test stability issues

This commit is contained in:
Ilkka Seppälä committed 2026-06-07 12:10:39 +03:00
1 parent 13cde46f2e
commit 177dc52704
24 files changed
+253 -111

No files matched your search

@@ -43,6 +43,11 @@ import org.springframework.scheduling.annotation.EnableScheduling;
public class App { public class App {
/** Program entry point. */ /** Program entry point. */
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(App.class, args); var context = SpringApplication.run(App.class, args);
if (args.length > 0 && "test".equals(args[0])) {
// Close the context immediately during tests to prevent Tomcat/background threads from
// hanging the JVM
context.close();
}
} }
} }
+1 -1
View File
@@ -33,6 +33,6 @@ class AppTest {
/** Entry point */ /** Entry point */
@Test @Test
void shouldExecuteApplicationWithoutException() { void shouldExecuteApplicationWithoutException() {
assertDoesNotThrow(() -> App.main(new String[] {})); assertDoesNotThrow(() -> App.main(new String[] {"test"}));
} }
} }
@@ -73,6 +73,11 @@ public class Main {
* @param args command line args * @param args command line args
*/ */
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(Main.class, args); var context = SpringApplication.run(Main.class, args);
if (args.length > 0 && "test".equals(args[0])) {
// Close the context immediately during tests to prevent Tomcat/background threads from
// hanging the JVM
context.close();
}
} }
} }
@@ -32,6 +32,6 @@ import org.junit.jupiter.api.Test;
class MainTest { class MainTest {
@Test @Test
void shouldExecuteApplicationWithoutException() { void shouldExecuteApplicationWithoutException() {
assertDoesNotThrow(() -> Main.main(new String[] {})); assertDoesNotThrow(() -> Main.main(new String[] {"test"}));
} }
} }
@@ -72,6 +72,11 @@ public class Main {
* @param args command line args * @param args command line args
*/ */
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(Main.class, args); var context = SpringApplication.run(Main.class, args);
if (args.length > 0 && "test".equals(args[0])) {
// Close the context immediately during tests to prevent Tomcat/background threads from
// hanging the JVM
context.close();
}
} }
} }
@@ -32,6 +32,6 @@ import org.junit.jupiter.api.Test;
class MainTest { class MainTest {
@Test @Test
void shouldExecuteApplicationWithoutException() { void shouldExecuteApplicationWithoutException() {
assertDoesNotThrow(() -> Main.main(new String[] {})); assertDoesNotThrow(() -> Main.main(new String[] {"test"}));
} }
} }
@@ -72,6 +72,11 @@ public class Main {
* @param args command line args * @param args command line args
*/ */
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(Main.class, args); var context = SpringApplication.run(Main.class, args);
if (args.length > 0 && "test".equals(args[0])) {
// Close the context immediately during tests to prevent Tomcat/background threads from
// hanging the JVM
context.close();
}
} }
} }
@@ -33,6 +33,6 @@ import org.junit.jupiter.api.Test;
class MainTest { class MainTest {
@Test @Test
void shouldExecuteApplicationWithoutException() { void shouldExecuteApplicationWithoutException() {
assertDoesNotThrow(() -> Main.main(new String[] {})); assertDoesNotThrow(() -> Main.main(new String[] {"test"}));
} }
} }
@@ -45,7 +45,12 @@ import org.springframework.context.annotation.Bean;
@Slf4j @Slf4j
public class App { public class App {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(App.class, args); var context = SpringApplication.run(App.class, args);
if (args.length > 0 && "test".equals(args[0])) {
// Close the context immediately during tests to prevent Tomcat/background threads from
// hanging the JVM
context.close();
}
} }
/** /**
@@ -40,7 +40,7 @@ class AppTest {
@Test @Test
void testMain() { void testMain() {
assertDoesNotThrow(() -> App.main(new String[] {})); assertDoesNotThrow(() -> App.main(new String[] {"test"}));
} }
@Test @Test
@@ -44,6 +44,11 @@ public class App {
* @param args command line args * @param args command line args
*/ */
public static void main(final String[] args) { public static void main(final String[] args) {
SpringApplication.run(App.class, args); var context = SpringApplication.run(App.class, args);
if (args.length > 0 && "test".equals(args[0])) {
// Close the context immediately during tests to prevent Tomcat/background threads from
// hanging the JVM
context.close();
}
} }
} }
@@ -32,6 +32,6 @@ import org.junit.jupiter.api.Test;
public class AppTest { public class AppTest {
@Test @Test
void shouldExecuteApplicationWithoutException() { void shouldExecuteApplicationWithoutException() {
assertDoesNotThrow(() -> App.main(new String[] {})); assertDoesNotThrow(() -> App.main(new String[] {"test"}));
} }
} }
@@ -40,6 +40,11 @@ public class App {
* @param args command line args. * @param args command line args.
*/ */
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(App.class, args); var context = SpringApplication.run(App.class, args);
if (args.length > 0 && "test".equals(args[0])) {
// Close the context immediately during tests to prevent Tomcat/background threads from
// hanging the JVM
context.close();
}
} }
} }
@@ -44,9 +44,22 @@ public class DataSourceService {
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
/** Constructor & Scheduler to push random data. */ /** Constructor & Scheduler to push random data. */
@org.springframework.beans.factory.annotation.Autowired
public DataSourceService(DataRepository repository) { public DataSourceService(DataRepository repository) {
this(repository, true);
}
/**
* Constructor with option to start the scheduler.
*
* @param repository the data repository
* @param startScheduler true to start the data generation scheduler
*/
public DataSourceService(DataRepository repository, boolean startScheduler) {
this.repository = repository; this.repository = repository;
scheduleDataGeneration(); if (startScheduler) {
scheduleDataGeneration();
}
} }
private void scheduleDataGeneration() { private void scheduleDataGeneration() {
@@ -78,4 +91,9 @@ public class DataSourceService {
public Map<Integer, String> getAllData() { public Map<Integer, String> getAllData() {
return repository.findAll(); return repository.findAll();
} }
@javax.annotation.PreDestroy
public void shutdown() {
scheduler.shutdown();
}
} }
@@ -28,12 +28,16 @@ import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.test.context.EmbeddedKafka;
@SpringBootTest @SpringBootTest
@EmbeddedKafka(
partitions = 1,
topics = {"updates", "API"})
public class AppTest { public class AppTest {
@Test @Test
void polling() { void polling() {
assertDoesNotThrow(() -> App.main(new String[] {})); assertDoesNotThrow(() -> App.main(new String[] {"test"}));
} }
} }
@@ -38,7 +38,7 @@ public class DataSourceServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
repository = new DataRepository(); repository = new DataRepository();
service = new DataSourceService(repository); service = new DataSourceService(repository, false);
} }
@Test @Test
@@ -77,8 +77,8 @@ public class DataSourceServiceTest {
Map<Integer, String> result1 = service.getAllData(); Map<Integer, String> result1 = service.getAllData();
assertEquals(size + 2, result.size(), "Should return all stored data."); assertEquals(size + 2, result1.size(), "Should return all stored data.");
assertEquals("First", result.get(1), "Value for key 1 should be 'First'."); assertEquals("First", result1.get(1), "Value for key 1 should be 'First'.");
assertEquals("Second", result.get(2), "Value for key 2 should be 'Second'."); assertEquals("Second", result1.get(2), "Value for key 2 should be 'Second'.");
} }
} }
@@ -32,6 +32,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication @SpringBootApplication
public class App { public class App {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(App.class, args); var context = SpringApplication.run(App.class, args);
if (args.length > 0 && "test".equals(args[0])) {
// Close the context immediately during tests to prevent Tomcat/background threads from
// hanging the JVM
context.close();
}
} }
} }
@@ -29,12 +29,16 @@ import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.test.context.EmbeddedKafka;
@SpringBootTest @SpringBootTest
@EmbeddedKafka(
partitions = 1,
topics = {"updates", "API"})
public class AppTest { public class AppTest {
@Test @Test
void subscriber() { void subscriber() {
assertDoesNotThrow(() -> App.main(new String[] {})); assertDoesNotThrow(() -> App.main(new String[] {"test"}));
} }
} }
@@ -10,13 +10,28 @@ public class AdaptiveRateLimiter implements RateLimiter {
private final AtomicInteger currentLimit; private final AtomicInteger currentLimit;
private final ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();
private final ScheduledExecutorService healthChecker = Executors.newScheduledThreadPool(1); private final ScheduledExecutorService healthChecker = Executors.newScheduledThreadPool(1);
private final long checkInterval;
private final TimeUnit timeUnit;
public AdaptiveRateLimiter(int initialLimit, int maxLimit) { public AdaptiveRateLimiter(int initialLimit, int maxLimit) {
this(initialLimit, maxLimit, 10, TimeUnit.SECONDS);
}
/**
* Constructor with option to set custom interval.
*
* @param initialLimit initial rate limit
* @param maxLimit maximum rate limit
* @param interval check interval
* @param timeUnit check interval time unit
*/
public AdaptiveRateLimiter(int initialLimit, int maxLimit, long interval, TimeUnit timeUnit) {
this.initialLimit = initialLimit; this.initialLimit = initialLimit;
this.maxLimit = maxLimit; this.maxLimit = maxLimit;
this.currentLimit = new AtomicInteger(initialLimit); this.currentLimit = new AtomicInteger(initialLimit);
// Periodically increase limit to recover if system appears healthy this.checkInterval = interval;
healthChecker.scheduleAtFixedRate(this::adjustLimits, 10, 10, TimeUnit.SECONDS); this.timeUnit = timeUnit;
healthChecker.scheduleAtFixedRate(this::adjustLimits, interval, interval, timeUnit);
} }
@Override @Override
@@ -24,9 +39,10 @@ public class AdaptiveRateLimiter implements RateLimiter {
String key = serviceName + ":" + operationName; String key = serviceName + ":" + operationName;
int current = currentLimit.get(); int current = currentLimit.get();
// Reuse or create TokenBucket for this key using currentLimit // Reuse or create TokenBucket for this key using currentLimit and checkInterval
RateLimiter limiter = RateLimiter limiter =
limiters.computeIfAbsent(key, k -> new TokenBucketRateLimiter(current, current)); limiters.computeIfAbsent(
key, k -> new TokenBucketRateLimiter(current, current, checkInterval, timeUnit));
try { try {
limiter.check(serviceName, operationName); limiter.check(serviceName, operationName);
@@ -47,4 +63,17 @@ public class AdaptiveRateLimiter implements RateLimiter {
int updated = currentLimit.updateAndGet(curr -> Math.min(maxLimit, curr + (initialLimit / 2))); int updated = currentLimit.updateAndGet(curr -> Math.min(maxLimit, curr + (initialLimit / 2)));
System.out.printf("[Adaptive] Health check passed - Increasing limit to %d%n", updated); System.out.printf("[Adaptive] Health check passed - Increasing limit to %d%n", updated);
} }
/** Gracefully shut down the health checker executor and child limiters. */
public void shutdown() {
healthChecker.shutdown();
limiters
.values()
.forEach(
limiter -> {
if (limiter instanceof TokenBucketRateLimiter) {
((TokenBucketRateLimiter) limiter).shutdown();
}
});
}
} }
@@ -4,8 +4,10 @@ import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
/** /**
* Token Bucket rate limiter implementation. Allows requests to proceed as long as there are tokens * Token Bucket rate limiter implementation. Allows requests to proceed as long
* available in the bucket. Tokens are added at a fixed interval up to a defined capacity. * as there are tokens
* available in the bucket. Tokens are added at a fixed interval up to a defined
* capacity.
*/ */
public class TokenBucketRateLimiter implements RateLimiter { public class TokenBucketRateLimiter implements RateLimiter {
private final int capacity; private final int capacity;
@@ -14,10 +16,22 @@ public class TokenBucketRateLimiter implements RateLimiter {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public TokenBucketRateLimiter(int capacity, int refillRate) { public TokenBucketRateLimiter(int capacity, int refillRate) {
this(capacity, refillRate, 1, TimeUnit.SECONDS);
}
/**
* Constructor with custom refill interval.
*
* @param capacity token bucket capacity
* @param refillRate token refill rate
* @param refillInterval refill interval value
* @param timeUnit refill interval time unit
*/
public TokenBucketRateLimiter(
int capacity, int refillRate, long refillInterval, TimeUnit timeUnit) {
this.capacity = capacity; this.capacity = capacity;
this.refillRate = refillRate; this.refillRate = refillRate;
// Refill tokens in all buckets every second scheduler.scheduleAtFixedRate(this::refillBuckets, refillInterval, refillInterval, timeUnit);
scheduler.scheduleAtFixedRate(this::refillBuckets, 1, 1, TimeUnit.SECONDS);
} }
@Override @Override
@@ -39,7 +53,10 @@ public class TokenBucketRateLimiter implements RateLimiter {
buckets.forEach((k, b) -> b.refill(refillRate)); buckets.forEach((k, b) -> b.refill(refillRate));
} }
/** Inner class that represents the bucket holding tokens for each service-operation. */ /**
* Inner class that represents the bucket holding tokens for each
* service-operation.
*/
private static class TokenBucket { private static class TokenBucket {
private final int capacity; private final int capacity;
private final AtomicInteger tokens; private final AtomicInteger tokens;
@@ -52,8 +69,10 @@ public class TokenBucketRateLimiter implements RateLimiter {
boolean tryConsume() { boolean tryConsume() {
while (true) { while (true) {
int current = tokens.get(); int current = tokens.get();
if (current <= 0) return false; if (current <= 0)
if (tokens.compareAndSet(current, current - 1)) return true; return false;
if (tokens.compareAndSet(current, current - 1))
return true;
} }
} }
@@ -61,4 +80,8 @@ public class TokenBucketRateLimiter implements RateLimiter {
tokens.getAndUpdate(current -> Math.min(current + amount, capacity)); tokens.getAndUpdate(current -> Math.min(current + amount, capacity));
} }
} }
public void shutdown() {
scheduler.shutdown();
}
} }
@@ -8,49 +8,60 @@ class AdaptiveRateLimiterTest {
@Test @Test
void shouldDecreaseLimitWhenThrottled() throws Exception { void shouldDecreaseLimitWhenThrottled() throws Exception {
AdaptiveRateLimiter limiter = new AdaptiveRateLimiter(10, 20); AdaptiveRateLimiter limiter = new AdaptiveRateLimiter(10, 20);
try {
// Exceed initial limit // Exceed initial limit
for (int i = 0; i < 11; i++) { for (int i = 0; i < 11; i++) {
try { try {
limiter.check("test", "op"); limiter.check("test", "op");
} catch (RateLimitException e) { } catch (RateLimitException e) {
// Expected after 10 requests // Expected after 10 requests
}
} }
}
// Verify limit was reduced // Verify limit was reduced
assertThrows( assertThrows(
RateLimitException.class, RateLimitException.class,
() -> { () -> {
for (int i = 0; i < 6; i++) { // New limit should be 5 (10/2) for (int i = 0; i < 6; i++) { // New limit should be 5 (10/2)
limiter.check("test", "op"); limiter.check("test", "op");
} }
}); });
} finally {
limiter.shutdown();
}
} }
@Test @Test
void shouldGraduallyIncreaseLimitWhenHealthy() throws Exception { void shouldGraduallyIncreaseLimitWhenHealthy() throws Exception {
AdaptiveRateLimiter limiter = AdaptiveRateLimiter limiter =
new AdaptiveRateLimiter(4, 10); // Start from 4 → expect 2 → expect increase to 4 new AdaptiveRateLimiter(
4,
// Force throttling to reduce limit 10,
for (int i = 0; i < 5; i++) { 100,
try { java.util.concurrent.TimeUnit
limiter.check("test", "op"); .MILLISECONDS); // Start from 4 → expect 2 → expect increase to 4
} catch (RateLimitException e) { try {
// Expected to throttle and reduce limit // Force throttling to reduce limit
for (int i = 0; i < 5; i++) {
try {
limiter.check("test", "op");
} catch (RateLimitException e) {
// Expected to throttle and reduce limit
}
} }
// Wait for health check to increase limit
Thread.sleep(150); // Wait slightly more than 100 milliseconds
// Allow up to 4 requests again (limit should've increased to 4)
for (int i = 0; i < 4; i++) {
limiter.check("test", "op");
}
// 5th should throw exception again
assertThrows(RateLimitException.class, () -> limiter.check("test", "op"));
} finally {
limiter.shutdown();
} }
// Wait for health check to increase limit
Thread.sleep(11000); // Wait slightly more than 10 seconds
// Allow up to 4 requests again (limit should've increased to 4)
for (int i = 0; i < 4; i++) {
limiter.check("test", "op");
}
// 5th should throw exception again
assertThrows(RateLimitException.class, () -> limiter.check("test", "op"));
} }
} }
@@ -13,57 +13,65 @@ class ConcurrencyTests {
int requestLimit = 5; int requestLimit = 5;
RateLimiter limiter = new TokenBucketRateLimiter(requestLimit, requestLimit); RateLimiter limiter = new TokenBucketRateLimiter(requestLimit, requestLimit);
ExecutorService executor = Executors.newFixedThreadPool(threadCount); ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount); try {
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(); AtomicInteger successCount = new AtomicInteger();
AtomicInteger failureCount = new AtomicInteger(); AtomicInteger failureCount = new AtomicInteger();
for (int i = 0; i < threadCount; i++) { for (int i = 0; i < threadCount; i++) {
executor.submit( executor.submit(
() -> { () -> {
try { try {
limiter.check("test", "op"); limiter.check("test", "op");
successCount.incrementAndGet(); successCount.incrementAndGet();
} catch (RateLimitException e) { } catch (RateLimitException e) {
failureCount.incrementAndGet(); failureCount.incrementAndGet();
} }
latch.countDown(); latch.countDown();
}); });
}
latch.await();
assertEquals(requestLimit, successCount.get());
assertEquals(threadCount - requestLimit, failureCount.get());
} finally {
executor.shutdown();
} }
latch.await();
assertEquals(requestLimit, successCount.get());
assertEquals(threadCount - requestLimit, failureCount.get());
} }
@Test @Test
void adaptiveLimiterShouldAdjustUnderLoad() throws Exception { void adaptiveLimiterShouldAdjustUnderLoad() throws Exception {
AdaptiveRateLimiter limiter = new AdaptiveRateLimiter(10, 20); AdaptiveRateLimiter limiter = new AdaptiveRateLimiter(10, 20, 100, TimeUnit.MILLISECONDS);
ExecutorService executor = Executors.newFixedThreadPool(20); ExecutorService executor = Executors.newFixedThreadPool(20);
try {
// Flood with requests to trigger throttling // Flood with requests to trigger throttling
for (int i = 0; i < 30; i++) { for (int i = 0; i < 30; i++) {
executor.submit( executor.submit(
() -> { () -> {
try { try {
limiter.check("test", "op"); limiter.check("test", "op");
} catch (RateLimitException ignored) { } catch (RateLimitException ignored) {
} }
}); });
}
Thread.sleep(15000); // Wait for adjustment
// Verify new limit is in effect
int allowed = 0;
for (int i = 0; i < 20; i++) {
try {
limiter.check("test", "op");
allowed++;
} catch (RateLimitException ignored) {
} }
}
assertTrue(allowed > 5 && allowed < 15); // Should be between initial and max Thread.sleep(150); // Wait for adjustment
// Verify new limit is in effect
int allowed = 0;
for (int i = 0; i < 20; i++) {
try {
limiter.check("test", "op");
allowed++;
} catch (RateLimitException ignored) {
}
}
assertTrue(allowed > 5 && allowed < 15); // Should be between initial and max
} finally {
executor.shutdown();
limiter.shutdown();
}
} }
} }
@@ -67,7 +67,12 @@ public class SingleTableInheritance implements CommandLineRunner {
* @param args program runtime arguments * @param args program runtime arguments
*/ */
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(SingleTableInheritance.class, args); var context = SpringApplication.run(SingleTableInheritance.class, args);
if (args.length > 0 && "test".equals(args[0])) {
// Close the context immediately during tests to prevent Tomcat/background threads from
// hanging the JVM
context.close();
}
} }
/** /**
@@ -33,6 +33,6 @@ class SingleTableInheritanceTest {
@Test @Test
void shouldExecuteWithoutException() { void shouldExecuteWithoutException() {
assertDoesNotThrow(() -> SingleTableInheritance.main(new String[] {})); assertDoesNotThrow(() -> SingleTableInheritance.main(new String[] {"test"}));
} }
} }