Testing

For an overview how asynchronous messages work with Pact, see Non-HTTP testing (Message Pact).

In this scenario, a message provider writes a Protocol Buffer message to some one-way transport mechanism, like a message queue, and a consumer then reads it. With this style of testing, the transport mechanism is abstracted away.

Consumer

The message consumer test is written using the Pact Message test DSL. The test DSL defines the expected message format, and then the consumer is tested with an example message generated by the test framework.

The Avro test configuration

The consumer tests need to get the plugin loaded and configure the expected messages to use in the test. This is done using the usingPlugin (or using_plugin, depending on the language implementation) followed by the content for the test in some type of map form.

For each field of the message that we want in the contract, we define an entry with the field name as the key and a matching definition as the value. For documentation on the matching definition format, see Matching Rule definition expressions.

For example, for a JVM test (taken from Protocol Buffer Java examples) we would use the PactBuilder class:

Example Avro schema
[
  {
    "namespace": "com.github.austek.example",
    "type": "record",
    "name": "Item",
    "fields": [
      {
        "name": "name",
        "type": "string"
      },
      {
        "name": "id",
        "type": "long"
      }
    ]
  },
  {
    "namespace": "com.github.austek.example",
    "type": "record",
    "name": "Order",
    "fields": [
      {
        "name": "id",
        "type": "long"
      },
      {
        "name": "names",
          "type": "string"
      },
      {
        "name": "enabled",
        "type": "boolean"
      },
      {
        "name": "height",
        "type": "float"
      },
      {
        "name": "width",
        "type": "double"
      },
      {
        "name": "status",
        "type": {
          "type": "enum",
          "name": "Status",
          "symbols": [
            "CREATED",
            "UPDATED",
            "DELETED"
          ],
          "default": "CREATED"
        }
      },
      {
        "name": "address",
        "type": {
          "type": "record",
          "name": "MailAddress",
          "fields": [
            {
              "name": "no",
              "type": "int"
            },
            {
              "name": "street",
              "type": "string"
            },
            {
              "name": "zipcode",
              "type": [
                "bytes",
                "null"
              ]
            }
          ]
        }
      },
      {
        "name": "items",
        "type": {
          "type": "array",
          "items": "com.github.austek.example.Item"
        }
      },
      {
        "name": "userId",
        "type": [
          "null",
          {
            "type": "string",
            "logicalType": "uuid"
          }
        ]
      }
    ]
  }
]
Consumer configuration
    return builder
        .usingPlugin("avro")
        .expectsToReceive("Order Created", "core/interaction/message")
        .with(
            Map.of(
                "message.contents",
                Map.ofEntries(
                    Map.entry("pact:avro", schemasPath),
                    Map.entry("pact:record-name", "Order"),
                    Map.entry("pact:content-type", "avro/binary"),
                    Map.entry("id", "notEmpty('100')"),
                    Map.entry("names", "notEmpty('name-1')"),
                    Map.entry("enabled", "matching(boolean, true)"),
                    Map.entry("height", "matching(decimal, 15.8)"),
                    Map.entry("width", "matching(decimal, 1.8)"),
                    Map.entry("status", "matching(equalTo, 'CREATED')"),
                    Map.entry(
                        "address",
                        Map.of(
                            "no", "matching(integer, 121)",
                            "street", "matching(equalTo, 'street name')")),
                    Map.entry(
                        "items",
                        List.of(
                            Map.of(
                                "name", "notEmpty('Item-1')",
                                "id", "notEmpty('1')"),
                            Map.of(
                                "name", "notEmpty('Item-2')",
                                "id", "notEmpty('2')"))),
                    Map.entry("userId", "notEmpty('20bef962-8cbd-4b8c-8337-97ae385ac45d')"))))
        .toPact();
