JUnit best practices (V)

上一篇 / 下一篇  2008-02-01 21:11:02

JUnit best practices

Techniques for building resilient, relocatable, multithreaded JUnit tests

Page 5 of 5

If we built a test case for the entire system, it would consist of all the other test cases, already defined. The test case would define thesuite()method, which would add all test cases defined in the system to aTestSuite. This test suite would then be returned from thesuite()method. If you had many test cases, building such a test suite would be time-consuming. In addition, you would have to update the universal test case when new test cases were added or existing test cases were renamed or deleted. Instead of manually building and maintaining the test suite, build a test case that automatically builds aTestSuitefrom all of your system's test cases. Here is an outline of the requirements for such a test case:

  • It should not be self-loading; that would cause recursion. As such, we need to mark test cases as not loadable.
  • It should not load classes derived fromTestCases that are meant to be subclasses, and not directly executed.
  • It should distinguish between unit tests and other tests, like load or stress tests. That will let different tests run at different times.
  • It should recurse down a directory structure, looking for test cases to add to the test suite.


We can use the Java type system to determine what sort of test a test case represents. We can have test cases extend classes likeUnitTest,StressTest,LoadTest, and so on. However, this would make test case classes difficult to reuse between test types, because the test type decision is made near the root of the inheritance hierarchy; it should be made at each leaf instead. As an alternative, we can distinguish tests using a field:public static final String TEST_ALL_TEST_TYPE. Test cases will be loaded if they have this field declared with a value matching a string that the automatic test case has been configured with. To build this, we'll implement three classes:

  • ClassFinderrecursively searches a directory tree for classfiles. Each classfile is loaded and the class's full class name is extracted. That class name is added to a list for later loading.
  • TestCaseLoaderloads each class in the list found byClassFinderand determines if it is a test case. If it is, it is added to a list.
  • TestAllis a subclass ofTestCasewith an implementation ofsuite()that will load in a set of test cases byTestCaseLoader.


Let's look at each class in turn.

ClassFinder

ClassFinderlocates the classes within the system to be tested. It is constructed with the directory that holds the system's classes.ClassFinderthen finds all the classes in the directory tree and stores them for later use. The first part ofClassFinder's implementation is below:

public class ClassFinder {
   // The cumulative list of classes found.
   final private Vector classNameList = new Vector ();
   /**
    * Find all classes stored in classfiles inclassPathRoot* Inner classes are not supported.
    */
   public ClassFinder(final File classPathRoot) throws IOException {
    findAndStoreTestClasses (classPathRoot);
   }
   /**
    * Recursive method that adds all class names related to classfiles it finds in
    * thecurrentDirectory(and below).
    */
   private void findAndStoreTestClasses (final File currentDirectory) throws IOException {
      String files[] = currentDirectory.list();
      for(int i = 0;i < files.length;i++) {
         File file = new File(currentDirectory, files[i]);
         String fileBase = file.getName ();
         int idx = fileBase.indexOf(".class");
         final int CLASS_EXTENSION_LENGTH = 6;
         if(idx != -1 && (fileBase.length() - idx) == CLASS_EXTENSION_LENGTH) {


In the code above, we iterate over all the files in a directory. If a filename has a ".class" extension, we determine the fully qualified class name of the class stored in the classfile, as seen here:

JcfClassInputStream inputStream = new JcfClassInputStream(new FileInputStream (file));
            JcfClassFile classFile = new JcfClassFile (inputStream);
            System.out.println ("Processing: " + classFile.getFullName ().replace ('/','.'));
            classNameList.add (classFile.getFullName ().replace ('/','.'));


This code uses the JCF package to load the classfile and determine the name of the class stored within it. The JCF package is a set of utility classes for loading and examining classfiles. (SeeResourcesfor more information.) The JCF package allows us to find each class's full class name. We could infer the class name from the directory name, but that doesn't work well for build systems that don't store classes according to this structure. Nor does it work for inner classes.

Lastly, we check to see if the file is actually a directory. (See the code snippet below.) If it is, we recurse into it. This allows us to discover all the classes in a directory tree:

} else if(file.isDirectory()) {
            findAndStoreTestClasses (file);
         }
      }
   }
/**
 * Return an iterator over the collection of classnames (Strings)
 */
public Iterator getClasses () {
   return classNameList.iterator ();
}
}


