Skip to content

Commit 581d6a6

Browse files
committed
feat(test): add concurrent test execution with timeout
1 parent b2769f3 commit 581d6a6

File tree

1 file changed

+167
-95
lines changed

1 file changed

+167
-95
lines changed

src/test/java/MavenTest.java

Lines changed: 167 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
import java.io.BufferedReader;
22
import java.io.InputStreamReader;
3-
// Import Duration
3+
import java.time.Duration;
44
import java.util.Arrays;
55
import java.util.Collection;
66
import java.util.List;
7+
import java.util.concurrent.Callable;
8+
import java.util.concurrent.ExecutionException;
9+
import java.util.concurrent.ExecutorService;
10+
import java.util.concurrent.Executors;
11+
import java.util.concurrent.Future;
12+
import java.util.concurrent.TimeUnit;
13+
import java.util.concurrent.TimeoutException;
714
import java.util.stream.Collectors;
815
import org.junit.jupiter.api.Assertions;
916
import org.junit.jupiter.api.DynamicTest;
1017
import org.junit.jupiter.api.TestFactory;
11-
import org.junit.jupiter.api.Timeout; // Import Timeout
12-
import org.junit.jupiter.api.parallel.Execution; // Import Execution
13-
import org.junit.jupiter.api.parallel.ExecutionMode; // Import ExecutionMode
1418

