I've been looking a way to reuse test code for a while now. Who hasn't written some test code only to have to repeat it in another test class. This is specially the case when you use BDD and have separate test classes for each context.
Refactoring tests does help but it doesn't seem to be the right solution. I've been experimenting with using Test Behaviour Handlers (TBH) to encapsulate the process of expecting, running and verifying code. It seems work quite well.
The term TestBehaviourHandler, is one I've come up with, with my limited imagination! If there is another name for this "pattern" please let me know.
What are the responsibilities of a TestBehaviourHandler?
1. Encapulating all mocking (expectations, assertions, replaying, verifying etc)
2. Ecapulating the method(s) under test. No data is returned. State is modified when methods return values. Assertions verify the returned values.
3. (Optional) Since this models the Builder pattern, return the instance of the TBH from each method.
3. (Optional) Since this models the Builder pattern, return the instance of the TBH from each method.
An Example
public final class RandomDataCreatorImpl implements RandomDataCreator {
private final MathBoundary mathBoundary;
public RandomDataCreatorImpl(final MathBoundary mathBoundary) {
this.mathBoundary = mathBoundary;
}
public int createNumber(final int value) {
return (int) (mathBoundary.random() * value);
}
...
}
Now we could write tests for this method of the form:
import com.googlecode.pinthura.boundary.java.lang.MathBoundary;
import com.googlecode.pinthura.util.builder.RandomDataCreatorBuilder;
import org.easymock.EasyMock;
import org.easymock.IMocksControl;
import static org.hamcrest.core.IsEqual.equalTo;
import static org.junit.Assert.assertThat;
import org.junit.Before;
import org.junit.Test;
public final class ARandomDataCreatorCreatingNumbersUnderTest {
private final IMocksControl mockControl;
private MathBoundary mockMathBoundary;
private RandomDataCreator creator;
public ARandomDataCreatorCreatingNumbersUnderTest() {
mockControl = EasyMock.createControl();
}
@Before
public void setup() {
mockMathBoundary = mockControl.createMock(MathBoundary.class);
creator = new RandomDataCreatorBuilder().withMathBoundary(mockMathBoundary).build();
}
@Test
public void shouldReturnZero() {
expectNumber(0.001, 255, 0);
}
@Test
public void shouldReturnAPositiveNumber() {
expectNumber(0.98, 1000, 980);
}
@Test
public void shouldReturnANegativeNumber() {
expectNumber(0.5, -500, -250);
}
@Test
public void shouldNeverReturnTheSuppliedValue() {
expectNumber(0.9999, 50, 49);
}
private void expectNumber(final double randomValue, final int value, final int expectedVal) {
EasyMock.expect(mockMathBoundary.random()).andReturn(randomValue);
mockControl.replay();
int number = creator.createNumber(value);
assertThat(number, equalTo(expectedVal));
mockControl.verify();
}
}
Suppose we now add a second method that generates random numbers between a range of supplied numbers:
public int createNumber(final int min, final int upperBoundary) {
int range = upperBoundary - min;
double randomValue = mathBoundary.random() * range;
if (min < 0) {
return (int) mathBoundary.floor(min + randomValue);
}
return (int) (min + randomValue);
}
A test case for this method might look like:
import com.googlecode.pinthura.boundary.java.lang.MathBoundary;
import com.googlecode.pinthura.util.builder.RandomDataCreatorBuilder;
import org.easymock.EasyMock;
import org.easymock.IMocksControl;
import static org.hamcrest.core.IsEqual.equalTo;
import static org.junit.Assert.assertThat;
import org.junit.Before;
import org.junit.Test;
public final class ARandomDataCreatorCreatingBoundedNumbersUnderTest {
private final IMocksControl mockControl;
private MathBoundary mockMathBoundary;
private RandomDataCreator creator;
public ARandomDataCreatorCreatingBoundedNumbersUnderTest() {
mockControl = EasyMock.createControl();
}
@Before
public void setup() {
mockMathBoundary = mockControl.createMock(MathBoundary.class);
creator = new RandomDataCreatorBuilder().withMathBoundary(mockMathBoundary).build();
}
@Test
public void shouldReturnABoundedNumber() {
expectBoundedNumber(0.5, 10, 20, 15);
}
@Test
public void shouldReturnTheMinValue() {
expectBoundedNumber(0.0001, 300, 555, 300);
}
@Test
public void shouldNotReturnTheUpperBoundary() {
expectBoundedNumber(0.9999, 50, 100, 99);
}
private void expectBoundedNumber(final double randomVal, final int minVal, final int upperBoundary, final int expectedVal) {
EasyMock.expect(mockMathBoundary.random()).andReturn(randomVal);
mockControl.replay();
int number = creator.createNumber(minVal, upperBoundary);
assertThat(number, equalTo(expectedVal));
mockControl.verify();
}
Now this test is almost identical to the previous test.
We could refactor the tests into one test class. This would become messy rather quickly though with the number of methods increasing. Also there is another test case that tests for negative number ranges. This is to be added to a separate test class.
Using the TBH for the above methods gives us:
import com.googlecode.pinthura.annotation.SuppressionReason;
import org.junit.Before;
import org.junit.Test;
public final class ARandomDataCreatorCreatingNumbersUnderTest {
@SuppressWarnings("InstanceVariableOfConcreteClass")
@SuppressionReason(SuppressionReason.Reason.TEST_BEHAVIOUR_HANDLER)
private ARandomDataCreatorCreatingNumbersTBH handler;
@Before
public void setup() {
handler = new ARandomDataCreatorCreatingNumbersTBH();
}
@Test
public void shouldReturnZero() {
expectNumber(0.001, 255, 0);
}
@Test
public void shouldReturnAPositiveNumber() {
expectNumber(0.98, 1000, 980);
}
@Test
public void shouldReturnANegativeNumber() {
expectNumber(0.5, -500, -250);
}
@Test
public void shouldNeverReturnTheSuppliedValue() {
expectNumber(0.9999, 50, 49);
}
private void expectNumber(final double randomValue, final int value, final int expectedVal) {
handler.expectRandomValue(randomValue).replay();
handler.createNumber(value).assertNumbersAreEqual(expectedVal).verify();
}
}
and
import com.googlecode.pinthura.annotation.SuppressionReason;
import org.junit.Before;
import org.junit.Test;
public final class ARandomDataCreatorCreatingBoundedNumbersUnderTest {
@SuppressWarnings("InstanceVariableOfConcreteClass")
@SuppressionReason(SuppressionReason.Reason.TEST_BEHAVIOUR_HANDLER)
private ARandomDataCreatorCreatingNumbersTBH handler;
@Before
public void setup() {
handler = new ARandomDataCreatorCreatingNumbersTBH();
}
@Test
public void shouldReturnABoundedNumber() {
expectBoundedNumber(0.5, 10, 20, 15);
}
@Test
public void shouldReturnTheMinValue() {
expectBoundedNumber(0.0001, 300, 555, 300);
}
@Test
public void shouldNotReturnTheUpperBoundary() {
expectBoundedNumber(0.9999, 50, 100, 99);
}
private void expectBoundedNumber(final double randomVal, final int minVal, final int upperBoundary, final int expectedVal) {
handler.expectRandomValue(randomVal).replay();
handler.createNumber(minVal, upperBoundary).assertNumbersAreEqual(expectedVal).verify();
}
}
The TestBehaviourHandler looks like this:
import com.googlecode.pinthura.annotation.SuppressionReason;
import com.googlecode.pinthura.boundary.java.lang.MathBoundary;
import com.googlecode.pinthura.util.builder.RandomDataCreatorBuilder;
import org.easymock.EasyMock;
import org.easymock.IMocksControl;
import static org.hamcrest.core.IsEqual.equalTo;
import static org.junit.Assert.assertThat;
@SuppressWarnings({"MethodReturnOfConcreteClass"})
@SuppressionReason(SuppressionReason.Reason.BUILDER_PATTERN)
public final class ARandomDataCreatorCreatingNumbersTBH {
private final IMocksControl mockControl;
private MathBoundary mockMathBoundary;
private RandomDataCreator creator;
private int number;
public ARandomDataCreatorCreatingNumbersTBH() {
mockControl = EasyMock.createControl();
mockMathBoundary = mockControl.createMock(MathBoundary.class);
creator = new RandomDataCreatorBuilder().withMathBoundary(mockMathBoundary).build();
}
public ARandomDataCreatorCreatingNumbersTBH replay() {
mockControl.replay();
return this;
}
public ARandomDataCreatorCreatingNumbersTBH createNumber(int value) {
number = creator.createNumber(value);
return this;
}
public ARandomDataCreatorCreatingNumbersTBH createNumber(int minVal, int upperBoundary) {
number = creator.createNumber(minVal, upperBoundary);
return this;
}
public ARandomDataCreatorCreatingNumbersTBH expectRandomValue(double randomVal) {
EasyMock.expect(mockMathBoundary.random()).andReturn(randomVal);
return this;
}
public ARandomDataCreatorCreatingNumbersTBH expectFlooredValue(double intermediateValue, double flooredValue) {
EasyMock.expect(mockMathBoundary.floor(intermediateValue)).andReturn(flooredValue);
return this;
}
public ARandomDataCreatorCreatingNumbersTBH verify() {
mockControl.verify();
return this;
}
public ARandomDataCreatorCreatingNumbersTBH assertNumbersAreEqual(int expectedVal) {
assertThat(number, equalTo(expectedVal));
return this;
}
}
Here are the tests for negative number ranges:
import com.googlecode.pinthura.annotation.SuppressionReason;
import org.junit.Before;
import org.junit.Test;
public final class ARandomDataCreatorCreatingBoundedNegativeNumbersUnderTest {
@SuppressWarnings("InstanceVariableOfConcreteClass")
@SuppressionReason(SuppressionReason.Reason.TEST_BEHAVIOUR_HANDLER)
private ARandomDataCreatorCreatingNumbersTBH handler;
@Before
public void setup() {
handler = new ARandomDataCreatorCreatingNumbersTBH();
}
@Test
public void shouldReturnTheMinValue() {
handler.expectRandomValue(0.001).expectFlooredValue(-9.995, -10).replay();
handler.createNumber(-10, -5).assertNumbersAreEqual(-10).verify();
}
@Test
public void shouldNotReturnTheUpperBoundary() {
handler.expectRandomValue(0.999999).expectFlooredValue(-100.0001, -101).replay();
handler.createNumber(-200, -100).assertNumbersAreEqual(-101).verify();
}
@Test
public void shouldReturnANumberBetweenNegativeAndPositveBounds() {
handler.expectRandomValue(0.4).expectFlooredValue(-1, -1).replay();
handler.createNumber(-5, 5).assertNumbersAreEqual(-1).verify();
}
}
The TestBehaviourHandler has been reused in the above test case as well.
The Disadvantages
1. You have to write yet another class. (The TBH)
2. You have to repeat the mocking code in each TBH.
One improvement that has to be done is to move the mocking code into a common class allowing the user to supply additional test behaviour only. This would allow the user to focus only on the test behaviour and not the mocking/ setup code.
The Advantages
1. Reuse of test code.
2. Test cases are kept smaller.
3. The tests are easier to read and maintain.
I'll keep using this "pattern" and see how it can be improved. Any comments and/or suggestions are welcome.
5 comments:
The easydoc guys used a similar approach a fair bit and I'm pretty sure called these kinds of classes Scenerios. I haven't used this approach too much myself as personally I don't mind some duplicated test code if it makes the test easier to read, but some of the uses of the pattern I've seen are very impressive. The general usage is:
* Create a scenario object to record expectations of the object(s) under test
* Instantiate the scenario for a test and record the individual expectations for that test (using a fluent style builder for the scenario object)
* Once all expectations have been specified, execute the test (i.e. scenario.run()) which will execute the code under test and verify that all expectations have been met. Essentially the equivalent of the build() method in the builder pattern).
My personal feel is that this makes sense when expectations need to be set on a number of objects (e.g. an integration or functional test) and may be overkill when a smaller unit of code is being tested. That said, I think the above examples make for good reading, and I definitely don't see the extra class as unnecessary class explosion.
Nice post btw!
As discussed via email, I'd also like to point out that Ipsedixit (http://ipsedixit.sourceforge.net) is a project trying to solve similar problems as the example being tested in this post, so if anyone found this page looking for a Random Test Data generation solution, ipsedixit is available for you )
Thanks Casey! :)
Yes, ipsedixit does look promising. I also like the "Scenarios". I might give them a play and see how different they are to the TBH. I'll also try ipsedixit.
I've also got some pseudo-random data providers in Pinthura. For example randomly choosing a value from a list of valid values. I'll add a post describing them when I have some time.
Wow, how come I never knew you had this blog! Anywho, I was also going to suggest ipsedixit (http://ipsedixit.sourceforge.net) as well as Instinct (http://code.google.com/p/instinct/).
I've used them both. I did a little work on instinct for Tom a long while back. Thinking of something a little different though.
I'm in the process of of refining this idea. I'll probably put up a post once I do.
Post a Comment