Monday, May 26, 2014

Help for Java parameterized tests

JUnit has many great features. One I especially like is parameterized tests.

The problem

One itch for me however is setting up the parameters and getting failed tests to name themselves.

Modern JUnit has help on the second itch with the name parameter to @Parameters. You still need to get the test name passed into the constructor.

But what about setting up the test data?

I write clunky code like this:

@Parameters(name = "{index}: {0}")
public static Collection<Object[]> data() {
    return asList(
            new Object[]{"Some test", 1, "a string"},
            new Object[]{"Another test", 2, "another string"}
            /* and so on for each test case */);
}

This is not the worst code, but with more complex inputs or data it grows into a long list of eye-glazing use cases. For me this is hard to maintain and document.

The solution

As an alternative I scouted around and settled on the venerable Windows INI file format. It has several virtues in this context:

  • The format is simple and well known
  • Section titles can be use to name tests
  • Comments are supported

Looking at libraries for INI support in Java, one stands out: ini4j. In particular:

  • Is stable and acceptably documented
  • Supports C-style escape sequences, e.g. \t for TAB—import for my particular tests
  • Accepts reasonable ways to input the INI (URL, input stream, et al)

Integrating this into @Parameters is straight-forward. One approach is this:

@Nonnull
public static List<Object[]> parametersFrom(
        @Nonnull final Ini ini, final Key... keys) {
    final List<Object[]> parameters = new ArrayList<>();
    for (final Section section : ini.values()) {
        final Object[] array = new Object[1 + keys.length];
        array[0] = section.getName();
        for (int i = 0; i < keys.length; i++) {
            final Key key = keys[i];
            final String value = section.fetch(key.name);
            array[1 + i] = null == value ? null : key.get.apply(value);
        }
        parameters.add(array);
    }
    return parameters;
}

public static final class Key {
    public final String name;
    public final Function<String, ?> get;

    @Nonnull
    public static Key of(@Nonnull final String name, @Nonnull final Function<String, ?> get) {
        return new Key(name, get);
    }

    @Nonnull
    public static Key of(@Nonnull final String name) {
        return of(name, Function.identity());
    }

    private Key(final String name, final Function<String, ?> get) {
        this.name = name;
        this.get = get;
     }
}

A key might look like Key.of("amount", BigDecimal::new)

Example

I simplified tests of a money value object to a test name, a value to parse and a currency and amount to expect:

@RunWith(Parameterized.class)
public class MoneyTest {
    // Failed test messages look like:
    // 0: Missing currency: 1
    @Parameters(name = "{index}: {0}: {1}")
    public static Collection<Object[]> parameters()
            throws IOException {
        return parametersFrom(
                new Ini(MoneyTest.class.getResource("MoneyTest.ini")),
                Key.of("value"),
                Key.of("currency", Currency::getInstance),
                Key.of("amount", BigDecimal::new));
    }

    @Rule
    public final ExpectedException thrown = ExpectedException.none();

    private final String description;
    private final String value;
    private final Currency currency;
    private final BigDecimal amount;

    public MoneyTest(@Nonnull final String description,
            @Nonnull final String value,
            @Nullable final Currency currency,
            @Nullable final BigDecimal amount) {
        this.description = description;
        this.value = value;
        this.currency = currency;
        this.amount = amount;

        if (!((null == currency && null == amount)
                || (null != currency && null != amount)))
            throw new IllegalArgumentException(
                    format("%s: currency and amount must both be null or non-null",
                            description));
    }

    @Test
    public void shouldParse() {
        if (null == currency) {
            thrown.expect(MoneyFormatException.class);
            thrown.expectMessage(value);
        }

        final Money money = Money.parse(value);

        assertThat(money.getCurrency(), is(equalTo(currency)));
        assertThat(money.getAmount(), is(equalTo(amount)));
    }
}
; Format - INI file
; Section title is description of test, used in reporting failure
; value is the input to `Money.parse(String)`
; IFF parsing should fail:
;   - Do not provide currency/amount
; IFF parsing should pass:
;   - currency is input to `Currency.getInstance(String)`
;   - amount is input is `new BigDecimal(String)`

[Missing currency]
value = 1

[Single dollar, no whitespace]
value = USD1
currency = USD
amount = 1.00

I like being able to add new tests by text editing of the INI file.

Post a Comment