The JUnit test framework started a direct port of Kent Beck's SUnit framework/pattern [1][2] that arose roughly a decade earlier in the Smalltalk/Agile community. SUnit does feature individual test objects. However, not all of that maps cleanly to the Java that was available in the 90s. In particular, whereas SUnit uses selectors, JUnit uses reflection and later annotations to discover test cases.
A fairly literal port of SUnit would look like this in Java. First, we define a TestCase
class. Instances of this class are test cases that can be run()
, similar to what you suggest. Methods of subclasses can be used to create those test cases, which are resolved by reflection. Each TestCase subclass represents a fixture – an environment in which test cases run. This fixture is configured by the setUp()
method.
class TestCase {
private String selector;
public TestCase(String selector) {
this.selector = selector;
}
/// Run whatever code you need to get ready for the test to run.
protected void setUp() { }
/// Release whatever resources you used for the test.
protected void tearDown() { }
/// Run the selected method.
public final void run() throws Throwable {
setUp();
try {
this.getClass().getMethod(selector).invoke(this);
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause(); // unwrap wrapped exception
} finally {
tearDown();
}
}
/// Run the selected method and register the result.
public final void run(TestResult result) {
try {
run();
} catch(Throwable e) {
result.error(this, e);
return;
}
result.pass(this);
}
/// The base class can define assertions that will be inherited by tests.
protected void should(boolean result) {
if (!result) throw new AssertionError("check failed");
}
protected void shouldnt(boolean result) {
if (result) throw new AssertionError("check failed");
}
@Override public String toString() {
return String.format("%s.%s", getClass().getName(), selector);
}
}
A TestSuite
is a collection of test cases:
class TestSuite {
public final String name;
private List<TestCase> testCases = new ArrayList<>();
public TestSuite(String name) {
this.name = name;
}
public TestSuite addTestCase(TestCase testCase) {
testCases.add(testCase);
return this;
}
public TestResult run() {
TestResult result = new TestResult(name);
for (TestCase each: testCases) {
each.run(result);
}
return result;
}
}
class TestResult {
public final String name;
public List<String> errors = new ArrayList<>();
public List<String> passed = new ArrayList<>();
public TestResult(String name) {
this.name = name;
}
public void error(TestCase testCase, Throwable error) {
errors.add(String.format("%s: %s", testCase, error));
}
public void pass(TestCase testCase) {
passed.add(testCase.toString());
}
public int count() {
return passed.size() + errors.size();
}
}
With that, we can implement an example test suite:
class SetTestCase extends TestCase {
Set<Object> empty;
Set<Object> full;
public SetTestCase(String selector) {
super(selector);
}
@Override protected void setUp() {
empty = new HashSet<>();
full = new HashSet<>();
full.add("abc");
full.add(5);
}
public void testAdd() {
empty.add(5);
should(empty.contains(5));
}
public void testRemove() {
full.remove(5);
should(full.contains("abc"));
shouldnt(full.contains(5));
}
public static TestSuite testSuite() {
TestSuite suite = new TestSuite("Set Tests");
suite.addTestCase(new SetTestCase("testAdd"));
suite.addTestCase(new SetTestCase("testRemove"));
return suite;
}
public static void main(String[] args) {
TestResult result = testSuite().run();
System.out.printf("Ran %d test cases\n", result.count());
for (String error: result.errors) {
System.out.printf("Test failure: %s\n", error);
}
if (!result.errors.isEmpty())
System.exit(1);
}
}
This approach is quite flexible: you can run test methods directly (Smalltalk was an inherently interactive environment). You can add arbitrary logic when assembling a test suite. But it has some problematic aspects:
- test cases are resolved by name
- test cases must be added manually to a suite – that's very error-prone and tedious
- test case fixtures aren't reused
- construction/set up of test cases happens in separate phases
- creating one-off fixtures is tedious in Java
JUnit fixes some of these problems, importantly that test cases are discovered via reflection and don't have to be listed explicitly. On the other hand this sacrifices some flexibility. Because a TestCase class contains the code of multiple cases, it continues to be misused as a test case collection rather than a fixture with some test cases running in that fixture. But perhaps the setUp()
method isn't the best approach for managing fixtures?
Note that TestCase
cannot be an interface because in this design it supplies some plumbing for method resolution, result management, and provides assertions to the subclasses. However, there could be an additional interface that hides everything but run(TestResult)
from the TestSuite.
In languages that, unlike Java or Smalltalk, do not enforce a strict class-oriented structure of the code, it is indeed possible to get drastically simpler test structures that focus on the behavior of the classes rather than on simple set-up of fixtures. E.g. JavaScript favours Rspec-style describe-it tests:
describe('Set', () => {
it('can add elements', () => {
const empty = new Set();
empty.add(5);
assert(empty.has(5));
});
it('can delete elements', () => {
const full = new Set(["abc", 5]);
full.delete(5);
assert(full.has("abc"));
assert(!full.has(5));
});
});
You can't pull that off in Java without having one gargantuan method for all your test cases. And you literally couldn't do that before Java 8 gave us lambdas. You also lose some benefits, for example being able to implicitly set up fixtures (though most implementations of this pattern provide a before
method).
The point is, a lot has happened since the xUnit pattern was popularized:
We've learned a lot more about OOP. The best practices of yesteryear aren't always the best practices of today. OOP is not always a good fit. Java's class-oriented approach to programming is restrictive and prevents better alternatives. Where JUnit sticks to a certain design, it is also motivated by backwards compatibility.
We've learned a lot more about automated testing. The xUnit style to unit testing is no longer state of the art – it is neither good for capturing requirements nor convenient for the programmer. Better approaches include BDD-style techniques such as specification by example or Rspec-style tests, and property-based testing à la Haskell's quickcheck. Some of that is available in Java and even JUnit, but it must still somehow fit into Java's structure – which often means methods with magic names or annotations.
Note that Java and C# aren't the only languages that make elegant testing more difficult. E.g. Python's pytest has a much better fixture system than xUnit can ever dream of, but still suffers from the inflexibility of the host language (no real lambdas). One of the most useful approaches that I've found is to treat testing not as a framework but as a pattern: where the standard test frameworks get in your way, it is often more convenient to create your own quick test implementation that perfectly fits your style of testing.