Embedding JUnit tests

 
Thinking About Computing
Articles by Bruce Eckel
Formerly Web Log, see new web log

4-6-04 Embedding JUnit tests

For the 4th edition of Thinking in Java, I've been experimenting with ways to take better control of JUnit testing. My primary interest is in automatically generating the JUnit code itself to minimize the effort of the programmer.

One approach is to use JUnitDoclet, which automatically creates frameworks of JUnit code for various classes. This is certainly a step in the right direction by eliminating all the redundant code generation, but:

1. The programmer must still switch over from the class code to the JUnit code framework and write the test code.

2. You have to worry about regeneration tags so that the JUnitDoclet can regenerate framework without losing work.

3. The essential test code is still mixed in with all the JUnit code, and requires extra effort to track down and maintain.

It would be nice if the programmer only needed to write the essence of the test code rather than write or even navigate around the JUnit code. In addition, it would be nice to be able to put the test code right next to the code that was being tested, so you could more easily keep track of and change the code. Finally, it would be helpful if the programmer rarely, if ever, has to look at all the JUnit code, but rather is able to just focus on the essence and assume that everything else will be automated.

I have been peripherally involved in the design of Walter Bright's "D" programming language (free at DigitalMars since it's inception, and one of the features I suggested was compiler support for unit testing. The resulting syntax produced what seems like an obvious Java solution for the same problem: embed the essence of the unit test code within inline comments in the code for the class to be tested, and then automatically generate the JUnit code - which can then be used without any examination or intervention on the part of the programmer. To make changes, the programmer only needs to change the commented source code, and run the JUnit generator again.

As an example, consider JUnitDemo.java from Thinking in Java 3rd edition. We'll embed the unit test code as comments. Each fragment of test code will be surrounded by a multiline comment beginning with /*~, to uniquely identify it and make it easy for the code generator to extract. Each block will always begin with the word "test," but the very first one will start with an uppercase 'T' to indicate that this is the test class name. This first block will also begin with information such as the package statement and any necessary imports. The remaining information in the first block can include constructors, setUp() and tearDown() methods (although these are normally not necessary), helper methods, and fields, such as list in the example below.

Each of the test method blocks also begins with /*~, but the word "test" begins with a lowercase 't' to indicate this is a test method. The lines of code that follow are simply wrapped into the test method of the given name.

//: c15:CountedListEmbedded.java
// Embedded comments to generate JUnit tests
import java.util.*;
import junit.framework.*;

public class CountedListEmbedded extends ArrayList {
  private static int counter = 0;
  private int id = counter++;
  public CountedListEmbedded() {
    System.out.println("CountedListEmbedded #" + id);
  }
  public int getId() { return id; }
  /*~ TestCountedListEmbedded
  // package statement goes here if necessary
  import java.util.*;  // Any imports go here
  private CountedListEmbedded list =
    new CountedListEmbedded();
  public TestCountedListEmbedded() {
    for(int i = 0; i < 3; i++)
      list.add("" + i);
  }
  protected void setUp() {
    System.out.println("Set up for " + list.getId());
  }
  public void tearDown() {
    System.out.println("Tearing down " + list.getId());
  }
  private void
  compare(CountedListEmbedded lst, String[] strs) {
    Object[] array = lst.toArray();
    assertTrue("Arrays not the same length",
      array.length == strs.length);
    for(int i = 0; i < array.length; i++)
      assertEquals(strs[i], (String)array[i]);
  }
  */
  // The methods to be tested are shown
  // redundantly here, as a demonstration:
  public void add(int index, Object element) {
    super.add(index, element);
  }
  /*~ testInsert
    System.out.println("Running testInsert()");
    assertEquals(list.size(), 3);
    list.add(1, "Insert");
    assertEquals(list.size(), 4);
    assertEquals(list.get(1), "Insert");
  */
  // A method to be tested:
  public Object set(int index, Object element) {
    return super.set(index, element);
  }
  /*~ testReplace
    System.out.println("Running testReplace()");
    assertEquals(list.size(), 3);
    list.set(1, "Replace");
    assertEquals(list.size(), 3);
    assertEquals(list.get(1), "Replace");
  */
  /*~ testOrder
    System.out.println("Running testOrder()");
    compare(list, new String[] { "0", "1", "2" });
  */
  // A method to be tested:
  public Object remove(int index) {
    return super.remove(index);
  }
  /*~ testRemove
    System.out.println("Running testRemove()");
    assertEquals(list.size(), 3);
    list.remove(1);
    assertEquals(list.size(), 2);
    compare(list, new String[] { "0", "2" });
  */
  // A method to be tested:
  public boolean addAll(Collection c) {
    return super.addAll(c);
  }
  /*~ testAddAll
    System.out.println("Running testAddAll()");
    list.addAll(Arrays.asList(new Object[] {
      "An", "African", "Swallow"}));
    assertEquals(list.size(), 6);
    compare(list, new String[] { "0", "1", "2",
       "An", "African", "Swallow" });
  */
} ///:~

