javajunit5

Junit5 multidimensional file source


I need to write a test that requires setup and actual test data. Simplified code will be like:

//given config
purchase_configs.add(pc1);
purchase_configs.add(pc2);

sales_configs.add(sc1);
sales_configs.add(sc2);

// given data
input_value = ....
//given expected output
expected_value = ....

//when
result = calculate(purchase_configs, sales_configs, input_value)

//then
assertEquals(expected_value, result)

In fact configs and input values are quite complex, so I want to externalise them to file/files e.g. using @CSVFileValue Apart of the params the test logic is the same.

The issue is that number of purchase and sales configs may vary. Sometimes it's pc1, pc2, sometimes only pc1, sometimes, none, sometimes more. Same for sales.

I know, that I can write multiple test - one per number of configs, but seems to be ugly. I know, that I can use @MethodSource and generate variable list of params, but then I need to write all the code for parsing CSV/JSON or whatever file or have a lot of Java code to create objects manually, also, if possible I'd like to externalize only important/meaningful params, all the rest can use some default/calculated not to decrease the readability.

Is there any clever way to use junit @ParametrizedTest to achieve the goal which is have one test method with params and, if possible do not need to create readers and parsers of files on myown?


Solution

  • Finally, the most convinient solution I found is to use ArgumentsAggregator combined with CsvSource

    When config objects are very simple

    Assuming SalesConfig class is

    public record SalesConfig(String param1, String param2, int param3) {
    }
    

    I needed to create ArgumentsAggregator class like:

    public class SalesConfigListAggregator implements ArgumentsAggregator {
        private final char CONFIG_DELIMITER = '|';
        private final char FIELDS_DELIMITER = ':';
    
        @Override
        public List<SalesConfig> aggregateArguments(ArgumentsAccessor accessor, ParameterContext context) throws ArgumentsAggregationException {
            int index = context.getIndex();
            String string = accessor.getString(index);
            List<SalesConfig> configList = Stream.of(StringUtils.split(string, CONFIG_DELIMITER))
                    .map(StringUtils::trimToNull)
                    .map(config -> {
                        String[] fields = StringUtils.split(config, FIELDS_DELIMITER);
                        return new SalesConfig(fields[0], fields[1], Integer.parseInt(fields[2]));
                    }).toList();
            return configList;
        }
    }
    

    Similar object (PurchaseConfig) and aggregator (PurchaseConfigListAggregator) need to be created for purchase configs.

    Then I was able to use it in my test. I use here @CsvSource to make it more visible in the answer, but in real, bigger example @CsvFileSource can be applied.

        @ParameterizedTest
        @CsvSource(textBlock = """
               #INPUT, OUTPUT, PURCHASE_CONFIGS       , SALES_CONFIGS
                 10.0,   10.0, a:abc:1|b:xyz:3        , c:xyz:3|d:xyz:3|e:xyz:3
                 20.0,   20.0, a:abc:1|b:xyz:3|g:ooo:7, c:xyz:3|d:xyz:3
               """)
        void should_test_calculation(BigDecimal input, BigDecimal expectedOutput,
                                     @AggregateWith(PurchaseConfigListAggregator.class) List<PurchaseConfig> purchaseConfigs,
                                     @AggregateWith(SalesConfigListAggregator.class) List<SalesConfig> salesConfigs
        ) {
            //when
            var result = calculate(purchaseConfigs, salesConfigs, input)
    
            //then
            assertEquals(expectedOutput, result)
        }
    

    Further I could create own annotation and make delimiters configurable or even result class configurable, so there is no need to create separate aggregator for each type of config. I find such solution most compact and readable as long sales and purchase configs are relatively small and easily read and parse by human on the fly.

    When config objects are more complex

    The solution can be to put them into JSON file. Depending on the use case you can have separate files for sales and purchase conditions or just one file containing config object with both lists. I chose the first option here.

    [
        {
          'param1': 'RENT',
          'param2': 100,
          'param3': {
            'param4': 'AAA',
            'param5': 'BBB',
            'param6': 23.5
          }
        },
        {
          'param1': 'BUY',
          'param2': 200,
          'param3': {
            'param4': 'CCC',
            'param5': 'DDD',
            'param6': 45.5
          }
        },
        {
          'param1': 'RENT',
          'param2': 300,
          'param3': {
            'param4': 'EEE',
            'param5': 'FFF',
            'param6': 67.5
          }
        }
    ]
    

    and similar one for sales configs.

    Then I need an aggregator

    public class SalesConfigListAggregator implements ArgumentsAggregator {
       
        @Override
        public List<SalesConfig> aggregateArguments(ArgumentsAccessor accessor, ParameterContext context) throws ArgumentsAggregationException {
            int index = context.getIndex();
            String fileName = accessor.getString(index);
            List<SalesConfig> configList = readAndParseJsonFile(fileName);
            return configList;
        }
    }
    

    And finally the test looks like

        @ParameterizedTest
        @CsvSource(textBlock = """
               #INPUT, OUTPUT, PURCHASE_CONFIGS       , SALES_CONFIGS
                 10.0,   10.0, purchase_1.json        , sales_1.json
                 20.0,   20.0, purchase_2.json.       , sales_2.json
               """)
        void should_test_calculation(BigDecimal input, BigDecimal expectedOutput,
                                     @AggregateWith(PurchaseConfigListAggregator.class) List<PurchaseConfig> purchaseConfigs,                                 @AggregateWith(SalesConfigListAggregator.class) List<SalesConfig> salesConfigs
        ) {
            //when
            var result = calculate(purchaseConfigs, salesConfigs, input)
    
            //then
            assertEquals(expectedOutput, result)
        }