Testing

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

In this scenario, a message provider writes an Avro 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, we would use the pact_consumer PactBuilder:

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
    let mut builder = PactBuilder::new_v4("avro-plugin-consumer", "avro-plugin-provider")
        .using_plugin("avro", None)
        .await;
    builder.output_dir("tests/e2e/pacts");

    builder
        .message_interaction("Order Created", |mut i| async move {
            i.contents_from(json!({
                "pact:content-type": "avro/binary",
                "pact:avro": fixture_path("orders.avsc"),
                "pact:record-name": "Order",
                "id": "notEmpty('100')",
                "names": "notEmpty('name-1')",
                "enabled": "matching(boolean, true)",
                "height": "matching(decimal, 15.8)",
                "width": "matching(decimal, 1.8)",
                "status": "matching(equalTo, 'CREATED')",
                "address": {
                    "no": "matching(integer, 121)",
                    "street": "matching(equalTo, 'street name')"
                },
                "items": [
                    { "name": "notEmpty('Item-1')", "id": "notEmpty('1')" },
                    { "name": "notEmpty('Item-2')", "id": "notEmpty('2')" }
                ],
                "userId": "notEmpty('20bef962-8cbd-4b8c-8337-97ae385ac45d')"
            }))
            .await;
            i
        })
        .await;
Rust example consumer test
    let messages: Vec<_> = builder.messages().collect();
    assert_eq!(messages.len(), 1, "expected exactly one generated message");
    for message in messages {
        assert_eq!(
            message
                .contents
                .contents
                .content_type()
                .expect("plugin must set a content type")
                .to_string(),
            "avro/binary;record=Order"
        );

        let schema = parse_file(Path::new("tests/fixtures/e2e/orders.avsc")).unwrap();
        let ctx = SchemaCtx::new(&schema).unwrap();
        let record = ctx.find_record("Order").unwrap();
        let bytes = message
            .contents
            .contents
            .value()
            .expect("plugin must generate a body");
        let decoded = decode(&ctx, record, &bytes).unwrap();

        let fields = match decoded {
            AvroValue::Record(fields) => fields,
            other => panic!("expected a Record, got {other:?}"),
        };
        assert_eq!(*record_field(&fields, "id"), AvroValue::Long(100));
        assert_eq!(
            *record_field(&fields, "names"),
            AvroValue::String("name-1".to_string())
        );
        assert_eq!(*record_field(&fields, "enabled"), AvroValue::Boolean(true));
        assert_eq!(
            *record_field(&fields, "status"),
            AvroValue::Enum(0, "CREATED".to_string())
        );

        let body_rules = message
            .contents
            .matching_rules
            .rules_for_category(Category::BODY)
            .expect("body matching rules must be present");
        for path in ["id", "names", "enabled", "height", "width", "userId"] {
            assert!(
                body_rules.rules.contains_key(&DocPath::root().join(path)),
                "expected a matching rule for $.{path}"
            );
        }
    }

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.

Rust example provider test
//! Verifies the built plugin binary's `CompareContents` RPC against the pact
//! `e2e_consumer.rs` wrote. Rust's `pact_verifier` verifies message pacts
//! only over HTTP (POST {description, providerStates} -> message bytes +
//! metadata headers) — there is no in-process callback path like JVM's
//! `@PactVerifyProvider`, so this starts a throwaway local HTTP server that
//! returns an independently-constructed `Order` value, to genuinely exercise
//! matching rather than replay the bytes the consumer test generated.
#![cfg(test)]

use apache_avro::types::Value as AvroValue;
use axum::response::IntoResponse;
use axum::routing::post;
use axum::Router;
use pact_avro_plugin::avro::codec::encode;
use pact_avro_plugin::avro::schema::{parse_file, SchemaCtx};
use pact_verifier::callback_executors::HttpRequestProviderStateExecutor;
use pact_verifier::{
    verify_provider_async, FilterInfo, NullRequestFilterExecutor, PactSource, ProviderInfo,
    ProviderTransport, VerificationOptions,
};
use std::path::Path;
use std::sync::Arc;

fn order_bytes() -> Vec<u8> {
    let schema = parse_file(Path::new("tests/fixtures/e2e/orders.avsc")).unwrap();
    let ctx = SchemaCtx::new(&schema).unwrap();
    let record = ctx.find_record("Order").unwrap();

    let value = AvroValue::Record(vec![
        ("id".to_string(), AvroValue::Long(100)),
        ("names".to_string(), AvroValue::String("name-1".to_string())),
        ("enabled".to_string(), AvroValue::Boolean(true)),
        ("height".to_string(), AvroValue::Float(15.8)),
        ("width".to_string(), AvroValue::Double(1.8)),
        (
            "status".to_string(),
            AvroValue::Enum(0, "CREATED".to_string()),
        ),
        (
            "address".to_string(),
            AvroValue::Record(vec![
                ("no".to_string(), AvroValue::Int(121)),
                (
                    "street".to_string(),
                    AvroValue::String("street name".to_string()),
                ),
                (
                    "zipcode".to_string(),
                    AvroValue::Union(1, Box::new(AvroValue::Null)),
                ),
            ]),
        ),
        (
            "items".to_string(),
            AvroValue::Array(vec![
                AvroValue::Record(vec![
                    ("name".to_string(), AvroValue::String("Item-1".to_string())),
                    ("id".to_string(), AvroValue::Long(1)),
                ]),
                AvroValue::Record(vec![
                    ("name".to_string(), AvroValue::String("Item-2".to_string())),
                    ("id".to_string(), AvroValue::Long(2)),
                ]),
            ]),
        ),
        (
            "userId".to_string(),
            AvroValue::Union(
                1,
                Box::new(AvroValue::String(
                    "20bef962-8cbd-4b8c-8337-97ae385ac45d".to_string(),
                )),
            ),
        ),
    ]);

    encode(&ctx, record, value).unwrap()
}

#[tokio::test]
#[ignore]
async fn order_provider_satisfies_the_consumer_pact() {
    let bytes = order_bytes();

    let app = Router::new().route(
        "/",
        post(move || {
            let bytes = bytes.clone();
            async move { ([("content-type", "avro/binary;record=Order")], bytes).into_response() }
        }),
    );
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let port = listener.local_addr().unwrap().port();
    tokio::spawn(async move {
        axum::serve(listener, app).await.unwrap();
    });

    let provider_info = ProviderInfo {
        name: "avro-plugin-provider".to_string(),
        transports: vec![ProviderTransport {
            transport: "message".to_string(),
            port: Some(port),
            path: None,
            scheme: Some("http".to_string()),
        }],
        ..ProviderInfo::default()
    };

    let source = vec![PactSource::Dir("tests/e2e/pacts".to_string())];
    let options = VerificationOptions::<NullRequestFilterExecutor>::default();
    let state_executor = Arc::new(HttpRequestProviderStateExecutor::default());

    let result = verify_provider_async(
        provider_info,
        source,
        FilterInfo::None,
        vec![],
        &options,
        None,
        &state_executor,
        None,
    )
    .await
    .expect("verification run must not error");

    eprintln!("{}", result.output.join("\n"));
    assert!(result.result, "provider verification must pass");
}