Simplify cross product foreach loops - maybe with Lambdas?

Viewed 61

As a note beforehand: I think about this as opportunity to simplify tests, so it doesn't have to be cutting-edge efficient.

Lets say I have this unit test:

@Test
public void testVoiceOutput() {
    Stone testStone = new Stone();
    testStone.talkTo("Hey, how are you doing?");
    assertNull("Stone's answer", testStone.listenForReply());
}

When I now add possibilities of conditions, the stone can be in, this gets more and more unreadable:

@Test
public void testVoiceOutput() {
    Stone testStone = new Stone();
    for (String currentEnvironment : new String[]{"FLOOR", "MEADOW", "STREET"}) {
        testStone.setEnvironment(currentEnvironment);
        
        testStone.talkTo("Hey, how are you doing?");
        assertNull("Stone's answer, environment=" + currentEnvironment, testStone.listenForReply());
    }
}

and

@Test
public void testVoiceOutput() {
    Stone testStone = new Stone();
    for (String timeOfDay : new String[]{"NIGHT", "NOON", "TEATIME", "DINNERTIME"}) {
        for (String currentWeather : new String[]{"RAINY", "FOGGY", "SUNNY", "SNOWY"}) {
            for (String currentEnvironment : new String[]{"FLOOR", "MEADOW", "STREET"}) {
                testStone.setTimeOfDay(timeOfDay);
                testStone.setWeather(currentWeather);
                testStone.setEnvironment(currentEnvironment);

                testStone.talkTo("Hey, how are you doing?");
                assertNull("Stone's answer, environment=" + currentEnvironment + ", weather=" + currentWeather + ", time=" + timeOfDay, testStone.listenForReply());
            }
        }
    }
}

Im am currently collecting ideas on how to simplify that, maybe with a functional interface that creates a Runnable? I am fairly new to the lambda functionalities of newer Java, so maybe you could give me a hint into a promising direction. Of course, in reality it wouldn't all be Strings as arguments.

I already know the @Parameterized functionalities of JUnit, but that's very unhandy if I only have one test that needs all possibilities or if I want to reuse the same Stone object over and over.

3 Answers

If I understand you correctly, you basically want to have a generic method that builds the permutations based on a list of parameters which includes types and values and a class that represents a permutation. In that case you could do something like this:

//P is the type of the Permutation object, T is the type of the parameter
class Parameter<P, T> {
    private final List<T> values;
    private final BiConsumer<P, T> setter;              
    private Parameter<P, ?> next;
    
    private int valueIndex = 0;
    private boolean indexReset = false;
    
    //first in line
    //pass the setter first and the values as varargs for convenience
    public Parameter(BiConsumer<P, T> setter, T... vals) {
        this(null, setter, vals);
    }

    //next chain elements
    //pass the previous element and the setter first and the values as varargs for convenience
    public Parameter(Parameter<P, ?> prev, BiConsumer<P, T> setter, T... vals) {            
        this.values = Arrays.stream(vals).collect(Collectors.toList());
        this.setter = setter;
        
        if( prev != null ) {
            prev.next = this;
        }
    }
    
    //add the next parameter in the chain
    public void setNext(Parameter<P, ?> next) {
        this.next = next;
    }

    //creates the permutations and uses the supplier to create a new instance at the end of the chain
    public List<P> createPermutations(Supplier<P> instanceSupplier) {
        //we don't know the number of permutations so use a linked list here
        List<P> permutations = new LinkedList<>();
        
        //run as long as the index didn't get reset to 0 (reached the end of 1 complete iteration)
        while( !indexReset ) {
            permutations.add(build(instanceSupplier));
        }
        
        return permutations;
    }

    //builds a single permutation by passing the command along the chain
    private P build(Supplier<P> instanceSupplier) {
        P permutation;
        
        //at the end of the chain build the permutation first, otherwise follow the chain
        if( next == null ) {
            permutation = instanceSupplier.get();
        } else {        
            permutation = next.build(instanceSupplier);             
        }
        
        //at this point we're unwinding, i.e. follow the chain tail to head

        //set the value, the index is basically the permutation index so we need to get it into range
        setter.accept(permutation, values.get(valueIndex));

        //assume the index has not reset, we'll check this next
        indexReset = false; 

        //increment the index if there is no next parameter or it has reset in this chain of calls
        if( next == null || next.indexReset ) {
            
            //increment
            valueIndex++;
            
            //reset if needed
            if( valueIndex >= values.size() ) {
                valueIndex = 0;
                indexReset = true;
            }
        }
        
        return permutation;
    }   
}

So what does this class do? It basically builds a linked list of parameters used to build the permutation. The order normally shouldn't matter, just pick what makes sense.

When you build the permutations the class will create call chains for each permutation, creates the instance at the end of the chain and then unwinds, sets the values to the new instance and increments indices as needed. So this is a depth-first approach.

Now let's build the permutations using your Stone class:

//time of day
Parameter<Stone, String> todParam = new Parameter<>(Stone::setTimeOfDay, "NIGHT", "NOON", "TEATIME", "DINNERTIME");     
//current weather
Parameter<Stone, String> wheaterParam = new Parameter<>(todParam, Stone::setCurrentWeather, "RAINY", "FOGGY", "SUNNY", "SNOWY");        
//current environment
Parameter<Stone, String> envParam = new Parameter<>(wheaterParam, Stone::setCurrentEnvironment, "FLOOR", "MEADOW", "STREET");       
    
List<Stone> permutations = todParam.createPermutations(Stone::new);

This would create permutions like

NIGHT, RAINY, FLOOR
NIGHT, RAINY, MEADOW
...
NIGHT, FOGGY, FLOOR
...
DINNERTIME, SNOWY, STREET

Note that there's still a lot room for improvement, especially on the API, but it should get you started.

You could as well build a kinf of iterator that returns just one Stone permutation on each call and even reuse the same Stone in that case. As mentioned, this is meant to get you started. Now it's time to play around with the code and fit it to your needs :)

JUnit 5 has @ParameterizedTest. This parameterizes the test case. Each test case is created by a static method that returns Stream<Arguments>. You can test efficiently if you create a combinations method that returns all the combinations of the arrays specified by the arguments as Stream<Arguments>.

(I'm adding the number parameter to indicate that there may be parameters other than strings.)

static Stream<Arguments> combinations(Object[]... values) {
    Stream.Builder<Arguments> builder = Stream.builder();
    int length = values.length;
    Object[] arguments = new Object[length];
    new Object() {
        void perm(int index) {
            if (index >= length)
                builder.add(Arguments.of(arguments.clone()));
            else
                for (int i = 0, max = values[index].length; i < max; ++i) {
                    arguments[index] = values[index][i];
                    perm(index + 1);
                }
        }
    }.perm(0);
    return builder.build();
}

static Stream<Arguments> stonesCombinations() {
    return combinations(
        new Integer[] {1, 2},
        new String[] {"NIGHT", "NOON", "TEATIME", "DINNERTIME"},
        new String[] {"RAINY", "FOGGY", "SUNNY", "SNOWY"},
        new String[] {"FLOOR", "MEADOW", "STREET"});
}

@ParameterizedTest
@MethodSource("stonesCombinations")  // Specify name of the static method that generates test cases
void testVoiceOutput(int number, String timeOfDay, String currentWeather, String currentEnvironment) {
    System.out.println(
        "number=" + number
        + ", timeOfDay=" + timeOfDay
        + ", currentWeather=" + currentWeather
        + ", currentEnvironment=" + currentEnvironment);
}

output:

number=1, timeOfDay=NIGHT, currentWeather=RAINY, currentEnvironment=FLOOR
number=1, timeOfDay=NIGHT, currentWeather=RAINY, currentEnvironment=MEADOW
number=1, timeOfDay=NIGHT, currentWeather=RAINY, currentEnvironment=STREET
number=1, timeOfDay=NIGHT, currentWeather=FOGGY, currentEnvironment=FLOOR
number=1, timeOfDay=NIGHT, currentWeather=FOGGY, currentEnvironment=MEADOW
number=1, timeOfDay=NIGHT, currentWeather=FOGGY, currentEnvironment=STREET
number=1, timeOfDay=NIGHT, currentWeather=SUNNY, currentEnvironment=FLOOR
   .....
number=2, timeOfDay=DINNERTIME, currentWeather=SNOWY, currentEnvironment=FLOOR
number=2, timeOfDay=DINNERTIME, currentWeather=SNOWY, currentEnvironment=MEADOW
number=2, timeOfDay=DINNERTIME, currentWeather=SNOWY, currentEnvironment=STREET

First keep in mind that tests need to justify their costs in terms of added running time, complexity and maintenance. You must ask yourself what is the extra benefit of an exhaustive test like the one you are describing over a sampling one that checks 2 or three different permutations? Is there anything in the code that would make it fail only for a particular permutation of time/whether/environment out of 1000?

Now let us suppose that for some reason the exhaustive test is a must.

Sometimes code with lambdas is harder to read than traditional code. ( at least for me )

I think you answered your own question. A small and simple step for reducing and simplifying the test code, would be to create a simple test helper, as helper functions, or preferably as a helper class.

@Test
public void testVoiceOutput() {
    Stone testStone = new Stone();
    for (String timeOfDay : new String[]{"NIGHT", "NOON", "TEATIME", "DINNERTIME"}) {
        for (String currentWeather : new String[]{"RAINY", "FOGGY", "SUNNY", "SNOWY"}) {
            for (String currentEnvironment : new String[]{"FLOOR", "MEADOW", "STREET"}) {           
                prepareTestStone(testStone,timeOfDay,currentWeather,currentEnvironment);
                testStone.talkTo("Hey, how are you doing?");
                String expected = getStoneResponse(testStone,timeOfDay,currentWeather,currentEnvironment);
                assertEquals(expected, testStone.listenForReply());
            }
        }
    }
}

There's a lot of room to play with here. If you really want to abstract the test further ( now this may be hiding too much of the test for my taste, you can move both the loading of the inputs and the testing of the results into the helper )

@Test
public void testVoiceOutput() {
    Stone testStone = new Stone();
    StoneTestHelper stoneTestHelper = new StoneTestHelper(testStone);
    for (String timeOfDay : new String[]{"NIGHT", "NOON", "TEATIME", "DINNERTIME"}) {
        for (String currentWeather : new String[]{"RAINY", "FOGGY", "SUNNY", "SNOWY"}) {
            for (String currentEnvironment : new String[]{"FLOOR", "MEADOW", "STREET"}) {           
                stoneTestHelper.checkStone(testStone,timeOfDay,currentWeather,currentEnvironment);                   
            }
        }
    }
}

class StoneTestHelper {
 private Stone testStone;
 public void StoneTestHelper(Stone stone) { this.testStone=stone; }
 public void CheckStone(String timeOfDay,String currentWeather,String currentEnvironment) {
{
            testStone.setTimeOfDay(timeOfDay);
            testStone.setWeather(currentWeather);
            testStone.setEnvironment(currentEnvironment);

            testStone.talkTo("Hey, how are you doing?");
            assertEquals("Stone's answer, environment=" + currentEnvironment + ", weather=" + currentWeather + ", time=" + timeOfDay, testStone.listenForReply());
        }
 }
}
Related