Skip to content

Instantly share code, notes, and snippets.

@wvanderdeijl
Last active December 7, 2023 15:35
Show Gist options
  • Save wvanderdeijl/4c5f67edff96f3d5f34f to your computer and use it in GitHub Desktop.
Save wvanderdeijl/4c5f67edff96f3d5f34f to your computer and use it in GitHub Desktop.
JUnit Rule to write Jacoco execution data file after each test for application under test running on a (remote) container
package org.adfemg.datacontrol.view.uitest;
import java.io.IOException;
import java.net.ConnectException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.logging.Logger;
import org.jacoco.core.tools.ExecDumpClient;
import org.jacoco.core.tools.ExecFileLoader;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
/**
* JUnit rule to collect jacoco code coverage information during execution of a unit test method or test class.
* Can be used with a @Rule annotation to fire before/after each test method in your test or with a @RuleClass
* annotation to collect statistics over the entire test run of the test class.<p>
* As this rule has quite a few configuration properties, it includes a Builder to conveniently configure your
* rule class. See the documentation on these builder methods to see the exact behavior of this rule.
* <p>
* Example:
* {@code @ClassRule
* public static final JacocoRule jacoco = JacocoRule.builder().withOutputDir(Paths.get("coverage")).build();}
*/
public class JacocoRule implements TestRule {
private final boolean reset;
private final boolean append;
private final String host;
private final int port;
private final int retryCount;
private final long retryDelay;
private final Path outputdir;
private final JacocoReporter reporter;
private Description description; // Description of currently executing test-class or test-method
private static final Logger log = Logger.getLogger(JacocoRule.class.getName());
/**
* Private constructor. See {@link #builder()}and {@link Builder}for a builder to create and configure a JacocoRule.
* @param host host with the remote jacoco agent to connect to
* @param port port of the remote jacoco agent to connect to
* @param retryCount number of retries for a failed jacoco remote agent connection
* @param retryDelay number of milliseconds to wait between retries
* @param reset should the execution counters be reset at the beginning of the test?
* @param append should execution data be added if the file already exists, or should we overwrite
* @param outputdir directory where the execution data should be written
* @param reporter optional reporter that transforms the execution data to a readable report after the test
*/
private JacocoRule(final String host, final int port, final int retryCount, final long retryDelay,
final boolean reset, final boolean append, final Path outputdir, final JacocoReporter reporter) {
this.host = host;
this.port = port;
this.retryCount = retryCount;
this.retryDelay = retryDelay;
this.reset = reset;
this.append = append;
this.outputdir = outputdir;
this.reporter = reporter;
}
/**
* Static method to get a {@link Builder} to configure and create a new JacocoRule.
* @return Builder
*/
public static Builder builder() {
return new Builder();
}
private void before() throws IOException {
if (reset) {
reset();
}
}
private void after() throws IOException {
// construct a jacoco client to send request to dump statistics
ExecDumpClient client = new ExecDumpClient();
ExecFileLoader dump = dump(client);
Path file = getDumpFile();
log.fine("Dumping code coverage execution data for " + description.getDisplayName() + " to " + file);
dump.save(file.toFile(), append);
if (reporter != null) {
// if a reporter is known, also create a report
String repname = description.getClassName();
if (description.getMethodName() != null) {
repname += "-" + description.getMethodName();
}
final String title = description.getDisplayName();
reporter.report(dump, repname, title);
}
}
private void reset() throws IOException {
ExecDumpClient client = new ExecDumpClient();
client.setDump(false);
client.setReset(true);
log.fine("Resetting code coverage execution statistics for " + description.getDisplayName());
dump(client);
}
private ExecFileLoader dump(ExecDumpClient client) throws IOException {
client.setRetryCount(retryCount);
client.setRetryDelay(retryDelay);
try {
return client.dump(host, port);
} catch (ConnectException e) {
throw new IOException("make sure application-under-test is running and you started its jvm with -javaagent:/somewhere/jacocoagent.jar=output=tcpserver",
e);
}
}
private Path getDumpFile() throws IOException {
return outputdir.resolve(getDecriptionFileName() + ".exec").normalize().toAbsolutePath();
}
private String getDecriptionFileName() {
StringBuilder filename = new StringBuilder(description.getClassName());
if (description.getMethodName() != null) {
filename.append("-").append(description.getMethodName());
}
return filename.toString();
}
/**
* Modifies the given method-running Statement to potentiale reset execution statistics before executing the test,
* dumping execution statistics after the test, and optionally create a report after the test.
* @param base the Statement to be modified
* @param description a Description of the test implemented in given Statement
* @return a new Statement, a wrapper around base, that handles any pre- and post-actions required for collecting
* and report jacoco statistics.
*/
@Override
public Statement apply(final Statement base, final Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
JacocoRule.this.description = description;
before();
try {
base.evaluate();
} finally {
after();
}
}
};
}
/**
* Builder to configure and create a new JacocoRule. Each configuration method returns this same builder after
* changing its internal state. This can be used to chain multiple method invocations in a single statement. The
* final method being called should be {@link #build} which actually returns the configured JacococRule.
* <p>
* See each method for an explanation of the separate configurable items.
* <p>
* Example
* {@code JacocoRule.builder().withHost("test.example.com").withOutputDir(Paths.get("coverage")).build();}
*/
public static class Builder {
private static final String DFLT_HOST = "localhost";
private static final int DFLT_PORT = 6300;
private static final int DFLT_RETRY_COUNT = 0;
private static final int DFLT_RETRY_DELAY = 1000;
private static final Path DFLT_OUTPUT_DIR = Paths.get("");
private boolean reset = true;
private boolean append = false;
private String host = DFLT_HOST;
private int port = DFLT_PORT;
private int retryCount = DFLT_RETRY_COUNT;
private long retryDelay = DFLT_RETRY_DELAY;
private Path outputdir = DFLT_OUTPUT_DIR;
private JacocoReporter reporter = null;
/**
* Private constructor.
* @see JacocoRule#builder
*/
private Builder() {
}
/**
* Do not reset the jacoco statistics at the beginning of the test instead of the default behavior which
* is to reset at the beginning so you get a clean report of the coverage that ran just during your test.
* @return Builder so you can continue invoking configuration methods
*/
public Builder withoutReset() {
this.reset = false;
return this;
}
/**
* Do not overwrite the existing execution file (which is the default), but append to the file if it already
* exists.
* @return Builder so you can continue invoking configuration methods
*/
public Builder withAppend() {
this.append = true;
return this;
}
/**
* Do not connect to the default host ({@value #DFLT_HOST}) but to the given host.
* @param host host name (or ip address) to connect to that runs the jacaoco agent
* @return Builder so you can continue invoking configuration methods
* @see #withPort
*/
public Builder withHost(final String host) {
this.host = host;
return this;
}
/**
* Do not connect to the default port ( {@value #DFLT_PORT}) but to the given port.
* @param port port number to connect to for the host that runs the jacaoco agent
* @return Builder so you can continue invoking configuration methods
* @see #withHost
*/
public Builder withPort(final int port) {
this.port = port;
return this;
}
/**
* Specify if, and how many times, we should retry connecting to the jacoco agent. Default is no retries.
* @param retryCount number of retries, where 0 means no retries
* @return Builder so you can continue invoking configuration methods
* @see #withRetryDelay
*/
public Builder withRetryCount(final int retryCount) {
this.retryCount = retryCount;
return this;
}
/**
* Specify how many milliseconds we should wait between try attempts to connect to the jacoco agent. Default is
* {@value #DFLT_RETRY_DELAY} millisecs.
* @param retryDelayMSecs number of msecs to wait between retry attempts
* @return Builder so you can continue invoking configuration methods
* @see #withRetryCount
*/
public Builder withRetryDelay(final long retryDelayMSecs) {
this.retryDelay = retryDelayMSecs;
return this;
}
/**
* Specify the directory to write the execution data files. Default is the current working directory.
* @param dir directory to write the execution data files
* @return Builder so you can continue invoking configuration methods
*/
public Builder withOutputDir(final Path dir) {
this.outputdir = dir;
return this;
}
/**
* Specify the optional {@link JacocoReporter} to write a human readable report after dumping the execution
* data file. Default is to not use a reporter and only write the execution data file for later analysis.
* @param reporter a JacocoReporter or {@code null} if no reporter is needed
* @return Builder so you can continue invoking configuration methods
*/
public Builder withReporter(final JacocoReporter reporter) {
this.reporter = reporter;
return this;
}
/**
* Construct a JacocoRule with all the configuration given to this builder.
* @return a fully (and immutable) JacocoRule instance.
*/
public JacocoRule build() {
return new JacocoRule(host, port, retryCount, retryDelay, reset, append, outputdir, reporter);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment