mtedone / podam

PODAM - POjo DAta Mocker
https://mtedone.github.io/podam
MIT License
323 stars 750 forks source link

Global Data Provider Strategy not working, neither custom TypeManufacturers (seems to be the same issue) #320

Closed AngeloIglesias closed 1 year ago

AngeloIglesias commented 1 year ago

So far I found out that there are two ways to let PODAM choose a class instance (not just some fields aka. attributes) as a whole.

  1. There is the DataProviderStrategy

grafik

grafik

  1. ..And there are the TypeManufacturers (Custom IntTypeManufacturerImpl or IntTypeManufacturerImplare both working fine)

grafik

grafik

I debugged both versions, but they have the same problem: Both versions are called correctly, but PODAM does not memorize that the instances have been already initialized and initializes every field again with a random value. I can not see any differences to the StringTypeManufacturerImpl or the IntTypeManufacturerImpl and why they are working.

I guess an Issue with ManufacturingContext in the PodamFactoryImpl or maybe it can be fixed with the ClassStrategy: grafik

daivanov commented 1 year ago

Hi,

This is how Podam is desinged, type manufacturers are making the class, and then podam will go and try to fill it. From you post it is not so easy to understand what you are trying to achieve. It would help to see AssociationRuleDto and what exactly you are trying to customize there.

Thanks, Daniil

AngeloIglesias commented 1 year ago

Hi, here is a short example, the class is auto-generated via OpenApi Generator:

import java.util.Objects;
import java.util.Arrays;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.annotation.JsonTypeName;

/**
 * AssociationRuleDto
 */
@JsonPropertyOrder({
  AssociationRuleDto.JSON_PROPERTY_ASSOCIATION_RULE_TYPE,
  AssociationRuleDto.JSON_PROPERTY_AEND_CARDINALITY,
  AssociationRuleDto.JSON_PROPERTY_ZEND_CARDINALITY
})
@JsonTypeName("AssociationRule")
@javax.annotation.Generated(value = "org.openapitools.codegen.languages.JavaClientCodegen", date = "2023-07-26T18:08:28.668830200+02:00[Europe/Berlin]")
public class AssociationRuleDto {
  /**
   * Gets or Sets associationRuleType
   */
  public enum AssociationRuleTypeEnum {
    FRAME_CONTRACT("CONTRACT_TYPE_A"),

    PARTNER_CONTRACT("CONTRACT_TYPE_B");

    private String value;

    AssociationRuleTypeEnum(String value) {
      this.value = value;
    }

    @JsonValue
    public String getValue() {
      return value;
    }

    @Override
    public String toString() {
      return String.valueOf(value);
    }

    @JsonCreator
    public static AssociationRuleTypeEnum fromValue(String value) {
      for (AssociationRuleTypeEnum b : AssociationRuleTypeEnum.values()) {
        if (b.value.equals(value)) {
          return b;
        }
      }
      throw new IllegalArgumentException("Unexpected value '" + value + "'");
    }
  }

  public static final String JSON_PROPERTY_ASSOCIATION_RULE_TYPE = "associationRuleType";
  private AssociationRuleTypeEnum associationRuleType;

  /**
   * Gets or Sets aendCardinality
   */
  public enum AendCardinalityEnum {
    ONE("ONE"),

    ONE_OR_MORE("ONE_OR_MORE"),

    ZERO_OR_MORE("ZERO_OR_MORE");

    private String value;

    AendCardinalityEnum(String value) {
      this.value = value;
    }

    @JsonValue
    public String getValue() {
      return value;
    }

    @Override
    public String toString() {
      return String.valueOf(value);
    }

    @JsonCreator
    public static AendCardinalityEnum fromValue(String value) {
      for (AendCardinalityEnum b : AendCardinalityEnum.values()) {
        if (b.value.equals(value)) {
          return b;
        }
      }
      throw new IllegalArgumentException("Unexpected value '" + value + "'");
    }
  }

  public static final String JSON_PROPERTY_AEND_CARDINALITY = "aendCardinality";
  private AendCardinalityEnum aendCardinality;

  /**
   * Gets or Sets zendCardinality
   */
  public enum ZendCardinalityEnum {
    ONE("ONE"),

    ONE_OR_MORE("ONE_OR_MORE"),

    ZERO_OR_MORE("ZERO_OR_MORE");

    private String value;

    ZendCardinalityEnum(String value) {
      this.value = value;
    }

    @JsonValue
    public String getValue() {
      return value;
    }

    @Override
    public String toString() {
      return String.valueOf(value);
    }

    @JsonCreator
    public static ZendCardinalityEnum fromValue(String value) {
      for (ZendCardinalityEnum b : ZendCardinalityEnum.values()) {
        if (b.value.equals(value)) {
          return b;
        }
      }
      throw new IllegalArgumentException("Unexpected value '" + value + "'");
    }
  }

