Mostly dependency-free wrapper of pact-jvm for commonly used scala testing frameworks. To find out more about consumer-driven contract testing, visit the Pact Foundation website here! Supported scala versions are 2.12, 2.13 and 3.
pact4s
is still in the early stages of development! Please consider helping us out by contributing or raising issues :)
- pact4s
pact4s
is available through maven-central.
This library provides support for munit-cats-effect
, weaver
, and scalatest
, to write and verify both request/response and message pacts. The underlying library, pact-jvm, is currently supported on two branches, depending on the jdk version:
Branch | Pact Spec | JDK |
---|---|---|
4.4.x | V4* | 11+ |
4.1.x | V3 | 8-12 |
All the modules in pact4s
are built against both of these branches to accommodate all jdk versions. To use the java11+ modules, simply add one of the following dependencies to your project:
"io.github.jbwheatley" %% "pact4s-munit-cats-effect" % xxx
"io.github.jbwheatley" %% "pact4s-weaver" % xxx
"io.github.jbwheatley" %% "pact4s-scalatest" % xxx
We also offer some additional helpers for using JSON encoders directly in your pact definitions. Currently, support is offered for circe
and play-json
in the modules pact4s-circe
and pact4s-play-json
, respectively. If you would like to see support for your favourite scala JSON library, consider submitting a PR!
We recommend using java11+ for your build if possible, as v4.3.x+ of pact-jvm will see longer continued support. But, if you are unable to use java11+ for your build, versions that work with java 8 can be found with the version suffix -java8
. e.g. instead of using version 0.1.0
, use version 0.1.0-java8
.
N.B. If you try and use the non-java8 module versions, and your project is built on java8, you will see an error like this:
java.lang.UnsupportedClassVersionError: au/com/dius/pact/core/model/BasePact has been compiled by a more recent version of the Java Runtime (class file version
55.0), this version of the Java Runtime only recognizes class file versions up to 52.0
In the example directory there are two modules, one for the consumer
, and one for the provider
. The consumer has tests to generate pacts using both the munit
and scalatest
pact forging interfaces. These should be ran first, as they
publish the pacts to files in ./example/resources/pacts
which the provider tests require to run. The provider has tests that verify the consumer generated pacts using the munit
and scalatest
pact verification interfaces. To run the tests from sbt go to project exampleConsumer
or project exampleProvider
.
The modules pact4s-munit-cats-effect
, pact4s-weaver
and pact4s-scalatest
mixins all share common interfaces for defining pacts. The APIs for each of these modules is slightly different to account for the differences between the APIs of the testing frameworks. We recommend looking at the tests in this project for examples of each, or the examples module.
Pacts are constructed using the pact-jvm DSL, but with additional helpers for easier interoperability with scala. For example, anywhere a java Map
is expected, a scala Map
, or scala tuples can be provided instead.
Using PactDslJsonBody
or PactDslJsonArray
can be painful.
This is why Pact JVM exposes a LambdaDsl
which aims to be easier to use and read (Read "Why a new DSL implementation?").
But still, in Scala it can be very verbose to use it.
Pact4s provides a ScalaDsl
trait to reduce verbosity.
For instance, see below how the following JSON body can be expressed with both DSLs:
{
"keyA": {
"a1": "...",
"a2": "..."
},
"keyB": [1]
}
// Pact JVM LambdaDsl
val dsl: DslPart = LambdaDsl
.newJsonBody { rootObj =>
rootObj.`object`(
"keyA",
o => {
o.stringType("a1")
o.stringType("a2")
()
}
)
rootObj.array(
"keyB",
a => {
a.integerType()
()
}
)
()
}
.build()
// Pact4s ScalaDsl
val dsl: DslPart = newJsonObject { rootObj =>
rootObj.newObject("keyA") { o =>
o.stringType("a1")
o.stringType("a2")
}
rootObj.newArray("keyB") { a =>
a.integerType()
}
}
If you want to construct simple pacts with bodies that do not use the pact-jvm matching dsl, (PactDslJsonBody
), a scala data type A
can be passed to .body
directly, provided there is an implicit instance of pact4s.PactBodyEncoder[A]
provided.
Instances of pact4s.PactBodyEncoder
are provided for:
- any type that has a
circe.Encoder
by adding the additional dependency:"io.github.jbwheatley" %% "pact4s-circe" % xxx
- any type that has a
play.api.libs.json.Writes
by adding the additional dependency:"io.github.jbwheatley" %% "pact4s-play-json" % xxx
This allows the following when using the import pact4s.circe.implicits._
:
import pact4s.circe.implicits._
final case class Foo(a: String)
implicit val encoder: Encoder[Foo] = ???
val pact: RequestResponsePact =
ConsumerPactBuilder
.consumer("Consumer")
.hasPactWith("Provider")
.uponReceiving("a request to say Hello")
.path("/hello")
.method("POST")
.body(Foo("abcde"), "application/json")
// ...
Or the following when using the import pact4s.playjson.implicits._
:
import pact4s.playjson.implicits._
final case class Foo(a: String)
implicit val reads: Writes[Foo] = ???
val pact: RequestResponsePact =
ConsumerPactBuilder
.consumer("Consumer")
.hasPactWith("Provider")
.uponReceiving("a request to say Hello")
.path("/hello")
.method("POST")
.body(Foo("abcde"), "application/json")
// ...
Or similarly when using the import pact4s.sprayjson.implicits._
import pact4s.sprayjson.implicits._
final case class Foo(a: String)
implicit object fooFormat extends JsonFormat[Foo] {
override def write(object: Foo): JsValue = ???
override def read(value: JsValue): Foo = ???
}
val pact: RequestResponsePact = {
ConsumerPactBuilder
.consumer("Consumer")
.hasPactWith("Provider")
.uponReceiving("a request to say Hello")
.path("/hello")
.method("POST")
.body(Foo("abcde"), "application/json")
// ...
}
Request/response pacts use the RequestResponsePactForger
trait. This trait requires that you provide a RequestResponsePact
, which will be used to stand up a stub of the provider server. Each interaction in the pact should then run against the stub server using client the consumer application uses to interact with the real provider. This ensures that the client, and thus the application, is compatible with the pact being defined.
An example RequestResponsePactForger
implementation is shown below.
override val pactTestExecutionContext: PactTestExecutionContext = new PactTestExecutionContext(
"./my-sub-project/target/pacts" //this is where the pact file gets written to. It defaults to ./target/pacts (relative to the project base)
)
val pact: RequestResponsePact =
ConsumerPactBuilder
.consumer("Consumer")
.hasPactWith("Provider")
.uponReceiving("a request to say Hello") // this is the description
.path("/hello")
.method("POST")
.body("""{"json": "body"}""", "application/json")
.headers("other-header" -> "howdy")
.willRespondWith()
.status(200)
.body("""{"response": "body"}""")
.toPact()
// The client your application uses to consume from the provider
val client: Client = new Client(mockServer.getUrl)
// Now loop through each interaction in the Pact and verify it.
// This is psuedo-code that will need to be adapted to the testing framework you are using.
interactions.foreach(verify)
def verify(interaction: RequestResponseInteraction): Result = interaction.getDescription match {
case "a request to say Hello" =>
val response = client(interaction.getRequest)
assert(response == interaction.getResponse)
case description =>
throw NoSuchElementException(s"Missing verification for interaction: '$description'.")
}
Upon completion of this test suite (and if all tests have passed) the pact will be written to the file defined in pactTestExecutionContext
. N.B. The pact file will not be written unless the mock server has received a request for every interaction that you have defined in your pact.
Examples:
If your consumer test need that the provider mock server runs on a specific port, you can override mockProviderConfig
from RequestResponsePactForger
like:
// Mock server will run on port 9003
override val mockProviderConfig: MockProviderConfig = MockProviderConfig.httpConfig("localhost", 9003)
Message pacts use the MessagePactForger
trait. This trait requires that you provide a MessagePact
. While the general principles of message forging and verification are the same as with request/response pacts, the guidance here will be a bit more abstract as actual implementations will vary by application and messaging framework. That said, at a high level you will want to generate a message and then feed it to your message handling function, which should expect a concrete class type. You do not want to verify what the message handling function does, only that it can receive the message payload without exception.
An example MessagePactForger
implementation is shown below:
val pact: MessagePact = Pact4sMessagePactBuilder()
.consumer("MessageConsumer")
.hasPactWith("MessageProvider")
.expectsToReceive("A message to say hello")
.withContent(Json.obj("hello" -> "harry".asJson))
.withMetadata(Map("hi" -> "there"))
.toMessagePact
// You will need access to the methods that actually handle your messages, for example on the application.
val application: Application = new Application()
// Now loop through each message and verify it.
// This is psuedo-code that will need to be adapted to the testing framework you are using.
messages.foreach(verify)
def verify(message: Message): Result = message.getDescription match {
case "a request to say hello" =>
// You will probably need to convert the Pact Message into some other format...
val applicationMessage = new ApplicationMessage(message.contentAsBytes, message.metadata)
assertNoException(application.handleMessage(applicationMessage))
case description =>
throw NoSuchElementException(s"Missing verification for message: '$description'.")
}
If your application framework supports it, another option would be to publish the Pact message and have your application consume it without error. Note that this would be testing more than just the Pact itself, though, and may or may not be beneficial to you.
Note that if your project has both request/response and message pacts, you will need to write them into separate pact files due to pact-jvm not currently supporting mixed pacts. Pact JSON files are written in the format <consumer_name>_<provider_name>.json
. Realistically, this means that you will need to choose a different provider name for the message pacts, the request/response pacts, or both. For example, you could use api.provider
as the provider name for the request/response pacts, or message.provider
as the provider name for the message pacts.
This library does not (and won't ever) provide native support for publishing consumer pacts to the pact broker. For this, we recommend using the Pact Broker CLI provided by the pact foundation as part of your CI pipeline.
If you have previously been relying on the scala-pact
sbt plugin to publish pacts to a pact broker, compatability with pacts produced by pact-jvm was added in version 3.3.1. By adding the sbt setting areScalaPactContracts := false
, the scala-pact plugin will be able to publish pacts produced by this library, and any other pact-jvm based consumer pact testing library.
Verification can either be done as part of your CI pipeline, again by using the Pact Broker CLI
, or by writing a verification test within your project. The test modules in pact4s
share the following interface for how pacts are retrieved from either a pact broker, or a file:
override val provider: ProviderInfoBuilder =
ProviderInfoBuilder(
name = "Provider",
protocol = "http",
host = "localhost",
port = 1234,
path = "/",
pactSource = ???,
stateManagement = None,
verificationSettings = None,
requestFilter = _ => None
)
PactSource
is an ADT that provides various different configurations for fetching pacts, either from the local filesystem or from a Pact Broker.
Please note, due to the version of pact-jvm that is underpinning pact4s
, the verification step uses the Pacts For Verification
API in the pact broker. See this issue here for more information: pact-foundation/pact_broker#307. This may not be available in earlier versions of the pact-broker, so make sure you are using the latest release of the broker.
Pacts produced by pact-jvm (and by extension pact4s) by default conform to V3 of the pact specification, which CANNOT be verified by scala-pact
.
Verification of request/response pacts is extremely simple. You will want to extend the trait PactVerifier
and set up a ProviderInfoBuilder
(see above), which determines where the Pact files come from. Then you can verify them against your application by calling verifyPacts
.
verifyPacts(
// In this example, the results of verification are being uploaded to the Pact Broker
publishVerificationResults = Some(
PublishVerificationResults(
// Normally this would be a version supplied by the build system, e.g. the Git commit hash, or a semantic version
// like "1.0.0". See: https://docs.pact.io/getting_started/versioning_in_the_pact_broker
providerVersion = "SNAPSHOT",
// Normally this would be the git branch, e.g. "main" or "master"
// See: https://docs.pact.io/pact_broker/tags/
providerTags = Nil,
//how long each interaction has to run before the test timeouts.
verificationTimeout = Some(30.seconds)
)
)
)
The verifyPacts
method will send requests generated from the pact to your application, and then verify the response it gets back, also against the pact.
It is sometimes necessary to modify the request that pact-jvm generates before it reaches your application. One common use-case for this is the injection of Authorization headers into the requests.
val provider: ProviderInfoBuilder =
ProviderInfoBuilder()
// This will add an Authorization header with a bearer token to every request
.withRequestFilter(request => List(ProviderRequestFilter.SetHeaders("Authorization" -> "bearer <token>")))
See ProviderInfoBuilder for more options.
Verification of message pacts is a little more abstract. You will want to extend the trait MessagePactVerifier
and set up your ProviderInfoBuilder
and verifyPacts
methods just like you would for request/response pacts. You will then need to supply the messages for verification.
def messages: String => MessageAndMetadataBuilder = {
case "A message to say hello" =>
// This is psuedo-code. Normally these data would come from your application implementation.
// For example, if you have a method which generates the message to publish, you could capture that value and
// convert it to a MessageAndMetadataBuilder here.
val metadata = Map("hi" -> "there")
val body = """{"hello":"harry"}"""
MessageAndMetadataBuilder(body, metadata)
case description =>
throw new NoSuchElementException(s"Missing generator for message: '$description'")
}
Some pacts have requirements on the state of the provider. These are defined by the consumer by creating a pact with the given
clause:
val pact: RequestResponsePact =
ConsumerPactBuilder
.consumer("Consumer")
.hasPactWith("Provider")
.`given`("user exists", Map("id" -> "bob")) // provider state id, and parameters (optional)
.uponReceiving(...)
...
During the verification process, pact-jvm
sends the provider state ID and parameters to a given http endpoint, and it is up to the end user to decode the request and manage the state of the provider. We have a domain model pact4s.providerProviderState
representing this state. For users of pact4s
we provide 2 ways of managing state in your verification tests.
You can receive a state-change request to a POST endpoint on your mock provider server (e.g. named "setup", or something similar) that expects a request body of {"state" : "the provider state id string", "params": { "id": "bob" } }
(params
are optional). This endpoint can be configured on the ProviderInfoBuilder
in your suite by setting the field stateChangeUrl
:
val provider: ProviderInfoBuilder =
// alternatively: withStateChangeEndpoint("/setup")
ProviderInfoBuilder().withStateChangeUrl("http://localhost:1234/setup")
Json codecs for the ProviderState
can be found in the json modules in the implicits
objects.
See our internal test setup here for an example of how we handle provider state.
It may not always be possible or desirable to add a endpoint to your provider server for this purpose. We can also simply configure a (maybe partial) function ProviderState => Unit
for managing provider state on the ProviderInfoBuilder
in your suite as follows:
val provider: ProviderInfoBuilder =
ProviderInfoBuilder().withStateChangeFunction {
case ProviderState("state", params) => doSomething()
case _ => ()
}
In this case, under the hood pact4s
creates its own http server with an endpoint that receives the state-change requests from pact-jvm
. By default, this server receieves requests to localhost:64646/pact4s-state-change
. In case this clashes with any other server you are running, the url components can be overriden with ProviderInfoBuilder#withStateChangeFunctionConfigOverrides
.
It is also possible to define a before hook (() => Unit
) that will run at each state change before the state-change function:
val provider: ProviderInfoBuilder =
ProviderInfoBuilder().withStateManagementFunction(
StateManagementFunction {
case ProviderState("state", params) => doSomething()
case _ => ()
}
.withBeforeEach(() => cleanTheState())
)
- For consumer tests (forging pacts), you can enable additional logging from
pact-jvm
with the loggerau.com.dius.pact.consumer
. - For provider tests (verifying pacts), you can enable additional logging from
pact-jvm
with the loggerau.com.dius.pact.provider
. - Additional logging from
pact4s
is given by the loggerio.github.jbwheatley.pact4s.Pact4sLogger
.
Here is an example logback.xml
if you are using logback:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d [%thread] %-5level %logger{35} - %msg%n</pattern>
</encoder>
</appender>
<logger name="io.github.jbwheatley.pact4s.Pact4sLogger" level="INFO" />
<logger name="au.com.dius.pact.consumer" level="DEBUG"/>
<logger name="au.com.dius.pact.provider" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>
Thank you for considering contributing to pact4s
! Before opening a PR, please make sure you have read the style guide, and gone through
the following checklist:
- Scaladocs are included where necessary - e.g. where methods or fields have been added.
- Broken or invalidated methods/fields have had a deprecation tag applied.
- Tests for your new feature or bugfix have been included.
- You've run
sbt scalafmtAll
to format your new code and runsbt headerCreate
to add headers to new files. - You've run
sbt commitCheck
to check formatting, headers, and run all the test suites. - If you're feeling especially generous, open the same PR against the
java8
branch in addition tomain
, so I don't have to backport it myself :)
N.B. sbt commitCheck
takes a while to run because we need to run each test suite in serial as there is networking involved.
You can save some time locally by using sbt quickCommitCheck
which doesn't do any cross-building for different scala versions.