15-
// Enable concurrent execution for tests in this class
16-
@Execution(ExecutionMode.CONCURRENT)
1719
public class MavenTest {
1820

1921
private static final List<String> PROBLEMS = Arrays.asList(
22+
// ... (Your existing long list of problems) ...
2023
"p100",
2124
"p102",
2225
"p104",
@@ -385,113 +388,182 @@ public class MavenTest {
385388
"p1673",
386389
"p190",
387390
"p191",
388-
"p195");
391+
"p195"
392+
// ... (End of your existing list) ...
393+
);
394+
395+
// Define the number of threads for the ExecutorService
396+
private static final int MAX_THREADS = 10;
397+
// Define the timeout for each test
398+
private static final Duration TEST_TIMEOUT = Duration.ofSeconds(3);
389399

390400
@TestFactory
391-
@Timeout(value = 3, unit = java.util.concurrent.TimeUnit.SECONDS) // Apply a 3-second timeout to each dynamic test
392401
Collection<DynamicTest> runMavenExecTests() {
393-
return PROBLEMS.stream()
394-
.map(problem -> DynamicTest.dynamicTest("Test problem: " + problem, () -> {
395-
// This command needs to directly execute the Java Main class,
396-
// NOT "mvn exec:exec" if you want full parallelization
397-
// and proper timeout handling by JUnit 5.
398-
// The `exec-maven-plugin` creates its own process.
399-
400-
// You need to ensure the Main class can be run directly
401-
// and can handle input redirection if needed.
402-
// Example: /opt/homebrew/Cellar/openjdk/24.0.1/bin/java -cp ...
403-
// If you compile your project, the classes will be in target/classes.
404-
// String javaCommand = String.format("/opt/homebrew/Cellar/openjdk/24.0.1/bin/java -cp
405-
// target/classes com.lzw.solutions.uva.%s.Main < src/main/resources/uva/%s/1.in", problem,
406-
// problem);
407-
// System.out.println("Executing command: " + javaCommand);
402+
// Create a fixed-size thread pool
403+
final ExecutorService executor = Executors.newFixedThreadPool(MAX_THREADS);
408404

409-
// For now, let's stick to your `mvn exec:exec` command, but be aware
410-
// it might not be the most efficient for JUnit's parallel execution.
411-
// The timeout here will apply to the *entire* 'mvn exec:exec' process.
412-
String command = String.format("mvn exec:exec -Dproblem=%s", problem);
413-
System.out.println(
414-
Thread.currentThread().getName() + ": Executing command for " + problem + ": " + command);
405+
// A list to hold the Futures of each submitted task
406+
List<Future<TestResult>> futures = PROBLEMS.stream()
407+
.map(problem -> {
408+
// Create a Callable for each problem
409+
Callable<TestResult> task = () -> {
410+
Thread.currentThread().setName("Problem-Runner-" + problem); // Name thread for better logging
411+
String command = String.format("mvn exec:exec -Dproblem=%s", problem);
412+
System.out.println(Thread.currentThread().getName() + ": Executing command for " + problem
413+
+ ": " + command);
415414

416-
Process process;
417-
try {
418-
process = Runtime.getRuntime().exec(command);
419-
} catch (Exception e) {
420-
Assertions.fail("Failed to execute command for problem " + problem + ": " + e.getMessage());
421-
return; // Exit if process creation fails
422-
}
415+
Process process;
416+
try {
417+
process = Runtime.getRuntime().exec(command);
418+
} catch (Exception e) {
419+
// If process execution itself fails
420+
return new TestResult(
421+
problem, false, "", "Failed to execute command: " + e.getMessage(), e);
422+
}
423423

424-
// --- Start: Capture output and error streams (with a small buffer size for efficiency) ---
425-
// Using try-with-resources for automatic closing of readers
426-
StringBuilder output = new StringBuilder();
427-
StringBuilder errorOutput = new StringBuilder();
424+
StringBuilder output = new StringBuilder();
425+
StringBuilder errorOutput = new StringBuilder();
428426

429-
// Using separate threads to consume streams to prevent deadlock
430-
// if process produces a lot of output on both streams
431-
Thread outputGobbler = new Thread(() -> {
432-
try (BufferedReader reader =
433-
new BufferedReader(new InputStreamReader(process.getInputStream()))) {
434-
String line;
435-
while ((line = reader.readLine()) != null) {
436-
output.append(line).append("\n");
427+
// Use separate threads to consume streams to prevent deadlock
428+
Thread outputGobbler = new Thread(() -> {
429+
try (BufferedReader reader =
430+
new BufferedReader(new InputStreamReader(process.getInputStream()))) {
431+
String line;
432+
while ((line = reader.readLine()) != null) {
433+
output.append(line).append("\n");
434+
}
435+
} catch (Exception e) {
436+
System.err.println(Thread.currentThread().getName() + ": Error reading output for "
437+
+ problem + ": " + e.getMessage());
437438
}
438-
} catch (Exception e) {
439-
System.err.println("Error reading output for " + problem + ": " + e.getMessage());
440-
}
441-
});
439+
});
442440

443-
Thread errorGobbler = new Thread(() -> {
444-
try (BufferedReader errorReader =
445-
new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
446-
String line;
447-
while ((line = errorReader.readLine()) != null) {
448-
errorOutput.append(line).append("\n");
441+
Thread errorGobbler = new Thread(() -> {
442+
try (BufferedReader errorReader =
443+
new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
444+
String line;
445+
while ((line = errorReader.readLine()) != null) {
446+
errorOutput.append(line).append("\n");
447+
}
448+
} catch (Exception e) {
449+
System.err.println(Thread.currentThread().getName()
450+
+ ": Error reading error output for " + problem + ": " + e.getMessage());
449451
}
452+
});
453+
454+
outputGobbler.start();
455+
errorGobbler.start();
456+
457+
int exitCode;
458+
try {
459+
exitCode = process.waitFor();
460+
outputGobbler.join(); // Ensure all output is consumed
461+
errorGobbler.join(); // Ensure all error output is consumed
462+
} catch (InterruptedException e) {
463+
process.destroyForcibly(); // Ensure subprocess is terminated if interrupted
464+
outputGobbler.join(100); // Give gobblers a moment, but don't hang
465+
errorGobbler.join(100);
466+
Thread.currentThread().interrupt(); // Restore interrupted status
467+
return new TestResult(
468+
problem, false, output.toString(), "Test interrupted (likely timed out)", e);
450469
} catch (Exception e) {
451-
System.err.println("Error reading error output for " + problem + ": " + e.getMessage());
470+
return new TestResult(
471+
problem,
472+
false,
473+
output.toString(),
474+
"Error waiting for process: " + e.getMessage(),
475+
e);
452476
}
453-
});
454477

455-
outputGobbler.start();
456-
errorGobbler.start();
478+
boolean success = (exitCode == 0);
479+
return new TestResult(problem, success, output.toString(), errorOutput.toString(), null);
480+
};
481+
return executor.submit(task); // Submit the task to the executor
482+
})
483+
.collect(Collectors.toList());
457484

458-
int exitCode;
485+
// Create DynamicTests to check the results of the submitted tasks
486+
Collection<DynamicTest> dynamicTests = futures.stream()
487+
.map(future -> DynamicTest.dynamicTest("Test problem: " + future.toString(), () -> {
488+
TestResult result = null;
459489
try {
460-
// Wait for the process to complete or timeout
461-
// This timeout is managed by JUnit's @Timeout annotation
462-
// and applies to the entire lambda body.
463-
exitCode = process.waitFor();
464-
outputGobbler.join(); // Ensure all output is consumed
465-
errorGobbler.join(); // Ensure all error output is consumed
466-
} catch (InterruptedException e) {
467-
// This block is executed if the JUnit timeout is triggered
468-
process.destroyForcibly(); // Terminate the process if interrupted
469-
outputGobbler.join(100); // Give gobblers a moment to finish, but don't wait forever
470-
errorGobbler.join(100);
471-
System.err.println(Thread.currentThread().getName() + ": Process for " + problem
472-
+ " was interrupted/timed out. Output:\n" + output.toString() + "\nError:\n"
473-
+ errorOutput.toString());
474-
throw new org.junit.platform.commons.JUnitException(
475-
"Test for problem " + problem + " timed out after 3 seconds.", e);
476-
} catch (Exception e) {
477-
Assertions.fail("Error waiting for process for problem " + problem + ": " + e.getMessage());
478-
return;
479-
}
480-
// --- End: Capture output and error streams ---
490+
// Wait for each task to complete with the defined timeout
491+
result = future.get(TEST_TIMEOUT.toSeconds(), TimeUnit.SECONDS);
481492

482-
System.out.println(Thread.currentThread().getName() + ": Command output for " + problem + ":\n"
483-
+ output.toString());
484-
if (errorOutput.length() > 0) {
485-
System.err.println(Thread.currentThread().getName() + ": Command error for " + problem + ":\n"
486-
+ errorOutput.toString());
487-
}
493+
System.out.println("Test " + result.problemName + " completed. Output:\n" + result.output);
494+
if (!result.errorOutput.isEmpty()) {
495+
System.err.println("Test " + result.problemName + " error output:\n" + result.errorOutput);
496+
}
488497

489-
Assertions.assertEquals(
490-
0,
491-
exitCode,
492-
"Maven command failed for problem: " + problem + "\nError output:\n"
493-
+ errorOutput.toString());
498+
Assertions.assertTrue(
499+
result.success,
500+
"Maven command failed for problem: " + result.problemName + "\nError output:\n"
501+
+ result.errorOutput);
502+
503+
} catch (TimeoutException e) {
504+
// This handles the case where the Callable itself exceeds the timeout
505+
future.cancel(true); // Attempt to interrupt the running task
506+
Assertions.fail(
507+
"Test for problem " + (result != null ? result.problemName : "unknown")
508+
+ " timed out after " + TEST_TIMEOUT.toSeconds() + " seconds.",
509+
e);
510+
} catch (InterruptedException e) {
511+
Thread.currentThread().interrupt(); // Restore interrupt status
512+
Assertions.fail(
513+
"Test for problem " + (result != null ? result.problemName : "unknown")
514+
+ " was interrupted.",
515+
e);
516+
} catch (ExecutionException e) {
517+
// The actual exception thrown by the Callable is wrapped here
518+
Throwable cause = e.getCause();
519+
Assertions.fail(
520+
"An error occurred during execution for problem "
521+
+ (result != null ? result.problemName : "unknown") + ": " + cause.getMessage(),
522+
cause);
523+
}
494524
}))
495525
.collect(Collectors.toList());
526+
527+
// Crucial: Shut down the executor after all tests have been processed
528+
// For a @TestFactory, this is a bit tricky as the tests are returned, not run immediately.
529+
// The safest place to shut down is after collecting all dynamic tests, or in an @AfterAll method.
530+
// However, @AfterAll needs the ExecutorService to be static.
531+
// For simple test runs, `shutdownNow()` is often acceptable here.
532+
executor.shutdown();
533+
try {
534+
// Wait for existing tasks to terminate
535+
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
536+
executor.shutdownNow(); // Forcefully terminate if not done in time
537+
}
538+
} catch (InterruptedException e) {
539+
executor.shutdownNow();
540+
Thread.currentThread().interrupt(); // Restore interrupt status
541+
}
542+
543+
return dynamicTests;
544+
}
545+
546+
// A simple record/class to encapsulate test results
547+
private static class TestResult {
548+
String problemName;
549+
boolean success;
550+
String output;
551+
String errorOutput;
552+
Throwable exception; // To store any exception from the callable
553+
554+
// Primary constructor
555+
public TestResult(String problemName, boolean success, String output, String errorOutput, Throwable exception) {
556+
this.problemName = problemName;
557+
this.success = success;
558+
this.output = output;
559+
this.errorOutput = errorOutput;
560+
this.exception = exception;
561+
}
562+
563+
@Override
564+
public String toString() {
565+
// Used by DynamicTest.dynamicTest() to name the test
566+
return problemName;
567+
}
496568
}
497569
}

0 commit comments

Comments
 (0)