  public static final String JSON_PROPERTY_ZEND_CARDINALITY = "zendCardinality";
  private ZendCardinalityEnum zendCardinality;

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    AssociationRuleDto associationRule = (AssociationRuleDto) o;
    return Objects.equals(this.associationRuleType, associationRule.associationRuleType) &&
        Objects.equals(this.aendCardinality, associationRule.aendCardinality) &&
        Objects.equals(this.zendCardinality, associationRule.zendCardinality);
  }

  @Override
  public int hashCode() {
    return Objects.hash(associationRuleType, aendCardinality, zendCardinality);
  }

  @Override
  public String toString() {
    StringBuilder sb = new StringBuilder();
    sb.append("class AssociationRuleDto {\n");
    sb.append("    associationRuleType: ").append(toIndentedString(associationRuleType)).append("\n");
    sb.append("    aendCardinality: ").append(toIndentedString(aendCardinality)).append("\n");
    sb.append("    zendCardinality: ").append(toIndentedString(zendCardinality)).append("\n");
    sb.append("}");
    return sb.toString();
  }

  /**
   * Convert the given object to string with each line indented by 4 spaces
   * (except the first line).
   */
  private String toIndentedString(Object o) {
    if (o == null) {
      return "null";
    }
    return o.toString().replace("\n", "\n    ");
  }

}

When I do the following JUnit Test..

@Test
    void testQueryContract()
        throws Exception
    {
        ContractID contractID = new ContractID(Domain.XXX, 1);
        ContractDto contractDto = podamFactory.manufacturePojo(ContractDto.class);

        wireMockServer.stubFor(WireMock.get(
                WireMock.urlEqualTo(QUERY_CONTRACT_PATH.replace("{id}", IdHelper.toString(contractID)))).willReturn(
                TestHelper.jsonResponse().withBody(mapper.writeValueAsString(contractDto))));

        Contract result = contractQueryWebClientAdapter.queryContract(contractID);
        assertThat(result).isNotNull();
        ContractAssertionHelper.assertContract(contractDto, result);
    }

.. I run into an error because of the internal validation of AssociationRuleDto: For example I can not have CONTRACT_TYPE_B in one field and a ONE to ONE_OR_MORE relationship mapped onto the other fields of AssociationRuleDto. So the classic attribute Strategy of PODAM will fail. Maybe I could set constraints for each attribute / field with the aid of PODAM, but it would be extrem complicated to implement for a test case.

That is why I wanted PODAM to use a random instance AssociationRuleDto (i determine the possible instances) instead of getting the fields initilized with random values through PODAM. PODAM would still be very useful since I just have to mock the ContractDto and PODAM still "injects" a random instance of AssociationRuleDto. I could not find the case in the documentation.

But I would have expected at least the DataProviderStrategy to do this kind of stuff, since of the "Data" in its name. Adding every field separately is too complicated in case of existing Validation. So I am force, to use one instance of AssociationRuleDto to keep the attribution simple or overwrite the random values later. None of the solutions is quite elegant.. Or do I miss something?

AngeloIglesias commented 1 year ago

I noticed there was a significant change in version 8, so it might now work with the AbstractTypeManufacturer: grafik

I cannot verify it because I'm still using Spring Boot 2 and encountering an issue with Mockito when I include jakarta.validation:jakarta.validation-api:3.0.2.

daivanov commented 1 year ago

This is just a refactoring, the behaviour has not changed. But I included your change with avoiding creating string twice.

Thanks, Daniil

AngeloIglesias commented 1 year ago

Yes, I saw it, too. I meant your Commit "Encapsulate typeArgsMap in ManufacturingContext, TypeManufacturer int…":

grafik

However I could solve my Problem in Podam 7 this way:

New DataProviderStrategy

import java.lang.annotation.Annotation;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;

import uk.co.jemos.podam.api.AbstractRandomDataProviderStrategy;

import uk.co.jemos.podam.api.ClassAttribute;
import uk.co.jemos.podam.common.AttributeStrategy;

/**
 *  Customized variant of PODAM´s Data Provider to be able to define the used data at class level
 *   - Allows constraints between field values
 *   - Reflection could be used to copy existing classes automagically
 *
 *   @author mfernandezig
 *   @since 01.08.2023
 */
public class CustomDataProviderStrategy extends AbstractRandomDataProviderStrategy
{
    // Define separate data provider for each class/pojo:
    private Map<Class<?>, Function<ClassAttribute, Object>> functionMap = new HashMap<>();

    //Second provider method if no class level value was supplied, not required.
    private Function<ClassAttribute, Object> hook;

