3.3 The xUnit Architecture
The xUnits
all have the same basic architecture. This
section describes the xUnit fundamentals, using JUnit as the
reference example, since it is the most widely used of the xUnits.
The other xUnits vary in their implementation details, but follow the
same pattern and generally contain the same key classes and concepts.
The key classes are TestCase,
TestRunner, TestFixture,
TestSuite, and TestResult.
The architecture diagrams in this section leave out some methods and
other design details for clarity and represent the generic xUnit
design, not that of JUnit.
3.3.1 TestCase
xUnit's
most elemental class is
TestCase, the base class for a unit test. It is
shown in Figure 3-1.
All unit tests are inherited from TestCase. To
create a unit test, define a test class that is descended from
TestCase and add a test method to it. Example 3-1 shows the unit test
BookTest.
Example 3-1. BookTest, a test built on TestCase
BookTest.java
import junit.framework.*;
public class BookTest extends TestCase {
public void testConstructBook( ) {
Book book = new Book("Dune");
assertTrue( book.getTitle( ).equals("Dune") );
}
}
The test method testConstructBook() uses assertTrue() to
check the value of the Book's
title. Test conditions always are evaluated by the
framework's assert methods. If a condition evaluates
to TRUE, the framework increments the successful
test counter. If it is FALSE, a test failure has
occurred and the framework records the details, including the
failure's location in the code. After a failure, the
framework skips the rest of the code in the test method, since the
test result is already known.
BookTest tests the class
Book, shown in
Example 3-2.
Example 3-2. The class Book
Book.java
public class Book {
private String title = "";
Book(String title) { this.title = title; }
String getTitle( ) { return title; }
}
This is the Book class developed in Chapter 2, with a few changes. The
title attribute is now private
and the accessor function getTitle( ) is added.
BookTest can be run by adding a main() method that calls the test method, as shown
in Example 3-3.
Example 3-3. BookTest with changes allowing it to be run
BookTest.java
import junit.framework.*;
public class BookTest extends TestCase {
public void testConstructBook( ) {
Book book = new Book("Dune");
assertTrue( book.getTitle( ).equals("Dune") );
}
public static void main(String args[]) {
BookTest test = new BookTest( );
test.testConstructBook( );
}
}
Compiling and running BookTest produces a
disappointing lack of output and not much confidence that anything
actually happened.
> javac BookTest.java
> java BookTest
>
For the commands to work as shown, junit.jar and
the directory containing the test classes must be in the
Java
classpath.
The results are more interesting if BookTest is
made to fail by changing the assertTrue( )
condition to FALSE.
> java BookTest
Exception in thread "main" junit.framework.AssertionFailedError
at junit.framework.Assert.fail(Assert.java:47)
at junit.framework.Assert.assertTrue(Assert.java:20)
at junit.framework.Assert.assertTrue(Assert.java:27)
at BookTest.testConstructBook(BookTest.java:7)
at BookTest.main(BookTest.java:12)
You can see that the unit test framework is doing its job, running
the test and reporting the test failure. This demonstrates that an
xUnit framework can be used in a very simple and straightforward way.
Basic unit tests can be built on TestCase without
any additional knowledge of the framework. However, the xUnits have
other, more useful functionality to offer. One of the most valuable
pieces is TestRunner.
3.3.2 TestRunner
A TestRunner reports details about the test results
and simplifies the test. It is a fairly complex object that, in
JUnit, comes in three flavors: the
AWT
TestRunner, the Swing TestRunner, and
the textual TestRunner (cleverly named
TextTestRunner.) Their purpose is to run one or more
TestCases and report the results. Figure 3-2 shows TextTestRunner.
The important methods of
TextTestRunner
are run( ), which
gives it a test to run, and main( ), which makes
TextTestRunner a runnable class.
TextTestRunner will be run with the test class
BookTest as its argument. It will find the test
method testConstructBook and run it.
You can remove the main( ) method in
BookTest, since you no longer need it
to run the test. Example 3-4 shows the refactored
BookTest.
Example 3-4. BookTest made simple again
BookTest.java
import junit.framework.*;
public class BookTest extends TestCase {
public void testConstructBook( ) {
Book book = new Book("Dune");
assertTrue( book.getTitle( ).equals("Dune") );
}
}
BookTest is reduced back to its essentials. Now,
use
TextTestRunner to run BookTest:
> java junit.textui.TestRunner BookTest
.
Time: 0.01
OK (1 test)
Using the TestRunner not only takes unnecessary
code out of BookTest, but also provides a nice
report of how many tests were run and how long they took.
Test classes often have multiple test methods.
TestRunner will find all of the test methods that
have names starting with test and run them. Example 3-5 shows BookTest with a
second test method added. The new test validates a
Book's author.
Example 3-5. BookTest with a second test method
BookTest.java
import junit.framework.*;
public class BookTest extends TestCase {
public void testConstructBook( ) {
Book book = new Book("Dune", "");
assertTrue( book.getTitle( ).equals("Dune") );
}
public void testAuthor( ) {
Book book = new Book("Dune", "Frank Herbert");
assertTrue( book.getAuthor( ).equals("Frank Herbert") );
}
}
The author attribute and its accessor
function getAuthor( ) are added to
Book, as shown in Example 3-6.
Example 3-6. Book with an author attribute
Book.java
public class Book {
private String title = "";
private String author = "";
Book(String title, String author) {
this.title = title;
this.author = author;
}
public String getTitle( ) { return title; }
public String getAuthor( ) { return author; }
}
Running BookTest shows that the framework now is
running two tests:
> java junit.textui.TestRunner BookTest
..
Time: 0.01
OK (2 tests)
A dot is printed when each test is run as a progress indicator. The
test output concludes with the number of tests and the elapsed time.
Most of the xUnits include a GUI
TestRunner to
provide enhanced visual feedback on the test results. The results are
highlighted in green if all the tests succeed, or in red if there is
a test failure. (This is the origin of the terms green
bar
and red
bar. The TDD cycle is sometimes described as
" Red-Green-Refactor"
because of this. First, implement a new test that fails, causing a
red bar; then, make the simplest possible code change that restores
the green bar; finally, refactor the possibly ugly code that was
introduced.) The chapters later in this book that describe specific
versions of xUnit show screenshots of their
TestRunner GUIs.
3.3.3 TestFixture
To
explain
test fixtures, another important xUnit concept, a more complex unit
test example is useful. Functionality will be added to the
Library class from Chapter 2
to allow multiple Books to be added and to get the
number of Books the Library
class contains. Example 3-7 gives an initial version
of the unit test LibraryTest that tests these new
features.
Example 3-7. Initial version of LibraryTest
LibraryTest.java
import junit.framework.*;
import java.util.*;
public class LibraryTest extends TestCase {
public void testAddBooks( ) {
Library library = new Library( );
library.addBook(new Book("Dune", "Frank Herbert"));
library.addBook(new Book("Solaris", "Stanislaw Lem"));
Book book = library.getBook( "Dune" );
assertTrue( book.getTitle( ).equals("Dune") );
book = library.getBook( "Solaris" );
assertTrue( book.getTitle( ).equals("Solaris") );
}
public void testLibrarySize( ) {
Library library = new Library( );
library.addBook(new Book("Dune", "Frank Herbert"));
library.addBook(new Book("Solaris", "Stanislaw Lem"));
assertTrue( library.getNumBooks( ) == 2 );
}
}
Two test methods are implemented. The method
testAddBooks( ) adds two Books
to the Library, then uses getBook(
) to verify that the additions succeeded. The method
testLibrarySize( ) also adds two
Books, then checks that getNumBooks(
) returns "2".
Example 3-8 shows the new version of
Library with the additional
functionality to pass the tests.
Example 3-8. New version of Library
Library.java
import java.util.*;
public class Library {
private Vector books;
Library( ) {
books = new Vector( );
}
public void addBook( Book book ) {
books.add( book );
}
public Book getBook( String title ) {
for ( int i=0; i < books.size( ); i++ ) {
Book book = (Book) books.elementAt( i );
if ( book.getTitle( ).equals(title) )
return book;
}
return null;
}
public int getNumBooks( ) {
return books.size( );
}
}
Library now uses a Vector to contain a
collection of Books. The new method
getNumBooks( ) returns the number of
Books in the collection. The methods
addBook( ) and getBook( ) add
and retrieve a Book from the collection.
When you use TextTestRunner to execute
LibraryTest, both test methods succeed:
> java junit.textui.TestRunner LibraryTest
..
Time: 0.05
OK (2 tests)
LibraryTest has a number of problems. First and
foremost, the amount of code duplication between the two test methods
is bothersome. Both of them create a test Library
and add two books to it. Second, another concern is what will happen
if one of the asserts fails. The rest of the code in the test method
will not be executed and any objects created will not be cleaned up.
In Java, the garbage collector will
deallocate objects automatically, but
often unit tests use
objects or resources that must be
explicitly closed or deleted.
One way to take care of the code duplication is to make
the test Library a member of
LibraryTest and have the first test initialize it
and add the initial two elements. The second test could assume that
the first test succeeded, run its tests, and then clean up.
Unfortunately, this solution introduces more potential problems. If
the first test fails, the second also may fail because its initial
conditions are wrong, even though there may be nothing wrong with the
functionality it tests. The second test will always fail unless the
first one is run before it, so they cannot be run separately or in
reverse order. Furthermore, failure of either test is likely to
result in things not getting cleaned up.
In general, well-written unit tests exhibit
isolation . An
isolated test doesn't depend in any way on the
results of other tests. To ensure isolation, tests should not share
objects that change. Tests that have
interdependencies are
coupled. In LibraryTest, if
one of the test methods assumed that the other test left the
Library in a certain state, it would be a classic
example of test coupling.
The xUnit architecture helps to ensure test isolation with
test fixtures. A test fixture is a
test environment used by multiple tests. It is implemented as a
TestCase with multiple test methods that share
objects. The shared objects represent the common test environment.
Figure 3-3 shows the relationship between a
TestFixture and a
TestCase.
Every TestCase is implicitly a
TestFixture, although it may not act as one. The
TestFixture behavior comes into play when multiple
test methods have objects in common. The setUp( )
method is called prior to each test method, establishing the initial
environment for the test. The tearDown( ) method
is always called after each test method to clean up the test
environment, even if there is a failure. Thus, although the tests use
the same objects, they can make changes without the possibility of
affecting the next test.
The TestFixture behavior effectively creates and
destroys the test class each time one of its test methods is called.
This may incur a performance penalty, but it is important to
guarantee that the tests are isolated.
Incidentally, some xUnits (such as CppUnit) have an actual class or
interface named TestFixture from which
TestCase is descended, while some (JUnit) just
allow TestCase to act as a
TestFixture.
Writing tests as TestFixtures has a number of
advantages. Test methods can share objects but still run in
isolation. Test coupling is minimized. Test methods that share code
can be grouped together in the same TestFixture.
Code duplication between tests is reduced. The cleanup code is
guaranteed to run whether a test succeeds or fails. Finally, the test
methods can be run in any order, since they are isolated. Example 3-9 shows
LibraryTest implemented as a
TestFixture. In this example, the test
fixture's shared environment contains an instance of
Library with two Books.
Example 3-9. LibraryTest implemented as a TestFixture
LibraryTest.java
import junit.framework.*;
import java.util.*;
public class LibraryTest extends TestCase {
private Library library;
public void setUp( ) {
library = new Library( );
library.addBook(new Book("Dune", "Frank Herbert"));
library.addBook(new Book("Solaris", "Stanislaw Lem"));
}
public void tearDown( ) {
}
public void testGetBooks( ) {
Book book = library.getBook( "Dune" );
assertTrue( book.getTitle( ).equals( "Dune" ) );
book = library.getBook( "Solaris" );
assertTrue( book.getTitle( ).equals( "Solaris" ) );
}
public void testLibrarySize( ) {
assertTrue( library.getNumBooks( ) == 2 );
}
}
The stylistic improvements over the previous version of
LibraryTest are apparent: the code duplication is
gone, the test methods contain only statements specifically related
to the test conditions, and the tests are easier to understand.
Note that the test method previously named testAddBooks() is renamed testGetBooks( ) to more
accurately describe what it's doing.
When LibraryTest is run, the sequence of function
calls is:
setUp( )
testGetBooks( )
tearDown( )
setUp( )
testLibrarySize( )
tearDown( )
The calls to setUp( )
and tearDown( )
initialize and deinitialize the test fixture each time a test method
is called, thus isolating the
tests.
3.3.4 TestSuite
So
far, this review of xUnit has
focused on writing single unit test classes, sometimes with multiple
test methods. What about testing with multiple unit test classes?
After all, each production object should have a corresponding unit
test.
xUnit contains a class for aggregating unit tests called
TestSuite. TestSuite is closely
related to TestCase, since both are descendants of
the same abstract class, Test. Figure 3-4 shows the
Test interface and how
TestSuite and TestCase
implement it.
The interface Test contains the run() method that the framework uses to
run tests and collect their results. Since
TestSuite implements run(), it can be run just like a
TestCase. When a TestCase is
run, its test methods are run. When a TestSuite is
run, its TestCases are run.
TestCases are added to a
TestSuite using the addTest()
method. Since a TestSuite is itself a
Test, a
TestSuite can contain other
TestSuites, allowing the intrepid developer to
build hierarchies of TestSuites and
TestCases.
Example 3-10 shows a
TestSuite-derived class named
LibraryTests that contains both
BookTest and LibraryTest.
Example 3-10. An instance of TestSuite named LibraryTests
LibraryTests.java
import junit.framework.*;
public class LibraryTests extends TestSuite {
public static Test suite( ) {
TestSuite suite = new TestSuite( );
suite.addTest(new TestSuite(BookTest.class));
suite.addTest(new TestSuite(LibraryTest.class));
return suite;
}
}
A TestSuite is created for each of the test
classes and added to LibraryTests. This is a quick
way to add all of the test methods to the test suite at once. The
addTest( ) method also may be used to add test
methods to a test suite individually, as shown here:
suite.addTest(new LibraryTest("testAddBooks"));
To be used this way, an instance of TestCase must
have a constructor that takes a string argument and invokes its
parent's constructor. The string argument specifies
the name of the test method to run.
You can run instances of TestSuite using a
TestRunner just as you would run a
TestCase. The
TestSuite's static method
suite( ) is
called to create the suite of tests to run.
> java junit.textui.TestRunner LibraryTests
....
Time: 0.06
OK (4 tests)
The results show that both of the test methods from the
LibraryTest and BookTest unit
test classes have been run, for a total of four
tests.
3.3.5 TestResult
As
shown in
the discussion of the Test
interface, TestResult is the parameter to
Test's run( )
method. The immediate goal of running unit tests, in a literal sense,
is to accumulate test results. The class
TestResult serves this purpose. Each time a test
is run, the TestResult object is passed in to
collect the results. Figure 3-5 shows
TestResult.
TestResult is a simple object. It counts the tests
run and collects test failures and errors so the
framework can report them. The failures and errors include details
about the location in the code where they occurred and any associated
test descriptions. The information printed for the
BookTest failure at the beginning of this chapter
is typical.
|
No comments:
Post a Comment