Java example consumer test
  @Test
  @PactTestFor(pactMethod = "configureRecordWithDependantRecord")
  void consumerRecordWithDependantRecord(V4Interaction.AsynchronousMessage message)
      throws IOException {
    MessageContents messageContents = message.getContents();
    List<Order> orders =
        arrayByteToAvroRecord(Order.class, messageContents.getContents().getValue());
    Order order = assertFirstOrder(orders);

    assertThat(messageContents.getContents().getContentType())
        .hasToString("avro/binary; record=Order");
    assertThat(messageContents.getContents().getContentTypeHint())
        .isEqualTo(ContentTypeHint.BINARY);

    Map<String, MatchingRuleCategory> ruleCategoryMap =
        ((MatchingRulesImpl) messageContents.getMatchingRules()).getRules();
    assertThat(ruleCategoryMap).hasSize(1);
    Map<String, MatchingRuleGroup> rules = ruleCategoryMap.get("body").getMatchingRules();
    List<MatchingRule> idRules = rules.get("$.id").getRules();
    assertThat(idRules).hasSize(1);
    assertThat(idRules.get(0)).extracting("name").isEqualTo("not-empty");
    List<MatchingRule> name0Rules = rules.get("$.names").getRules();
    assertThat(name0Rules).hasSize(1);
    assertThat(name0Rules.get(0)).extracting("name").isEqualTo("not-empty");

    assertThat(order.getUserId())
        .isEqualTo(UUID.fromString("20bef962-8cbd-4b8c-8337-97ae385ac45d"));

    assertDoesNotThrow(() -> orderService.process(order));
  }

  public static <T> List<T> arrayByteToAvroRecord(Class<T> c, byte[] bytes) throws IOException {
    SpecificDatumReader<T> datumReader = new SpecificDatumReader<>(c);
    List<T> records = new ArrayList<>();

    try (ByteArrayInputStream in = new ByteArrayInputStream(bytes)) {
      BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(in, null);
      while (!decoder.isEnd()) records.add(datumReader.read(null, decoder));
    }

    return records;
  }

Provider

The message provider is verified by getting it to generate a message, and then this is verified against the Pact file from the consumer. There are two main ways of verifying the provider:

  1. Write a test in the provider code base that can call the provider to generate the message.

  2. Use an HTTP proxy server that can call the provider and return the generated message, and then use a Pact framework verifier to verify it.

Java example provider test
package com.github.austek.example.pulsar.avro;

import au.com.dius.pact.core.model.ContentTypeHint;
import au.com.dius.pact.provider.MessageAndMetadata;
import au.com.dius.pact.provider.PactVerifyProvider;
import au.com.dius.pact.provider.junit5.MessageTestTarget;
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.loader.PactBroker;
import com.github.austek.example.Item;
import com.github.austek.example.MailAddress;
import com.github.austek.example.Order;
import com.github.austek.example.Status;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.apache.avro.io.BinaryEncoder;
import org.apache.avro.io.EncoderFactory;
import org.apache.avro.specific.SpecificDatumWriter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;

@Provider("avro-plugin-provider")
@PactBroker
class PactPulsarProducerTest {
  private static final String AVRO_CONTENT_TYPE = "avro/binary; record=Order";
  private static final String KEY_CONTENT_TYPE = "contentType";
  private static final String KEY_CONTENT_TYPE_HINT = "contentTypeHint";
  private static final ContentTypeHint CONTENT_TYPE_HINT = ContentTypeHint.BINARY;

  @TestTemplate
  @ExtendWith(PactVerificationInvocationContextProvider.class)
  void testTemplate(PactVerificationContext context) {
    context.verifyInteraction();
  }

  @SuppressWarnings("JUnitMalformedDeclaration")
  @BeforeEach
  void setupTest(PactVerificationContext context) {
    context.setTarget(new MessageTestTarget());
  }

  @PactVerifyProvider("Order Created")
  public MessageAndMetadata orderCreatedEvent() throws IOException {
    Order order =
        new Order(
            100L,
            "name-1",
            true,
            15.8F,
            1.8D,
            Status.CREATED,
            new MailAddress(121, "street name", null),
            List.of(new Item("Item-1", 1L), new Item("Item-2", 2L)),
            UUID.fromString("20bef962-8cbd-4b8c-8337-97ae385ac45d"));

    return new MessageAndMetadata(
        serialise(order),
        Map.of(
            KEY_CONTENT_TYPE, AVRO_CONTENT_TYPE,
            KEY_CONTENT_TYPE_HINT, CONTENT_TYPE_HINT));
  }

  private byte[] serialise(Order record) throws IOException {
    SpecificDatumWriter<Order> writer = new SpecificDatumWriter<>(Order.class);
    try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
      BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(outputStream, null);
      writer.write(record, encoder);
      encoder.flush();
      return outputStream.toByteArray();
    }
  }
}