Skip to content

Commit

Permalink
Showing 3 changed files with 565 additions and 38 deletions.
120 changes: 82 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
@@ -34,43 +34,43 @@ including [flexible matching](http://docs.pact.io/documentation/matching.html).

<!-- TOC -->

- [Pact Go](#pact-go)
- [Introduction](#introduction)
- [Table of Contents](#table-of-contents)
- [Installation](#installation)
- [Installation on \*nix](#installation-on-\nix)
- [Using Pact](#using-pact)
- [HTTP API Testing](#http-api-testing)
- [Consumer Side Testing](#consumer-side-testing)
- [Provider API Testing](#provider-api-testing)
- [Provider Verification](#provider-verification)
- [API with Authorization](#api-with-authorization)
- [Publishing pacts to a Pact Broker and Tagging Pacts](#publishing-pacts-to-a-pact-broker-and-tagging-pacts)
- [Publishing from Go code](#publishing-from-go-code)
- [Publishing Provider Verification Results to a Pact Broker](#publishing-provider-verification-results-to-a-pact-broker)
- [Publishing from the CLI](#publishing-from-the-cli)
- [Using the Pact Broker with Basic authentication](#using-the-pact-broker-with-basic-authentication)
- [Asynchronous API Testing](#asynchronous-api-testing)
- [Consumer](#consumer)
- [Provider (Producer)](#provider-producer)
- [Pact Broker Integration](#pact-broker-integration)
- [Matching](#matching)
- [Matching on types](#matching-on-types)
- [Matching on arrays](#matching-on-arrays)
- [Matching by regular expression](#matching-by-regular-expression)
- [Match common formats](#match-common-formats)
- [Examples](#examples)
- [HTTP APIs](#http-apis)
- [Asynchronous APIs](#asynchronous-apis)
- [Integrated examples](#integrated-examples)
- [Troubleshooting](#troubleshooting)
- [Splitting tests across multiple files](#splitting-tests-across-multiple-files)
- [Output Logging](#output-logging)
- [Contact](#contact)
- [Documentation](#documentation)
- [Troubleshooting](#troubleshooting-1)
- [Roadmap](#roadmap)
- [Contributing](#contributing)
* [Introduction](#introduction)
* [Table of Contents](#table-of-contents)
* [Installation](#installation)
* [Installation on \*nix](#installation-on-\nix)
* [Using Pact](#using-pact)
* [HTTP API Testing](#http-api-testing)
* [Consumer Side Testing](#consumer-side-testing)
* [Provider API Testing](#provider-api-testing)
* [Provider Verification](#provider-verification)
* [API with Authorization](#api-with-authorization)
* [Publishing pacts to a Pact Broker and Tagging Pacts](#publishing-pacts-to-a-pact-broker-and-tagging-pacts)
* [Publishing from Go code](#publishing-from-go-code)
* [Publishing Provider Verification Results to a Pact Broker](#publishing-provider-verification-results-to-a-pact-broker)
* [Publishing from the CLI](#publishing-from-the-cli)
* [Using the Pact Broker with Basic authentication](#using-the-pact-broker-with-basic-authentication)
* [Asynchronous API Testing](#asynchronous-api-testing)
* [Consumer](#consumer)
* [Provider (Producer)](#provider-producer)
* [Pact Broker Integration](#pact-broker-integration)
* [Matching](#matching)
* [Matching on types](#matching-on-types)
* [Matching on arrays](#matching-on-arrays)
* [Matching by regular expression](#matching-by-regular-expression)
* [Match common formats](#match-common-formats)
* [Auto-generate matchers from struct tags](#auto-generate-matchers-from-struct-tags)
* [Examples](#examples)
* [HTTP APIs](#http-apis)
* [Asynchronous APIs](#asynchronous-apis)
* [Integrated examples](#integrated-examples)
* [Troubleshooting](#troubleshooting)
* [Splitting tests across multiple files](#splitting-tests-across-multiple-files)
* [Output Logging](#output-logging)
* [Contact](#contact)
* [Documentation](#documentation)
* [Troubleshooting](#troubleshooting-1)
* [Roadmap](#roadmap)
* [Contributing](#contributing)

<!-- /TOC -->

@@ -469,7 +469,7 @@ func TestMessageConsumer_Success(t *testing.T) {
**Explanation**:
1. The API - a contrived API handler example. Expects a User object and throws an `Error` if it can't handle it.
1. The API - a contrived API handler example. Expects a User object and throws an `Error` if it can't handle it.
* In most applications, some form of transactionality exists and communication with a MQ/broker happens.
* It's important we separate out the protocol bits from the message handling bits, so that we can test that in isolation.
1. Creates the MessageConsumer class
@@ -597,6 +597,50 @@ Often times, you find yourself having to re-write regular expressions for common
| `IPv6Address()` | Match string containing IP6 formatted address |
| `UUID()` | Match strings containing UUIDs |
#### Auto-generate matchers from struct tags
Furthermore, if you isolate your Data Transfer Objects (DTOs) to an adapters package so that they exactly reflect the interface between you and your provider, then you can leverage `dsl.Match` to auto-generate the expected response body in your contract tests. Under the hood, `Match` recursively traverses the DTO struct and uses `Term, Like, and EachLike` to create the contract.
This saves the trouble of declaring the contract by hand. It also maintains one source of truth. To change the consumer-provider interface, you only have to update your DTO struct and the contract will automatically follow suit.
_Example:_
```go
type DTO struct {
ID string `json:"id"`
Title string `json:"title"`
Tags []string `json:"tags" pact:"min=2"`
Date string `json:"date" pact:"example=2000-01-01,regex=^\\d{4}-\\d{2}-\\d{2}$"`
}
```

then specifying a response body is as simple as:

```go
// Set up our expected interactions.
pact.
AddInteraction().
Given("User foo exists").
UponReceiving("A request to get foo").
WithRequest(dsl.Request{
Method: "GET",
Path: "/foobar",
Headers: map[string]string{"Content-Type": "application/json"},
}).
WillRespondWith(dsl.Response{
Status: 200,
Headers: map[string]string{"Content-Type": "application/json"},
Body: Match(DTO{}), // That's it!!!
})
```

The `pact` struct tags shown above are optional. By default, dsl.Match just asserts that the JSON shape matches the struct and that the field types match.

See [dsl.Match](https://github.com/pact-foundation/pact-go/blob/master/dsl/matcher.go) for more information.

See the [matcher tests](https://github.com/pact-foundation/pact-go/blob/master/dsl/matcher_test.go)
for more matching examples.

## Examples

### HTTP APIs
110 changes: 110 additions & 0 deletions dsl/matcher.go
Original file line number Diff line number Diff line change
@@ -4,7 +4,9 @@ import (
"encoding/json"
"fmt"
"log"
"reflect"
"strconv"
"strings"
"time"
)

@@ -306,3 +308,111 @@ func objectToString(obj interface{}) string {
return string(jsonString)
}
}

// Match recursively traverses the provided type and outputs a
// matcher string for it that is compatible with the Pact dsl.
// By default, it requires slices to have a minimum of 1 element.
// For concrete types, it uses `dsl.Like` to assert that types match.
// Optionally, you may override these defaults by supplying custom
// pact tags on your structs.
//
// Supported Tag Formats
// Minimum Slice Size: `pact:"min=2"`
// String RegEx: `pact:"example=2000-01-01,regex=^\\d{4}-\\d{2}-\\d{2}$"`
func Match(src interface{}) Matcher {
return match(reflect.TypeOf(src), getDefaults())
}

// match recursively traverses the provided type and outputs a
// matcher string for it that is compatible with the Pact dsl.
func match(srcType reflect.Type, params params) Matcher {
switch kind := srcType.Kind(); kind {
case reflect.Ptr:
return match(srcType.Elem(), params)
case reflect.Slice, reflect.Array:
return EachLike(match(srcType.Elem(), getDefaults()), params.slice.min)
case reflect.Struct:
result := make(map[string]interface{})

for i := 0; i < srcType.NumField(); i++ {
field := srcType.Field(i)
result[field.Tag.Get("json")] = match(field.Type, pluckParams(field.Type, field.Tag.Get("pact")))
}
return result
case reflect.String:
if params.str.regEx != "" {
return Term(params.str.example, params.str.regEx)
}
return Like(`"string"`)
case reflect.Bool:
return Like(true)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return Like(1)
default:
panic(fmt.Sprintf("match: unhandled type: %v", srcType))
}
}

// params are plucked from 'pact' struct tags as match() traverses
// struct fields. They are passed back into match() along with their
// associated type to serve as parameters for the dsl functions.
type params struct {
slice sliceParams
str stringParams
}

type sliceParams struct {
min int
}

type stringParams struct {
example string
regEx string
}

// getDefaults returns the default params
func getDefaults() params {
return params{
slice: sliceParams{
min: 1,
},
}
}

// pluckParams converts a 'pact' tag into a pactParams struct
// Supported Tag Formats
// Minimum Slice Size: `pact:"min=2"`
// String RegEx: `pact:"example=2000-01-01,regex=^\\d{4}-\\d{2}-\\d{2}$"`
func pluckParams(srcType reflect.Type, pactTag string) params {
params := getDefaults()
if pactTag == "" {
return params
}

switch kind := srcType.Kind(); kind {
case reflect.Slice:
if _, err := fmt.Sscanf(pactTag, "min=%d", &params.slice.min); err != nil {
triggerInvalidPactTagPanic(pactTag, err)
}
case reflect.String:
components := strings.Split(pactTag, ",regex=")

if len(components) != 2 {
triggerInvalidPactTagPanic(pactTag, fmt.Errorf("invalid format: unable to split on ',regex='"))
} else if len(components[1]) == 0 {
triggerInvalidPactTagPanic(pactTag, fmt.Errorf("invalid format: regex must not be empty"))
} else if _, err := fmt.Sscanf(components[0], "example=%s", &params.str.example); err != nil {
triggerInvalidPactTagPanic(pactTag, err)
}

params.str.regEx = strings.Replace(components[1], `\`, `\\`, -1)
}

return params
}

func triggerInvalidPactTagPanic(tag string, err error) {
panic(fmt.Sprintf("match: encountered invalid pact tag %q . . . parsing failed with error: %v", tag, err))
}
373 changes: 373 additions & 0 deletions dsl/matcher_test.go
Original file line number Diff line number Diff line change
@@ -524,3 +524,376 @@ func ExampleEachLike() {
// "min": 1
//}
}

func TestMatch(t *testing.T) {
type wordDTO struct {
Word string `json:"word"`
Length int `json:"length"`
}
type dateDTO struct {
Date string `json:"date" pact:"example=2000-01-01,regex=^\\d{4}-\\d{2}-\\d{2}$"`
}
type wordsDTO struct {
Words []string `json:"words" pact:"min=2"`
}
str := "str"
type args struct {
src interface{}
}
tests := []struct {
name string
args args
want Matcher
wantPanic bool
}{
{
name: "recursive case - ptr",
args: args{
src: &str,
},
want: Like(`"string"`),
},
{
name: "recursive case - slice",
args: args{
src: []string{},
},
want: EachLike(Like(`"string"`), 1),
},
{
name: "recursive case - array",
args: args{
src: [1]string{},
},
want: EachLike(Like(`"string"`), 1),
},
{
name: "recursive case - struct",
args: args{
src: wordDTO{},
},
want: map[string]interface{}{
"word": Like(`"string"`),
"length": Like(1),
},
},
{
name: "recursive case - struct with custom string tag",
args: args{
src: dateDTO{},
},
want: map[string]interface{}{
"date": Term("2000-01-01", `^\\d{4}-\\d{2}-\\d{2}$`),
},
},
{
name: "recursive case - struct with custom slice tag",
args: args{
src: wordsDTO{},
},
want: map[string]interface{}{
"words": EachLike(Like(`"string"`), 2),
},
},
{
name: "base case - string",
args: args{
src: "string",
},
want: Like(`"string"`),
},
{
name: "base case - bool",
args: args{
src: true,
},
want: Like(true),
},
{
name: "base case - int",
args: args{
src: 1,
},
want: Like(1),
},
{
name: "base case - int8",
args: args{
src: int8(1),
},
want: Like(1),
},
{
name: "base case - int16",
args: args{
src: int16(1),
},
want: Like(1),
},
{
name: "base case - int32",
args: args{
src: int32(1),
},
want: Like(1),
},
{
name: "base case - int64",
args: args{
src: int64(1),
},
want: Like(1),
},
{
name: "base case - uint",
args: args{
src: uint(1),
},
want: Like(1),
},
{
name: "base case - uint8",
args: args{
src: uint8(1),
},
want: Like(1),
},
{
name: "base case - uint16",
args: args{
src: uint16(1),
},
want: Like(1),
},
{
name: "base case - uint32",
args: args{
src: uint32(1),
},
want: Like(1),
},
{
name: "base case - uint64",
args: args{
src: uint64(1),
},
want: Like(1),
},
{
name: "base case - float32",
args: args{
src: float32(1),
},
want: Like(1),
},
{
name: "base case - float64",
args: args{
src: float64(1),
},
want: Like(1),
},
{
name: "error - unhandled type",
args: args{
src: make(map[string]string),
},
wantPanic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got Matcher
var didPanic bool
defer func() {
if rec := recover(); rec != nil {
fmt.Println(rec)
didPanic = true
}
if tt.wantPanic != didPanic {
t.Errorf("Match() - '%s': didPanic = %v, want %v", tt.name, didPanic, tt.wantPanic)
} else if !didPanic && !reflect.DeepEqual(got, tt.want) {
t.Errorf("Match() - '%s': = %v, want %v", tt.name, got, tt.want)
}
}()

got = Match(tt.args.src)
log.Println("Got matcher: ", got)
})
}
}

func Test_pluckParams(t *testing.T) {
type args struct {
srcType reflect.Type
pactTag string
}
tests := []struct {
name string
args args
want params
wantPanic bool
}{
{
name: "expected use - slice tag",
args: args{
srcType: reflect.TypeOf([]string{}),
pactTag: "min=2",
},
want: params{
slice: sliceParams{
min: 2,
},
str: stringParams{
example: getDefaults().str.example,
regEx: getDefaults().str.regEx,
},
},
},
{
name: "empty slice tag",
args: args{
srcType: reflect.TypeOf([]string{}),
pactTag: "",
},
want: getDefaults(),
},
{
name: "invalid slice tag - no min",
args: args{
srcType: reflect.TypeOf([]string{}),
pactTag: "min=",
},
wantPanic: true,
},
{
name: "invalid slice tag - min typo capital letter",
args: args{
srcType: reflect.TypeOf([]string{}),
pactTag: "Min=2",
},
wantPanic: true,
},
{
name: "invalid slice tag - min typo non-number",
args: args{
srcType: reflect.TypeOf([]string{}),
pactTag: "min=a",
},
wantPanic: true,
},
{
name: "expected use - string tag",
args: args{
srcType: reflect.TypeOf(""),
pactTag: "example=aBcD123,regex=[A-Za-z0-9]",
},
want: params{
slice: sliceParams{
min: getDefaults().slice.min,
},
str: stringParams{
example: "aBcD123",
regEx: "[A-Za-z0-9]",
},
},
},
{
name: "expected use - string tag with backslash",
args: args{
srcType: reflect.TypeOf(""),
pactTag: "example=33,regex=\\d{2}",
},
want: params{
slice: sliceParams{
min: getDefaults().slice.min,
},
str: stringParams{
example: "33",
regEx: `\\d{2}`,
},
},
},
{
name: "empty string tag",
args: args{
srcType: reflect.TypeOf(""),
pactTag: "",
},
want: getDefaults(),
},
{
name: "invalid string tag - no example value",
args: args{
srcType: reflect.TypeOf(""),
pactTag: "example=,regex=[A-Za-z0-9]",
},
wantPanic: true,
},
{
name: "invalid string tag - no example",
args: args{
srcType: reflect.TypeOf(""),
pactTag: "regex=[A-Za-z0-9]",
},
wantPanic: true,
},
{
name: "invalid string tag - example typo",
args: args{
srcType: reflect.TypeOf(""),
pactTag: "exmple=aBcD123,regex=[A-Za-z0-9]",
},
wantPanic: true,
},
{
name: "invalid string tag - no regex value",
args: args{
srcType: reflect.TypeOf(""),
pactTag: "example=aBcD123,regex=",
},
wantPanic: true,
},
{
name: "invalid string tag - no regex",
args: args{
srcType: reflect.TypeOf(""),
pactTag: "example=aBcD123",
},
wantPanic: true,
},
{
name: "invalid string tag - regex typo",
args: args{
srcType: reflect.TypeOf(""),
pactTag: "example=aBcD123,regx=[A-Za-z0-9]",
},
wantPanic: true,
},
{
name: "invalid string tag - space inserted",
args: args{
srcType: reflect.TypeOf(""),
pactTag: "example=aBcD123 regex=[A-Za-z0-9]",
},
wantPanic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got params
var didPanic bool
defer func() {
if rec := recover(); rec != nil {
didPanic = true
}
if tt.wantPanic != didPanic {
t.Errorf("pluckParams() didPanic = %v, want %v", didPanic, tt.wantPanic)
} else if !didPanic && !reflect.DeepEqual(got, tt.want) {
t.Errorf("pluckParams() = %v, want %v", got, tt.want)
}
}()
got = pluckParams(tt.args.srcType, tt.args.pactTag)
})
}
}

0 comments on commit 16a9146

Please sign in to comment.