This is a very simple, straightforward approach. With more effort, you could deduce the name of the class under test and prepend the word "Test" to it in order to generate the class name, and you could also automatically generate the test method names. For this first implementation of the idea, however, the approach above seems to achieve the desirable leverage.

The easiest and most appropriate approach to generating the JUnit test from the extracted code is a Python program, although it could be done using Java (with a great deal more effort - this exercise is left to the reader). This is JUnitCreate.py:

import os, re

pattern = re.compile("//*~(.*?)/*/", re.DOTALL)

BeginFile = """/
import junit.framework.*;

public class %s extends TestCase {
"""

TestMethod = """/
  public void %s() {
%s
  }"""

EndFile = """/
  public static void main(String[] args) {
    // Invoke JUnit on the class:
    junit.textui.TestRunner.run(%s.class);
  }
}
"""

for path, dirs, files in os.walk('.'):
    for filepath in [ path + os.sep + f
            for f in files if f.endswith(".java")]:
        package = None
        imports = ''
        header = ''
        matches = pattern.findall(file(filepath).read())
        if matches:
            startBody = matches[0].split("/n")
            className = startBody[0].strip()
            assert className.startswith("Test"), /
                "className must start with 'Test'"
            junitName = path + os.sep + className + ".java"
            print "Creating", junitName
            tfile = file(junitName, "w")
            for line in startBody[1:]:
                if line.strip().startswith("package"):
                    package = line.strip()
                elif line.strip().startswith("import"):
                    imports += line.strip() + "/n"
                else:
                    if line.strip() != "":
                        header += line.rstrip() + "/n"
            if package:
                print >>tfile, package
            if imports:
                print >>tfile, imports,
            print >>tfile, BeginFile % className,
            print >>tfile, header,
            for m in matches[1:]:
                methodName, methodBody = m.split("/n",1)
                methodName = methodName.strip()
                assert methodName.startswith("test"), /
                    "methodName must start with 'test'"
                print >>tfile, TestMethod % (
                    methodName, methodBody.rstrip())
            print >>tfile, EndFile % className

This program uses the "re" regular-expression module to quickly extract all the fragments of unit-test code. The regular expression pattern is precompiled with the compile() statement. You'll see that regular expressions in most languages are fairly similar, although Python's do not require as much escaping as Java's do. The above expression captures the text within the starting '/*~' and the ending '*/', with the '*' escaped because they are regular expression characters. The main part of the expression, '(.*?)', captures any character any number of times with the '.*', but the question mark means that the expression is not "greedy" and will thus stop the first time a '*/' is encountered. DOTALL is a flag indicating that the '.' will capture everything including newlines. Because the main part of the expression is contained within parentheses, this will be saved separately when the expression is used.

BeginFile, TestMethod and EndFile are multiline strings, denoted by the triple-quotes. They also contain '%s' characters, which allow printf-style formatting.

The main operation of the code begins with the os.walk() expression that walks every file in the directory. The list comprehension produces the full path of each of the Java files. Next, the contents of each Java file is read and passed through the findall() regular expression, which produces a list of each of the portions of the string that matches the previously-compiled regular expression, but only the part that was inside the parentheses of that regular expression.