    /**
     * New Instance of PODAM`s Data Provider Strategy, which can be injected in the PodamFactoryImpl (via constructor)
     *
     * @param provider A function map which defines for which class a custom strategy should be applied and
     *                 which function serves as data provider for the determined class
     */
    public CustomDataProviderStrategy(CustomDataProvider provider)
    {
        this.functionMap = provider.getFunctionMap(); //ToDo: May be generalized to add data of multiple maps not just one.
        this.hook = provider.getHookValue();  // setze den globalen Hook aus dem provider (nicht erforderlich)
    }

    //~ Methods ----------------------------------------------------------------------------------------------------------------

    @Override
    public AttributeStrategy<?> getStrategyForAttribute(final ClassAttribute attribute)
    {
        AttributeStrategy<?> strategy = null;
        strategy = customizeStrategy(attribute);
        if(strategy == null)
        {
            strategy = super.getStrategyForAttribute(attribute); //run as normal (default)
        }
        return strategy;
    }

    /**
     * Injects data for each class (the way) defined in the function map, if any
     *
     * @param attribute PODAM`s Field Descriptor
     * @return PODAM`s special strategy to manipulate a single field (in podam called attribute) value
     */
    private AttributeStrategy<?> customizeStrategy(ClassAttribute attribute) {
        AttributeStrategy<?> strategy = null; // null determines default strategy

        // Select and use correct data provider:
        Object value = Optional.ofNullable(functionMap.get(attribute.getAttribute().getDeclaringClass()))
                .map(f -> f.apply(attribute)) //Check more specific class level strategy first
                .orElseGet(() -> hook != null ? hook.apply(attribute) : null); //else check global strategy if provided

        // Create custom strategy if required:
        if( value != null)
        {
            strategy = new AttributeStrategy<Object>() {
                @Override
                public Object getValue(Class<?> attrType, List<Annotation> attrAnnotations) {
                    return value;
                }
            };
        }

        return strategy;
    }
}

New CustomDataProvider

import uk.co.jemos.podam.api.ClassAttribute;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.function.Function;

/**
 *
 * @author mfernandezig
 * @since 01.08.2023
 */
public abstract class CustomDataProvider {

    protected static final Random RANDOM = new Random();
    protected static final Map<Class<?>, Function<ClassAttribute, Object>> functionMap = new HashMap<>();

    /**
     * Get map for class level resolution
     *
     * @return
     */
    Map<Class<?>, Function<ClassAttribute, Object>> getFunctionMap() {
        return functionMap;
    }

    /**
     * Get global hook if set
     *
     * @return
     */
    public Function<ClassAttribute, Object> getHookValue() {
        return attribute -> null;  // standardmäßig immer null, außer in der abgeleiteten Klasse überschrieben
    }

    /**
     * Returns the field name of class in lower case letters
     * Used for jump table (switch statement)
     *
     * @param attribute Podam class with field metadata
     * @return
     */
    protected static String nameOf(ClassAttribute attribute)
    {
        return attribute.getName().toLowerCase(); // case-sensitivity vermeiden
    }

    ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
    // UTIL
    ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////

    protected static OffsetDateTime randomOffsetDateTime(String startStr, String endStr) {
        LocalDateTime start = LocalDateTime.parse(startStr);
        LocalDateTime end = LocalDateTime.parse(endStr);

        long startSeconds = start.toEpochSecond(ZoneOffset.UTC);
        long endSeconds = end.toEpochSecond(ZoneOffset.UTC);
        long random = startSeconds + (long)(RANDOM.nextDouble() * (endSeconds - startSeconds));

        return OffsetDateTime.ofInstant(Instant.ofEpochSecond(random), ZoneOffset.UTC);
    }

    /**
     * Generates a random enum value
     *
     * @param enumClass The enum which values should be used
     * @return The String of a random enum value
     */
    protected String generateRandomEnumValueAsString(Class<? extends Enum<?>> enumClass)
    {
        Object[] enumValues = enumClass.getEnumConstants();
        int randomIndex = RANDOM.nextInt(enumValues.length);
        return enumValues[randomIndex].toString();
    }
}

Example Usage:

Configuring Podam Factory via Spring:

@Configuration
public class PodamConfig
{
    @Bean
    @Profile("Query")
    public PodamFactory queryPodamFactory() {
        ///////////////////////////////////////////////////////////////////////////////////////////////////////////////
        //Allow custom class creation via global strategy:
        DataProviderStrategy strategy = new CustomDataProviderStrategy(new QueryData());
        PodamFactory factory = new PodamFactoryImpl(strategy);

        //Customized podam manufacturers:
        CustomIntegerManufacturer customIntegerManufacturer = new CustomIntegerManufacturer();
        factory.getStrategy().addOrReplaceTypeManufacturer(Integer.class, customIntegerManufacturer);

        return factory;
    }
}

