JUnit Theories and Other Beasts, Part 3


15 June 2012, by

previous article in series

How far we’ve come

If you’ve been reading along, you’ll know that in Part 1 of this series we examined JUnit basics, runners and rules. Then, in Part 2, we took a look at motivation and background of theories, a way of encouraging more general statements about how code works on its inputs than standard tests.

Here in Part 3, we’re going to roll our sleeves up and see how to put theories into practice.

Getting stuck in

If Part 2 was a little too abstract for you, perhaps you’ve been wondering how all this actually works? The answer is, quite straightforwardly, happily. You’ll be interested in the following classes:

  • Theories, the runner you’ll need to use to run your theories
  • @Theory, the annotation to mark a method as a theory (rather than a test)
  • @DataPoint and @DataPoints, the annotations to mark variables and/or methods as candidate data for your theories.

Stolen pretty much verbatim from the papers mentioned in Part 2, the below example (in which we test that multiplying and diving a sum of money by the same factor results in the original amount of money) should outline the basic idea.

If you’re following along at home, these examples were built with Java 6, with the only dependency as JUnit 4.10 – if you’re using Maven, you can use the following dependency snippet in your pom:

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.10</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

First, we imagine we have a very simple Money class:

package theories;

public class Money {
  private int cents;

  public Money(int cents) {
    this.cents = cents;
  }

  public Money multiplyBy(int factor) {
    return new Money(cents * factor);
  }

  public Money divideBy(int factor) {
    return new Money(cents / factor);
  }

  public int cents() {
    return cents;
  }

  @Override
  public boolean equals(Object other) {
    if (!(other instanceof Money)) {
      return false;
    }
    Money otherMoney = (Money) other;
    return this.cents == otherMoney.cents();
  }

  // Snip! In a real project you'd want to implement
  // hashCode() and possibly toString() here.

}

You can then put it through its paces with the following test class:

package theories;

import static org.junit.Assume.*;
import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;

import org.junit.Test;
import org.junit.experimental.theories.*;
import org.junit.runner.RunWith;

import theories.Money;

@RunWith(Theories.class)
public class MoneyTest {
  @DataPoint public static final Money NULL_MONEY = null;
  @DataPoint public static final Money ZERO_CENTS = new Money(0);
  @DataPoint public static final Money TEN_CENTS = new Money(10);
  @DataPoint public static final int ZERO = 0;
  @DataPoint public static final int SEVEN = 7;
  @DataPoint public static final int NINETYNINE = 99;

  @Theory
  public void multiplyIsInverseOfDivide(Money amount, int factor) {
    System.out.println("Before assumptions. " + amount + "  factor: " + factor);
    assumeThat(amount, is(not(nullValue())));
    assumeTrue(factor > 0);
    System.out.println("After assumptions. " + amount + "  factor: " + factor);

    assertEquals(amount, amount.multiplyBy(factor).divideBy(factor));
  }

  @Test
  public void testMultiply() {
    assertEquals(10, (new Money(5)).multiplyBy(2).cents());
  }
}

Here you can see that the @Theory methods are @RunWith the Theories class, supplying the @DataPoint variables to the theory. We could also have had a @DataPoints method that returns an int[] (or a static int[]), to specify many int values at once. I’ve added some logging in order to illustrate both how Theories handles the @DataPoint variables, and how assumptions shape which inputs the test is run on. Running the above test class gives the following console output:

Before assumptions. null factor: 0
Before assumptions. null factor: 7
Before assumptions. null factor: 99
Before assumptions. Money(cents=0) factor: 0
Before assumptions. Money(cents=0) factor: 7
After assumptions. Money(cents=0) factor: 7
Before assumptions. Money(cents=0) factor: 99
After assumptions. Money(cents=0) factor: 99
Before assumptions. Money(cents=10) factor: 0
Before assumptions. Money(cents=10) factor: 7
After assumptions. Money(cents=10) factor: 7
Before assumptions. Money(cents=10) factor: 99
After assumptions. Money(cents=10) factor: 99

Looking first at the ‘Before assumptions’ lines, you can see that Theories is supplying every possible combination of parameters allowed by the type system. Note that this means that with many data points or parameters the number of times each theory is run can balloon dramatically (something the authors note, but without any particularly practical suggestions on how to combat this).

You can also see that the ‘After assumptions’ line is only reached, unsurprisingly, when the stated assumptions are valid for the parameters fed in by Theories.

Only if all the combinations of parameters that don’t break the assumptions pass the asserts will the theory be considered as passing in the results. For example, if we were to add @DataPoint public static final int MAX = Integer.MAX_VALUE, then we might find that Money uses an int suffers an overflow bug when the factor is MAX. Rather than making you guess what went wrong, the error message produced is remarkably helpful, showing which data points were used:

org.junit.experimental.theories.internal.ParameterizedAssertionError: multiplyIsInverseOfDivide(MAX_CENTS, SEVEN)
at [snip]

Beyond assumptions

If you find that your input values are carved up into very complicated partitions, you may find that assumptions become a bit cumbersome. Alternatively, you might find that you have a particular set of data that you want to re-use across multiple tests. In these cases, you can annotate your theory parameters with a custom @interface annotation which itself is annotated with @ParametersSuppliedBy, referencing a custom subclass of ParameterSupplier to generate the values for that parameter. Phew! Don’t worry, though, it’s nothing complicated. For the example above, we could define an annotation like so:

package theories;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

import org.junit.experimental.theories.ParametersSuppliedBy;

@Retention(RetentionPolicy.RUNTIME)
@ParametersSuppliedBy(MoneyValueSupplier.class)
public @interface MoneyValues {};

This references a ParameterSupplier subclass MoneyValueSupplier:

package theories;

import java.util.ArrayList;
import java.util.List;

import org.junit.experimental.theories.ParameterSignature;
import org.junit.experimental.theories.ParameterSupplier;
import org.junit.experimental.theories.PotentialAssignment;

public class MoneyValueSupplier extends ParameterSupplier {
  @Override
  public List getValueSources(ParameterSignature sig) {
    ArrayList values = new ArrayList();

    values.add(PotentialAssignment.forValue("Null", null));
    values.add(PotentialAssignment.forValue("Zero cents", new Money(0)));
    values.add(PotentialAssignment.forValue("Ten cents", new Money(10)));

    return values;
  }
}

We can then make use of our new attribute:

  @Theory
  public void multiplyIsInverseOfDivide(@MoneyValues Money amount, int factor) {
    // ...
  }

Watch this space

Look out for the fourth and final post in this series, in which we’ll look at what the future might hold for theories and wrap everything up.

Tags: , , ,

Categories: Technical

«
»

Leave a Reply

* Mandatory fields


7 − four =

Submit Comment