If findall() doesn't find anything the matches object will be the special value None and the if statement will be false, so the program will go on to the next file. If it finds a match, the first block will contain the basic information about the unit test file, so matches[0] is "split" at its newlines to produce startBody. Line zero of startBody contains the unit test class name; the strip() method removes leading and trailing white space. We want this to begin with the capitalized word "Test" and the assertion guarantees this.

With the test class name we can open the file to contain the text of the JUnit program; this is assigned to the variable tfile. Each of the rest of the lines in startBody are examined to see if they are package or import statements; if so they are stored so they can be inserted in the JUnit program in the appropriate place, otherwise the lines are added to the header section.

The remaining matches (denoted by Python's list-slicing syntax, so matches[1:] means "from index 1 until the end of the list) are dealt with similarly, except that each of their first lines indicate the test method name, and the rest of the lines are the method body. The first and remaining lines of each method block are separated with the m.split("/n", 1) call, which does a single split (the number of splits is determined by the second argument) at the first newline. The results come back as a two- element tuple, each of which is assigned in the expression:

methodName, methodBody = m.split("/n",1)

The print statements use a redirection syntax, for example:

print >>tfile, EndFile % className

The '>>tfile' sends the results to tfile rather than the console. The '%' causes className to be substituted for the %s tag in the EndFile variable; if there is more than one element to be substituted the elements must be place in a tuple, as in:

print >>tfile, TestMethod % (methodName, methodBody.rstrip())

When this program is run on CountedListEmbedded.java, the result is:

import java.util.*;  // Any imports go here
import junit.framework.*;

public class TestCountedListEmbedded extends TestCase {
  // package statement goes here if necessary
  private CountedListEmbedded list =
    new CountedListEmbedded();
  public TestCountedListEmbedded() {
    for(int i = 0; i < 3; i++)
      list.add("" + i);
  }
  protected void setUp() {
    System.out.println("Set up for " + list.getId());
  }
  public void tearDown() {
    System.out.println("Tearing down " + list.getId());
  }
  private void
  compare(CountedListEmbedded lst, String[] strs) {
    Object[] array = lst.toArray();
    assertTrue("Arrays not the same length",
      array.length == strs.length);
    for(int i = 0; i < array.length; i++)
      assertEquals(strs[i], (String)array[i]);
  }
  public void testInsert() {
    System.out.println("Running testInsert()");
    assertEquals(list.size(), 3);
    list.add(1, "Insert");
    assertEquals(list.size(), 4);
    assertEquals(list.get(1), "Insert");
  }
  public void testReplace() {
    System.out.println("Running testReplace()");
    assertEquals(list.size(), 3);
    list.set(1, "Replace");
    assertEquals(list.size(), 3);
    assertEquals(list.get(1), "Replace");
  }
  public void testOrder() {
    System.out.println("Running testOrder()");
    compare(list, new String[] { "0", "1", "2" });
  }
  public void testRemove() {
    System.out.println("Running testRemove()");
    assertEquals(list.size(), 3);
    list.remove(1);
    assertEquals(list.size(), 2);
    compare(list, new String[] { "0", "2" });
  }
  public void testAddAll() {
    System.out.println("Running testAddAll()");
    list.addAll(Arrays.asList(new Object[] {
      "An", "African", "Swallow"}));
    assertEquals(list.size(), 6);
    compare(list, new String[] { "0", "1", "2",
       "An", "African", "Swallow" });
  }
  public static void main(String[] args) {
    // Invoke JUnit on the class:
    junit.textui.TestRunner.run(TestCountedListEmbedded.class);
  }
}

With this system, you simply keep the test code up-to-date within the source file, and generate the surrounding JUnit code automatically, every time you do a build.

The build system for Thinking in Java, 4th edition, automatically runs JUnitCreate.py, and then the results are incorporated into the Ant build.xml script so that the unit tests are automatically executed during a build. However, another Python program removes the inline unit tests before the code is automatically incorporated back into the book.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值