Setting up some test data for example:

import /*package name*/.podam.strategies.CustomDataProvider;
import uk.co.jemos.podam.api.ClassAttribute;

public class QueryData extends CustomDataProvider {

    private static final int ASSOCIATION_RULE_VARIANT = RANDOM.nextInt(4);
    private static int serviceCounter = 0;
    private static final AssociationRuleDto[] ASSOCIATION_RULE_VARIANTS = generateAssociationRules();

    public QueryData()
    {
        functionMap.put(AssociationRuleDto.class, QueryData::associationRule);
        functionMap.put(ServiceDto.class, QueryData::service);
    }

    public static Object associationRule(ClassAttribute field) {
        // Match exactly:
        switch (nameOf(field))
        {
            case "aendcardinality":
                return ASSOCIATION_RULE_VARIANTS[ASSOCIATION_RULE_VARIANT].getAendCardinality();

            case "zendcardinality":
                return ASSOCIATION_RULE_VARIANTS[ASSOCIATION_RULE_VARIANT].getZendCardinality();

            default:
                return null;
        }
    }

    public static Object service(ClassAttribute field) {
        // Match exactly:
        switch (nameOf(field))
        {
            case "serviceid":
                return "Service No. " + (999 + serviceCounter++);

            default:
                return null;
        }
    }

    @Override
    public Function<ClassAttribute, Object> getHookValue() {
    // Replace Regex in all places:
        return field -> {
            Object value = null;

            if ((nameOf(field).contains("contractid")))
            {
                value = "1234";
            }

            return value;
        };
    }

    ////////////////////////////////////////////////////////////////////////////////////////////////////
    ///////////////////////////////////////////////////////////////////////////////////////////////////

    private static AssociationRuleDto[] generateAssociationRules() {
        AssociationRuleDto[] data = new AssociationRuleDto[4];

        for( int i = 0; i < 4; i++)
        {
            data[i] = new AssociationRuleDto();
            switch (i)
            {
                case 0:
                    data[i].setAendCardinality(AssociationRuleDto.AendCardinalityEnum.ONE);
                    data[i].setZendCardinality(AssociationRuleDto.ZendCardinalityEnum.ZERO_OR_MORE);
                    break;

                case 1:
                    data[i].setAendCardinality(AssociationRuleDto.AendCardinalityEnum.ONE);
                    data[i].setZendCardinality(AssociationRuleDto.ZendCardinalityEnum.ONE_OR_MORE);

                    break;

                case 2:
                    data[i].setAendCardinality(AssociationRuleDto.AendCardinalityEnum.ONE);
                    data[i].setZendCardinality(AssociationRuleDto.ZendCardinalityEnum.ONE_OR_MORE);

                    break;

                case 3:
                    data[i].setAendCardinality(AssociationRuleDto.AendCardinalityEnum.ONE);
                    data[i].setZendCardinality(AssociationRuleDto.ZendCardinalityEnum.ONE);

                    break;
            }
        }

        return data;
    }
}

Setting up a test example:

@ContextConfiguration(initializers = WireMockInitializer.class)
@Import(WebClientConfiguration.class)
@SpringBootTest
@ActiveProfiles("Query")
public class ServiceQueryWebClientAdapterTest
{

    private static final String QUERY_PATH = "/query/{id}";

    private final WireMockServer wireMockServer = WireMockSingleton.getServer();

    @Autowired
    private ContractQueryRequestMapper contractQueryRequestMapper;

    @Autowired
    private ContractQueryService contractQueryWebClientAdapter;

    @Autowired
    private ObjectMapper mapper;

    @Autowired
    private PodamFactory podamFactory;

    @BeforeEach
    public void beforeEach()
    {
        assertThat(podamFactory).withFailMessage("The PODAM factory cannot be null!").isNotNull();
        assertThat(podamFactory.getStrategy()).withFailMessage("The factory strategy cannot be null!").isNotNull();
        wireMockServer.resetAll();
    }

    @Test
    void testQuery()
        throws Exception
    {
        String serviceID = new Service(/*..*/)
        ServiceDto serviceDto = podamFactory.manufacturePojo(ServiceDto.class);

        wireMockServer.stubFor(WireMock.get(
                WireMock.urlEqualTo(QUERY_PATH.replace("{id}", serviceID))).willReturn(
                TestHelper.jsonResponse().withBody(mapper.writeValueAsString(serviceDto))));

        Service service = contractQueryWebClientAdapter.queryContract(serviceID);
        assertThat(serviceID).isNotNull();
        ContractAssertionHelper.assertService(serviceDto, service);
    }
}