Skip to main content

Improving test harness: generating coverage report with on-the-fly instrumentation

Posted by kohsuke on September 2, 2007 at 12:06 PM PDT

Metro uses a dedicated test harness for writing end-to-end unit tests. This harness builds on top of JUnit but adds a lot of useful features and conventions so that we can write tests more productively. One of the features I added lately was the ability to generate code coverage report, and I'm going to talk about the technique because I think this is useful to other test harnesses.

The standard way to do code coverage report is by instrumenting byte code to record information. Because of this, most tools typically ask you to run a tool to post-process class files. This makes sense but it's tedious. Since Metro test harness (and most test harnesses in general) uses multiple classloaders to isolate the test subject from the harness, if we can have a custom classloader that performs instrumentation on-the-fly, then we can produce a coverage report without requiring any upfront processing.

In Metro, we use Emma for the coverage report, so the following code shows how to do this in Emma. Doing something like this with Cobertura is quite easy, except that its license GPL forces your harness to be GPL, too.

Anyway, the first you need is a ClassLoader that calls bytecode transformer in Emma. There's nothing particularly noteworthy here. You just invoke IClassLoadHook.processClassDef:

public final class InstrumentingClassLoader extends AntClassLoader2 {
    final IClassLoadHook transformer;

    public InstrumentingClassLoader(IClassLoadHook transformer) {
        this.transformer = transformer;
    }

    public final Class findClass(final String name) throws ClassNotFoundException {
        // find the class file image
        String classResource = name.replace('.', '/') + ".class";
        URL classURL = getResource(classResource);

        if (classURL == null)
            throw new ClassNotFoundException(name);

        try {
            byte[] image = readFully(classURL);

            ByteArrayOStream baos = new ByteArrayOStream(image.length);
            if (transformer.processClassDef(name, image, image.length, baos))
                image = baos.copyByteArray();

            return defineClass(name, image, 0, image.length);
        } catch (IOException ioe) {
            throw new Error(ioe);
        }
    }

    private byte[] readFully(URL resource) throws IOException {
        URLConnection con = resource.openConnection();
        InputStream in = con.getInputStream();
        try {
            int len = con.getContentLength();
            if(len!=-1) {
                byte[] buf = new byte[len];
                new DataInputStream(in).readFully(buf);
                return buf;
            } else {
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                byte[] buf = new byte[1024];
                while((len=in.read(buf))>=0)
                    baos.write(buf,0,len);
                return baos.toByteArray();
            }
        } finally {
            in.close();
        }
    }
}

Then I wrapped the whole thing into the Emma class. IInclExclFilter is a mechanism that can be used to decide which classes to instrument and which classes not to.

public class Emma {
    private final IMetaData metadata;
    private final IInclExclFilter filter;
    private ICoverageData coverage;

    public Emma() {
        this(new IInclExclFilter() {
            public boolean included(String s) {
                return true;
            }
        });
    }

    public Emma(IInclExclFilter filter) {
        // we'll take care of record dump
        RTSettings.setStandaloneMode(false);
        RT.reset(true,false);


        // KK : not sure exactly what IProperties are abstracting,
        // but looks like they are used to allow emma configurations
        // to be given from different sources
       
        IProperties appProperties = RT.getAppProperties();
        if (appProperties == null) appProperties = EMMAProperties.getAppProperties();
        metadata = DataFactory.newMetaData(CoverageOptionsFactory.create(appProperties));

        coverage = RT.getCoverageData();

        this.filter = filter;
    }

    public AntClassLoader2 createInstrumentingClassLoader() {
        return new InstrumentingClassLoader(new InstrClassLoadHook(filter, metadata));
    }

    /**
     * Writes the coverate report as a data file.
     */
    public void write(File output) throws IOException {
        IMetaData msnap = metadata.shallowCopy();
        if (msnap.isEmpty ()) {
            System.err.println("no metadata collected at runtime [no reports generated]");
            return;
        }

        ICoverageData csnap = coverage.shallowCopy();
        if (csnap.isEmpty ()) {
            System.err.println("no coverage data collected at runtime [all reports will be empty]");
            return;
        }

        System.out.println("Writing emma coverage report");
        DataFactory.persist(new SessionData(msnap, csnap), output, false);
    }
}

The createInstrumentingClassLoader method returns a instrumenting classloader. This extends from AntClassLoader, so you can set the parent classloader and add classpaths after you create an instance.

When everything is over, you can call the write method to get the coverage data file written in a file. You can then use emma CLI/Ant tasks to produce human-readable reports.

Related Topics >>