The UML Profile for Framework Architectures
6.1 An overview of JUnit
JUnit allows programmers to define tests in a compact way, to reuse code in related test cases, to compose test cases into test suites, and to manipulate and visualize test results. The goal of JUnit is to reduce the amount of code that has to be written by a developer who wants to test Java applications. The following are the main parts of the framework.
Test cases: each test case describes one particular test. Adding new test cases is the most frequent JUnit adaptation. JUnit provides the TestCase class, which defines a standard interface for test cases. TestCase is the most important abstraction in JUnit.
Test suites: the number of test cases can grow quickly, thus JUnit allows test cases to be composed into test suites in order to organize a larger number of test cases. TestSuite objects are composed of test cases and other test suites, thus allowing for hierarchical and efficient test management.
Test results: JUnit keeps track of the results of executed test cases. Since there are different kinds of reporting mechanisms, it keeps the way that the test results are reported flexible. Overriding the TestResult class allows the desired manipulation of test results (for example, storing the results of the tests in a database for project control purposes, or creating HTML files that report the test activities). The default behavior provided by JUnit is to count the number of executed tests.
Figure 6.1. provides an overview of the framework. The following subsections describe each entity in more detail, highlighting the JUnit variation points and providing recipes for their adaptation. For an appropriate marking of JUnit classes, we introduce the JUnit tag in Table 6.1. It can be regarded as shorthand for framework=JUnit , denoting that the class it is attached to belongs to the framework JUnit.
Figure 6.1. Overview of the core JUnit classes
Tag | JUnit |
---|---|
Applies to | Classes, interfaces, packages, methods, and attributes. |
| |
Type | Boolean. |
Motivation and purpose | The tag denotes that the origin of the tagged element is the JUnit framework. |
Explanation of effect | The tag is used to make it explicit that a class/interface belongs to the JUnit framework. Methods and attributes that are originally defined in the JUnit framework, but represented in application classes, may also be annotated with that tag though the tag holds only for methods whose implementation is not overridden in application subclasses. |
Expansion | The tag can be seen as a short cut for framework=JUnit or package=JUnit . |
Discussion | The JUnit tag does not apply to methods whose signature is inherited from JUnit and whose method body is overridden. |
The following subsections discuss the concepts underlying the mentioned classes. Section 6.2 presents adaptation recipes for each of JUnit's variation points.
6.1.1 Test cases
JUnit builds on the observation that any test case consists of potentially three parts: the initialization, the test check, and a clean up. The initialization part defines a so-called test fixture. A test fixture is an object structure that is used as a starting point for testing. Fixtures that may be used by several test cases should be created in the initialization part for reuse purposes.
The test check applies one or several method calls on the fixture created in the initialization part. Afterwards it verifies whether certain constraints are met. Have certain changes of the object structure and values been conducted? Do invariants still hold? These checks primarily rely on the assert() method provided by JUnit. The assert() method succeeds if the evaluation of the parameter yields the boolean value True.
Finally, the third part cleans up a test. It is used mainly to close files and database connections and to make unused objects available for garbage collection.
Figure 6.2 annotates the TestCase class with the Unification tags. Method run() has a standard implementation, calling the other three methods in the order setUp(), runTest(), and tearDown() (see Example 6.1). The three Unif-h methods could be abstract because their intention is to be overridden in subclasses to define actual tests. However, all three provide an empty default implementation allowing a subclass not to have to define these methods if they aren't necessary. For example, when no clean up code is required tearDown() doesn't need to be overriden.
Figure 6.2. JUnit's TestCase class
Test cases should override the hook methods. Code reuse among tests can be achieved by building subclasses and reusing a subset of these methods. For example, reuse of the initialization and clean up methods allows different tests to operate on the same data. Reuse of runTest() allows a test to operate on different data.
Example 6.1 Simplified run() method of JUnit's class TestCase (from Beck and Gamma, 1998a)
public void run() { setUp(); runTest(); tearDown(); }
6.1.2 Test suites
Defining and running only a single test would be insufficient for checking a software system. Therefore, a larger number of tests needs to be defined and managed. The Composite pattern allows the definition of test suites as composite tests that can be treated as individual tests. Thus, test suites may contain test cases and test suites. Additional subclasses of Test are not allowed. The tag fixed attached to the generalization hierarchy expresses this restriction (see Figure 6.3).
Figure 6.3. TestSuite design structure
The implementation of the run() method in class TestSuite invokes the run() method for each contained Test (see Example 6.2). The UML-F sequence diagram in Figure 6.4 describes this behavior, using '*' to indicate that several Test objects are in a test suite and to make the repeated invocation of the run() method explicit.
Figure 6.4. Interaction between a TestSuite object and its contained Test objects
Example 6.2 The code of the run() method in class TestSuite (from Beck and Gamma, 1998a)
public void run() { for (Enumeration e = fTests.elements(); e.hasMoreElements();) { Test test = (Test) e.nextElement(); test.run(); } }
6.1.3 Reporting the test results
JUnit distinguishes between failures and errors. Failures are situations where a test does not yield the expected result. Usually, a failure is detected by applying the assert() method to check whether a constraint holds. Errors, instead, are unanticipated bugs in the code being tested, or in the test cases themselves. The TestResult class is responsible for reporting failures and errors in different ways.
Analogous with a test case, a test result object is able to execute an initialization code before it starts keeping track of each test case, and to execute a wrap up code at the end of each test case. TestResult offers four methods: startTest() containing the initialization code; addFailure() which is invoked every time a failure occurs; addError() which is invoked every time an error happens; and endTest() which does the wrap up. Example 6.3 shows the implementation of the run(TestResult) method in class TestCase in more detail. The test result object is handed through the test objects as a parameter of the run(TestResult) method.
Example 6.3 The run method of class TestCase (from Beck and Gamma 1998a)
public void run(TestResult result) { result.startTest(this); setUp () ; try { runTest(); } catch (AssertionFailedError e) { result.addFailure(this, e); } catch (Throwable e) { result.addError(this, e); } finally { tearDown(); result.endTest(this); } }
The earlier Examples 6.1 and 6.2 show simplified versions of the parameter-less run() method. In the actual implementation, both methods call the run(TestResult) method after they have created a TestResult object on their own. The UML-F sequence diagram in Figure 6.5 describes the behavior of the run(TestResult) method, showing the three alternative outcomes of an invoked test.
Figure 6.5. Sequence diagram for the run(TestResult) method in class TestCase
In order to define a test-reporting mechanism that is different from the default implementation, the developer is allowed to override the appropriate methods of class TestResult. The JUnit designers have applied the Separation construction principle to achieve the required flexibility for each aspect of a test result that is, initializing the component, adding failures or errors, and finalizing the reporting. The UML-F diagram in Figure 6.6 illustrates that aspect of JUnit.