Saturday, November 14, 2009

Parameterized Testing with JUnit 4

I have seen several examples in the past of using parameterization with JUnit 4, but always felt like I didn't completely grasp what was going on. So if I don't understand something, I should try it out right?

My example class to test with parameterization will be a nice car with one state-changing method that will add a number of miles to the total traveled, whose value will be stored in a odometer property:

    package prystasj.junit

class Lambo {

int odometer

def drive(miles) {
if (miles > 0) odometer += miles
}

@Override String toString() {
"${getClass().getName()}[odometer=$odometer]"
}
}

To test the odometer, I will "drive" distances of 5, 10, then 20 miles, checking the odometer along the way. At the end, my low-use Lambo should have traveled a total of 35 miles. To do so, I will use the same Lambo instance throughout the tests.

The @BeforeClass annotation will run the annotated static method once per test run, so I will initialize my Lambo, which also must be static, there:

    static Lambo lambo

@BeforeClass static void createLambo() {
lambo = new Lambo()
println "Created Lambo: $lambo"
}

For every run of my test method, I want to receive a number miles to drive and the expected value of the odometer afterwards. Therefore, I need a way to set these values in my test class via a constructor:

    def total
def miles

LamboMileageTest(expectedTotal, milesToDrive) {
this.total = expectedTotal
this.miles = milesToDrive
}

The test parameters come from a static data method that returns a collection of arrays. Each entry in the collection will have its values passed as parameters to my constructor above:

    @Parameters static def data() {
def data = []
def expectedTotal = 0
[5, 10, 20].each { miles ->
expectedTotal += miles
def group = [miles, expectedTotal] as Integer[]
data << group
}
data
}

Without the conversion via as Integer[], the following exception was thrown:

  Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.038 sec <<< FAILURE!
initializationError0(prystasj.junit.LamboMileageTest) Time elapsed: 0.007 sec <<< ERROR!
java.lang.Exception: prystasj.junit.LamboMileageTest.data() must return a Collection of arrays.
Additionally, if I tried to append to data in one statement without the intermediate group variable, a GroovyCastException was thrown. I could wrap the right side of the append with parentheses, but I think this way is easier to read.

Each parameter grouping will be passed to each test method in my test class. Here I will have only one test method, but if I had two, each would be run with [5, 5], and then each would be run with [10, 15], and so on.

The test method:

    @Test void driveSomeDistance() {
println "Parmeters: [miles=$miles, total=$total]"
lambo.drive(miles)
assertEquals "Odomoter reads '$total'", total, lambo.odometer
}

The output from a successful test run:

  Running prystasj.junit.LamboMileageTest
Created Lambo: prystasj.junit.Lambo[odometer=0]
Parmeters: [miles=5, total=5]
Parmeters: [miles=10, total=15]
Parmeters: [miles=20, total=35]
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.173 sec

The entire test class:

    package prystasj.junit

import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.junit.runners.Parameterized.Parameters
import static org.junit.Assert.assertEquals

@RunWith(value=Parameterized)
public class LamboMileageTest {

static Lambo lambo
def total
def miles

LamboMileageTest(milesToDrive, expectedTotal) {
this.miles = milesToDrive
this.total = expectedTotal
}

@BeforeClass static void createLambo() {
lambo = new Lambo()
println "Created Lambo: $lambo"
}

@Parameters static def data() {
def data = []
def expectedTotal = 0
[5, 10, 20].each { miles ->
expectedTotal += miles
def group = [miles, expectedTotal] as Integer[]
data << group
}
data
}

@Test void driveSomeDistance() {
println "Parmeters: [miles=$miles, total=$total]"
lambo.drive(miles)
assertEquals "Odomoter reads '$total'", total, lambo.odometer
}

}

Looking at the nature of a parameterized test class, and at the goal of my tests, I would recommend that this test class only be used for testing the accumulation of miles. If I wanted to test other Lambo functionality or usage, such as a Lambo not being able to drive negative miles, I would create another test class.

Without making use of parameterization, I would of wrote a test class like this:

    public class AlternateLamboMileageTest {

Lambo lambo

@Before void setUp() {
lambo = new Lambo()
}

@Test void starting_with_zero_miles_drive_5() {
lambo.odometer = 0
lambo.drive(5)
assertEquals 5, lambo.odometer
}

@Test void starting_with_five_miles_drive_10() {
lambo.odometer = 5
lambo.drive(10)
assertEquals 15, lambo.odometer
}

@Test void starting_with_fifteen_miles_drive_20() {
lambo.odometer = 15
lambo.drive(20)
assertEquals 35, lambo.odometer
}
}

This test class might be easier to follow though, or at least would of been before I started writing. With this approach however, I need to set the odometer before testing the result of a drive as I can't guarantee the order in which the tests are run. If I could, I could also use a static Lambo instance as before and avoid the odometer settings, or JExample to define test dependencies (which I wrote a bit about here).

This new test class also helps expose a design issue with my Lambo modeling, as unless I was some sort of shady used car salesman, I shouldn't be able to set the odometer once its initialized.

Then again, maybe if I was a shady salesman, I could find my way into a real Lambo... :)

No comments:

Post a Comment