TestCaseLoader

TestCaseLoaderfinds the test cases among the class names fromClassFinder. This code snippet shows the top-level method for adding a class that represents aTestCaseto the list of test cases:

public class TestCaseLoader {
   final private Vector classList = new Vector ();
   final private String requiredType;
   /**
    * Adds testCaseClass to the list of classdes
    * if the class is a test case we wish to load. Calls
    * shouldLoadTestCase () to determine that.
    */
   private void addClassIfTestCase (final Class testCaseClass) {
      if (shouldAddTestCase (testCaseClass)) {
         classList.add (testCaseClass);
      }
   }
   /**
    * Determine if we should load this test case. Calls isATestCaseOfTheCorrectType
    * to determine if the test case should be
    * added to the class list.
    */
   private boolean shouldAddTestCase (final Class testCaseClass) {
      return isATestCaseOfTheCorrectType (testCaseClass);
   }


You'll find the meat of the class in theisATestCaseOfTheCorrectType()method, listed below. For each class being considered, it:

  • Determines whether it is derived fromTestCase. If not, it is not a test case.
  • Determines whether the fieldpublic final static TEST_ALL_TEST_TYPEhas a value matching that specified in the member fieldrequiredType.


Here's the code:

private boolean isATestCaseOfTheCorrectType (final Class testCaseClass) {
      boolean isOfTheCorrectType = false;
      if (TestCase.class.isAssignableFrom(testCaseClass)) {
         try {
            Field testAllIgnoreThisField = testCaseClass.getDeclaredField("TEST_ALL_TEST_TYPE");
            final int EXPECTED_MODIFIERS = Modifier.STATIC | Modifier.PUBLIC | Modifier.FINAL;
            if (((testAllIgnoreThisField.getModifiers() & EXPECTED_MODIFIERS) != EXPECTED_MODIFIERS) ||
               (testAllIgnoreThisField.getType() != String.class)) {
               throw new IllegalArgumentException ("TEST_ALL_TEST_TYPE should be static private final String");
            }
            String testType = (String)testAllIgnoreThisField.get(testCaseClass);
            isOfTheCorrectType = requiredType.equals (testType);
         } catch (NoSuchFieldException e) {
         } catch (IllegalAccessException e) {
            throw new IllegalArgumentException ("The field " + testCaseClass.getName () + ".TEST_ALL_TEST_TYPE is not accessible.");
         }
      }
      return isOfTheCorrectType;
   }


Next, theloadTestCases()method examines each class name. It loads the class (if it can be loaded); if the class is a test case and of the required type, the method adds the class to its list of test cases:

public void loadTestCases (final Iterator classNamesIterator) {
      while (classNamesIterator.hasNext ()) {
         String className = (String)classNamesIterator.next ();
         try {
            Class candidateClass = Class.forName (className);
            addClassIfTestCase (candidateClass);
         } catch (ClassNotFoundException e) {
            System.err.println ("Cannot load class: " + className);
         }
      }
   }
   /**
   * Construct this instance. Load all the test cases possible that derive
   * from baseClass and cannot be ignored.
   * @param classNamesIterator An iterator over a collection of fully qualified class names
   */
  public TestCaseLoader(final String requiredType) {
      if (requiredType == null) throw new IllegalArgumentException ("requiredType is null");
      this.requiredType = requiredType;
   }
   /**
   * Obtain an iterator over the collection of test case classes loaded by loadTestCases
   */
   public Iterator getClasses () {
      return classList.iterator ();
   }


TestAll

TestCallpulls everything together. It uses the aforementioned classes to build a list of test cases defined in the system. It adds those test cases to aTestSuiteand returns theTestSuiteas part of its implementation of thesuite()method. The result: a test case that automatically extracts every defined test case in the system, ready for execution by JUnit.

public class TestAll extends TestCase {


TheaddAllTests()method iterates over the classes loaded by theTestCaseLoaderand adds them to the test suite:

private static int addAllTests(final TestSuite suite, final Iterator classIterator)
   throws java.io.IOException {
      int testClassCount = 0;
      while (classIterator.hasNext ()) {
         Class testCaseClass = (Class)classIterator.next ();
         suite.addTest (new TestSuite (testCaseClass));
         System.out.println ("Loaded test case: " + testCaseClass.getName ());
         testClassCount++;
      }
      return testClassCount;
   }


Withsuite(), the test cases are added to theTestSuite, then returned to JUnit for execution. It obtains, from the system property"class_root", the directory where the classes are stored. It obtains, from the system property"test_type", the type of test cases to load. It uses theClassFinderto find all the classes, and theTestCaseLoaderto load all the appropriate test cases. It then adds these to a newTestSuite:

public static Test suite()
   throws Throwable {
      try {
         String classRootString = System.getProperty("class_root");
         if (classRootString == null) throw new IllegalArgumentException ("System property class_root must be set.");
         String testType = System.getProperty("test_type");
         if (testType == null) throw new IllegalArgumentException ("System property test_type must be set.");
         File classRoot = new File(classRootString);
         ClassFinder classFinder = new ClassFinder (classRoot);
         TestCaseLoader testCaseLoader = new TestCaseLoader (testType);
         testCaseLoader.loadTestCases (classFinder.getClasses ());
         TestSuite suite = new TestSuite();
         int numberOfTests = addAllTests (suite, testCaseLoader.getClasses ());
         System.out.println("Number of test classes found: " + numberOfTests);
         return suite;
      } catch (Throwable t) {
         // This ensures we have extra information. Otherwise we get a "Could not invoke the suite method." message.
         t.printStackTrace ();
         throw t;
      }
   }
  /**
   * Basic constructor - called by the test runners.
   */
   public TestAll(String s) {
      super(s);
   }
}


To test an entire system using these classes, execute the following command (in a Windows command shell):

java -cp C:\project\classes;C:\junit3.2\junit.jar:C:\jcf\jcfutils.zip -Dclass_root=C:\project\classes -Dtest_type=UNIT junit.ui.TestRunner bp.TestAll


This command loads and runs all test cases of typeUNITthat have classes stored underC:\project\classes.

Test thread safety

You'll want to guarantee the status of supposedly thread-safe classes by testing them. Such tests prove difficult using Junit 3.2's existing set of facilities. You can usejunit.extensions.ActiveTestto run a test case in a different thread. However,TestSuiteassumes that a test case is complete when it returns fromrun(); withjunit.extensions.ActiveTest, it is not. We could work hard to define a properly workingActiveTestSuite; instead, let's look at a simpler solution:MultiThreadedTestCase. First, I'll show howMultiThreadedTestCaseassists with multithreaded testing. Then I'll show howMultiThreadedTestCaseis implemented.

To useMultiThreadedTestCase, we implement the standard elements of aTestCase, but we derive fromMultiThreadedTestCase. The standard elements are the class declaration, the constructor, and since we're usingTestAll, the definition of the test type:

public class MTTest extends MultiThreadedTestCase {
   /**
    * Basic constructor - called by the test runners.
    */
   public MTTest(String s) {
      super (s);
   }
   public static final String TEST_ALL_TEST_TYPE = "UNIT";


A multithreaded test case needs to spawn a number of threads that perform some operation. We need to start those threads, wait until they've executed, and then return the results to JUnit -- all done in the code below. The code is trivial; in practice, this code would spawn multiple threads that performed different operations on the class under test. After each operation the class invariants and post-conditions would be tested to ensure that the class was behaving properly.

public void testMTExample ()
   {
      // Create 100 threads containing the test case.
      TestCaseRunnable tct [] = new TestCaseRunnable [100];
      for (int i = 0; i < tct.length; i++)
      {
         tct[i] = new TestCaseRunnable () {
            public void runTestCase () {
               assert (true);
            }
         };
      }
      // Run the 100 threads, wait for them to complete and return the results to JUnit.
      runTestCaseRunnables (tct);
   }
}


Now that I've shown how to useMultiThreadedTestCase, I'll examine the implementation. First, we declare the class and add an array where the running threads will be stored:

public class MultiThreadedTestCase extends TestCase {
   /**
    * The threads that are executing.
    */
   private Thread threads[] = null;


testResult, seen below, holds thetestResultthat declares that the test case'srun()will be passed. We overriderun()so we can store thetestResultfor later population by the test threads:

/**
    * The tests TestResult.
    */
   private TestResult testResult = null;
   /**
    * Simple constructor.
    */
   public MultiThreadedTestCase(final String s) {
      super(s);
   }
   /**
    * Override run so we can save the test result.
    */
   public void run(final TestResult result) {
      testResult = result;
      super.run(result);
      testResult = null;


runTestCaseRunnables()runs eachTestCaseRunnablein a seperate thread. All the threads are created and then started at the same time. The method waits until every thread has finished and then returns:

protected void runTestCaseRunnables (final TestCaseRunnable[] runnables) {
      if(runnables == null) {
         throw new IllegalArgumentException("runnables is null");
      }
      threads = new Thread[runnables.length];
      for(int i = 0;i < threads.length;i++) {
         threads[i] = new Thread(runnables[i]);
      }
      for(int i = 0;i < threads.length;i++) {
         threads[i].start();
      }
      try {
         for(int i = 0;i < threads.length;i++) {
            threads[i].join();
         }
      }
      catch(InterruptedException ignore) {
         System.out.println("Thread join interrupted.");
      }
      threads = null;
   }


Exceptions caught in the test threads must be propagated into thetestResultinstance we saved from therun()method.handleException(), below, does just that:

/**
    * Handle an exception. Since multiple threads won't have their
    * exceptions caught the threads must manually catch them and call
    * handleException().
    * @param t Exception to handle.*/
   private void handleException(final Throwable t) {
      synchronized(testResult) {
         if(t instanceof AssertionFailedError) {
            testResult.addFailure(this, (AssertionFailedError)t);
         }
         else {
            testResult.addError(this, t);
         }
      }
   }


Finally, we define the class that each test thread extends. The purpose of this class is to provide an environment (runTestCase()) where thrown exceptions will be caught and passed to JUnit. The implementation of this class is:

/**
    * A test case thread. Override runTestCase () and define
    * behaviour of test in there.*/
   protected abstract class TestCaseRunnable implements Runnable {
      /**
       * Override this to define the test*/
      public abstract void runTestCase()
                 throws Throwable;
      /**
       * Run the test in an environment where
       * we can handle the exceptions generated by the test method.*/
      public void run() {
         try {
            runTestCase();
         }
         catch(Throwable t) /* Any other exception we handle and then we interrupt the other threads.*/ {
            handleException(t);
            interruptThreads();
         }
      }
   }
}


The implementation above helps to develop multithreaded test cases. It handles exceptions thrown in the multiple testing threads and passes them back to JUnit. JUnit only sees a test case that behaves like a single-threaded test. The unit test developer can extend that test case to develop multithreaded tests, without spending much time developing thread-handling code.

Conclusion

Using JUnit to develop robust tests takes some practice (as does writing tests). This article contains a number of techniques for improving your tests' usefulness. Those techniques range from avoiding basic mistakes (such as not usingsetUp()) to more design-level issues (avoiding intertest coupling). I've covered some basic ideas to help you use JUnit to test parts of your UI or Web application. I've also shown how to build an automated test suite that removes the overhead of maintaining hand-coded test suites and a mechanism for reducing the effort of developing multithreaded JUnit test cases.

JUnit is an excellent framework for unit-testing Java applications. One final thought: If you just started using JUnit to produce unit tests, stick at it. For the first few weeks, you may not see any real reward for your labors. In fact, you may feel that the whole process slows you down. However, after a few weeks, you'll begin to enhance existing code. Then you'll run your tests, pick up new bugs, and fix them. You'll be far more confident in your code base and you will see the value of unit testing.

Author Bio


TAG:

 

评分:0

我来说两句

Open Toolbar