Some JUnit Test Rules

Categories: Programming, Java

Overview

JUnit is one of the most widely-used frameworks for unit-testing Java code.

One of its features is the ability to associate “rule objects” with a test-class which are executed before and after each test. This article presents two reasonably simple rule classes I have implemented recently; one sets up a temporary folder on disk for the test to write to or read from, and the other allows tests to verify the logging output generated by tested code.

JUnit Rules

Every user of JUnit knows about @Before and @After methods on test-classes, which are invoked before and after each test. Rules provide similar functionality with a somewhat more elegant syntax. Alternatively, a JUnit rule can be described as an interceptor or around advice for test methods.

Given a class which extends org.junit.rules.TestRule, and a plain member variable of that type on the test-class annotated with @Rule, then the member’s “apply” method is invoked for each test-method, allowing it to perform setup/teardown and exception-handling for the test-method.

Multiple rule objects can be applied to the same test-class if needed.

WorkdirRule

This extremely simple but useful rule allows code like this:

public class SomeTest {
  @Rule
  public WorkdirRule workdirRule = new WorkdirRule("sometest");

  @Test
  public void testSomething() {
    Path workdir = workdirRule.getDir();
    try(OutputStream os = Files.newOutputStream(workdir)) {
      os.write("hello, world".getBytes());
    }  

    ...
  }
}

A temporary directory is created before method testSomething is invoked, and is deleted afterwards.

The implementation of WorkdirRule is:

// Author Simon Kitching 2017.
// This software is in the public domain
package net.vonos.junit.rules;

import org.apache.commons.io.FileUtils;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

import java.nio.file.Files;
import java.nio.file.Path;

/**
 * JUnit rule which creates a temporary directory (accessible via method getDir), runs a test, then deletes
 * the directory.
 */
public class WorkdirRule implements TestRule {
    private final String prefix;
    private Path dir;

    public WorkdirRule(String prefix) {
        this.prefix = prefix;
    }

    public Path getDir() {
        return dir;
    }

    @Override
    public Statement apply(Statement statement, Description description) {
        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
                dir = Files.createTempDirectory(prefix);
                try {
                    statement.evaluate();
                } finally {
                    FileUtils.deleteDirectory(dir.toFile());
                }
            }
        };
    }
}

LogInterceptRule

Perhaps assertions that a specific class being tested has logged messages with specific content is not in the best of taste, but sometimes it is necessary. If the application is using Logback as the concrete logging framework (and presumably SLF4J as the API to it), then this rule will allow writing such tests.

Because a large amount of logging may be generated for many different categories, this rule requires an annotation on each test method to indicate which categories (if any) should be captured and saved for later inspection. Example usage:

public class SomeTest {
  @Rule
  public LogInterceptRule logRule = new LogInterceptRule();

  @Test
  @LogInterceptRule.forName("net.vonos.example")
  public void testSomething() {
    .. some code that logs to net.vonos.example ..
    Assert.assertEquals(2, logRule.getMessages().size());
    // The logRule has captured all output to the specified category so assertions can also test exact content if desired
  }
}

The implementation of LogInterceptRule is:

// Author Simon Kitching 2017.
// This software is in the public domain
package net.vonos.junit.rules;

import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.PatternLayout;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.UnsynchronizedAppenderBase;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import org.slf4j.LoggerFactory;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;

/**
 * Junit rule to capture all logmessages to a specific logging category.
 * <p>
 * This assumes that the underlying logging implementation is logback-classic.
 * </p>
 * <p>
 * Use as follows:
 * <pre>
 * @Rule
 * public final LogInterceptRule logRule = new LogInterceptRule();
 *
 * @Test
 * @LogInterceptRule.ForName("foo.bar")
 * public void testSomething() {
 *     ...
 *     Assert.assertEquals(2, logRule.getMessages().size());
 * }
 * </pre>
 * </p>
 */
public class LogInterceptRule implements TestRule {
    private final MemAppender memAppender;

    /** Constructor. */
    public LogInterceptRule() {
        this.memAppender = new MemAppender();
    }

    /**
     * Return the captured messages without formatting (no level, category, stacktraces or variable-interpolation).
     */
    public List<String> getRawMessages() {
        return memAppender.rawMessages;
    }

    /**
     * Return the captured messages with basic formatting, stacktraces and variable-interpolation.
     */
    public List<String> getMessages() {
        return memAppender.messages;
    }

    /**
     * Implement the junit-rule-behaviour: return a closure whose evaluate() method will run the specified "base"
     * statement, with logging-logic wrapped around it.
     */
    @Override
    public Statement apply(Statement base, Description description) {
        memAppender.clear();

        String catName = getCategory(description);
        if (catName == null) {
            return base;
        }

        ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(catName);
        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
                logger.addAppender(memAppender);
                try {
                    base.evaluate();
                } finally {
                    logger.detachAppender(memAppender);
                }
            }
        };
    }

    // ====================================================================================================

    /**
     * Annotation type that can be used to specify logging-category to be captured by string.
     */
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface ForName {
        String value();
    }

    /**
     * Annotation type that can be used to specify logging-category to be captured by class.
     */
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface ForClass {
        Class<?> value();
    }

    /**
     * Determine the logging-category to capture (if any) for a specific junit-test-method.
     */
    public static String getCategory(Description desc) {
        ForName fn = desc.getAnnotation(ForName.class);
        ForClass fc = desc.getAnnotation(ForClass.class);

        if ((fn == null) && (fc == null)) {
            return null;
        }

        if ((fn != null) && (fc == null)) {
            return fn.value();
        }

        if ((fc != null) && (fn == null)) {
            return fc.value().getName();
        }

        throw new IllegalArgumentException("Only one of ForName and ForClass may be specified");
    }

    /**
     * Custom SLF4J appender which saves messages into an in-memory list.
     * <p>
     * Only level WARN or above are kept. The logging format is hard-wired here so tests are not dependent
     * on the current logging configuration settings.
     * </p>
     */
    private static class MemAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {
        protected final ReentrantLock lock = new ReentrantLock(true);
        protected final List<String> rawMessages = new ArrayList<>();
        protected final List<String> messages = new ArrayList<>();
        private final PatternLayout patternLayout;

        // Minimal pattern without date, threadid, etc as these are not expected to be useful for tests.
        private static final String PATTERN = "%level %logger %msg%n";

        public MemAppender() {
            // Get the static app-wide context
            LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();

            patternLayout = new PatternLayout();
            patternLayout.setContext(lc);
            patternLayout.setPattern(PATTERN);
            patternLayout.start();

            start();
        }

        protected void clear() {
            rawMessages.clear();
            messages.clear();
        }

        @Override
        protected void append(ILoggingEvent e) {
            if (!this.isStarted()) {
                return;
            }

            if (!e.getLevel().isGreaterOrEqual(ch.qos.logback.classic.Level.WARN)) {
                // Filter out INFO and lower; tests are not expected to be interested in such things.
                return;
            }

            this.lock.lock();
            try {
                rawMessages.add(e.getMessage());

                String fm = patternLayout.doLayout(e);
                messages.add(fm);
            } finally {
                this.lock.unlock();
            }
        }
    }
}