Skip to content

Commit

Permalink
feat: first draft
Browse files Browse the repository at this point in the history
Signed-off-by: Maxime Soulé <btik-git@scoubidou.com>
  • Loading branch information
maxatome committed Nov 7, 2022
1 parent 5f3448f commit 6256cc6
Show file tree
Hide file tree
Showing 7 changed files with 389 additions and 1 deletion.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
# tdhttpmock
The marriage of go-testdeep and httpmock

The marriage of [go-testdeep](github.com/maxatome/go-testdeep) and
[httpmock](github.com/jarcoal/httpmock).
12 changes: 12 additions & 0 deletions any.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) 2022, Maxime Soulé
// All rights reserved.
//
// This source code is licensed under the BSD-style license found in the
// LICENSE file in the root directory of this source tree.

//go:build !go1.18
// +build !go1.18

package testdeep

type any = interface{}
12 changes: 12 additions & 0 deletions any_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) 2022, Maxime Soulé
// All rights reserved.
//
// This source code is licensed under the BSD-style license found in the
// LICENSE file in the root directory of this source tree.

//go:build !go1.18
// +build !go1.18

package testdeep_test

type any = interface{}
10 changes: 10 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module github.com/maxatome/tdhttpmock

go 1.19

require (
github.com/jarcoal/httpmock v1.2.1-0.20221107220010-e78b81264f6c
github.com/maxatome/go-testdeep v1.12.0
)

require github.com/davecgh/go-spew v1.1.1 // indirect
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jarcoal/httpmock v1.2.1-0.20221022183827-0f002063db7c h1:+mS3pcUxRC8S5pKmr8GAdjIWcturp3jE89oaJkj/kwk=
github.com/jarcoal/httpmock v1.2.1-0.20221022183827-0f002063db7c/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jarcoal/httpmock v1.2.1-0.20221107220010-e78b81264f6c h1:6Qw5Ud4Z7d8XJVF37IMuxIuih2qXON/EsmL0E8ChMGw=
github.com/jarcoal/httpmock v1.2.1-0.20221107220010-e78b81264f6c/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
273 changes: 273 additions & 0 deletions tdhttpmock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
// Copyright (c) 2022, Maxime Soulé
// All rights reserved.
//
// This source code is licensed under the BSD-style license found in the
// LICENSE file in the root directory of this source tree.

package tdhttpmock

import (
"encoding/json"
"encoding/xml"
"fmt"
"io/ioutil" //nolint: staticcheck
"net/http"
"reflect"

"github.com/jarcoal/httpmock"
"github.com/maxatome/go-testdeep/td"
)

var interfaceType = reflect.TypeOf((*any)(nil)).Elem()

func marshaledBody(
acceptEmptyBody bool,
unmarshal func([]byte, any) error,
expectedBody any,
) httpmock.MatcherFunc {
return func(req *http.Request) bool {
body, err := ioutil.ReadAll(req.Body)
if err != nil {
return false
}

if !acceptEmptyBody && len(body) == 0 {
return false
}

var bodyType reflect.Type

// If expectedBody is a TestDeep operator, try to ask it the type
// behind it.
op, ok := expectedBody.(td.TestDeep)
if ok {
bodyType = op.TypeBehind()
if bodyType == nil {
// As the expected body type cannot be guessed, try to
// unmarshal in an any
bodyType = interfaceType
} else {
bodyType = reflect.TypeOf(expectedBody)
if bodyType == nil {
bodyType = interfaceType
}
}
}

bodyPtr := reflect.New(bodyType)

if unmarshal(body, bodyPtr.Interface()) != nil {
return false
}

return td.EqDeeply(bodyPtr.Elem().Interface(), expectedBody)
}
}

// Body returns an [httpmock.Matcher] matching request body against
// expectedBody. expectedBody can be a []byte, a string or a
// [td.TestDeep] operator.
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.Body("OK!\n"),
// httpmock.NewStringResponder(200, "OK"))
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.Body(td.Re(`\d+ test`)),
// httpmock.NewStringResponder(200, "OK test"))
//
// The name of the returned [httpmock.Matcher] is auto-generated (see
// [httpmock.NewMatcher]). To name it explicitely, use
// [httpmock.Matcher.WithName] as in:
//
// tdhttpmock.Body("OK!\n").WithName("01-body-OK")
func Body(expectedBody any) httpmock.Matcher {
return httpmock.NewMatcher("",
marshaledBody(true,
func(body []byte, target any) error {
switch target := target.(type) {
case *string:
*target = string(body)
case *[]byte:
*target = body
case *any:
*target = body
default:
// marshaledBody always calls us with target as a pointer
return fmt.Errorf(
"Body only accepts expectedBody be a []byte, a string or a TestDeep operator allowing to match these types, but not type %s",
reflect.TypeOf(target).Elem())
}
return nil
},
expectedBody))
}

// JSONBody returns an [httpmock.Matcher] expecting a JSON request body
// that can be [json.Unmarshal]'ed and that matches expectedBody.
// expectedBody can be any type one can [json.Unmarshal] into, or a
// [td.TestDeep] operator.
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.JSONBody(Person{
// ID: 42,
// Name: "Bob",
// Age: 26,
// }),
// httpmock.NewStringResponder(200, "OK bob"))
//
// The same using [td.JSON]:
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.JSONBody(td.JSON(`
// {
// "id": NotZero(),
// "name": "Bob",
// "age": 26
// }`)),
// httpmock.NewStringResponder(200, "OK bob"))
//
// Note also the existence of [td.JSONPointer]:
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.JSONBody(td.JSONPointer("/name", "Bob")),
// httpmock.NewStringResponder(200, "OK bob"))
//
// The name of the returned [httpmock.Matcher] is auto-generated (see
// [httpmock.NewMatcher]). To name it explicitely, use
// [httpmock.Matcher.WithName] as in:
//
// tdhttpmock.JSONBody(td.JSONPointer("/name", "Bob")).WithName("01-bob")
func JSONBody(expectedBody any) httpmock.Matcher {
return httpmock.NewMatcher("",
marshaledBody(false, json.Unmarshal, expectedBody))
}

// XMLBody returns an [httpmock.Matcher] expecting an XML request
// body that can be [xml.Unmarshal]'ed and that matches
// expectedBody. expectedBody can be any type one can [xml.Unmarshal]
// into, or a [td.TestDeep] operator.
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.XMLBody(Person{
// ID: 42,
// Name: "Bob",
// Age: 26,
// }),
// httpmock.NewStringResponder(200, "OK bob"))
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.XMLBody(td.SStruct(
// Person{
// Name: "Bob",
// Age: 26,
// },
// td.StructFields{
// "ID": td.NotZero(),
// })),
// httpmock.NewStringResponder(200, "OK bob"))
//
// The name of the returned [httpmock.Matcher] is auto-generated (see
// [httpmock.NewMatcher]). To name it explicitely, use
// [httpmock.Matcher.WithName] as in:
//
// tdhttpmock.XMLBody(td.Struct(Person{Name: "Bob"})).WithName("01-bob")
func XMLBody(expectedBody any) httpmock.Matcher {
return httpmock.NewMatcher("",
marshaledBody(false, xml.Unmarshal, expectedBody))
}

// Header returns an [httpmock.Matcher] matching request header against
// expectedHeader. expectedHeader can be a [http.Header] or a
// [td.TestDeep] operator. Keep in mind that if it is a [http.Header],
// it has to match exactly the response header. Often only the
// presence of a header key is needed:
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.Header(td.ContainsKey("X-Custom")),
// httpmock.NewStringResponder(200, "OK custom"))
//
// or some specific key, value pairs:
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.Header(td.SuperMapOf(
// http.Header{
// "X-Account": []string{"Bob"},
// },
// td.MapEntries{
// "X-Token": td.Bag(td.Re(`^[a-z0-9-]{32}\z`)),
// },
// )),
// httpmock.NewStringResponder(200, "OK account"))
//
// The name of the returned [httpmock.Matcher] is auto-generated (see
// [httpmock.NewMatcher]). To name it explicitely, use
// [httpmock.Matcher.WithName] as in:
//
// tdhttpmock.Header(td.ContainsKey("X-Custom")).WithName("01-header-custom")
func Header(expectedHader any) httpmock.Matcher {
return httpmock.NewMatcher("",
func(req *http.Request) bool {
return td.EqDeeply(req.Header, td.Lax(expectedHader))
})
}

// Cookies returns an [httpmock.Matcher] matching request cookies
// against expectedCookies. expectedCookies can be a [][*http.Cookie]
// or a [td.TestDeep] operator. Keep in mind that if it is a
// [][*http.Cookie], it has to match exactly the response
// cookies. Often only the presence of a cookie key is needed:
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.Cookies(td.SuperBagOf(td.Smuggle("Name", "cookie_session"))),
// httpmock.NewStringResponder(200, "OK session"))
//
// To make tests easier, [http.Cookie.Raw] and [http.Cookie.RawExpires] fields
// of each [*http.Cookie] are zeroed before doing the comparison. So no need
// to fill them when comparing against a simple literal as in:
//
// httpmock.RegisterMatcherResponder(
// http.MethodPost,
// "/test",
// tdhttpmock.Cookies([]*http.Cookies{
// {Name: "cookieName1", Value: "cookieValue1"},
// {Name: "cookieName2", Value: "cookieValue2"},
// }),
// httpmock.NewStringResponder(200, "OK cookies"))
//
// The name of the returned [httpmock.Matcher] is auto-generated (see
// [httpmock.NewMatcher]). To name it explicitely, use
// [httpmock.Matcher.WithName] as in:
//
// tdhttpmock.Cookies([]*http.Cookies{}).WithName("01-cookies")
func Cookies(expectedCookies any) httpmock.Matcher {
return httpmock.NewMatcher("",
func(req *http.Request) bool {
// Empty Raw* fields to make comparisons easier
cookies := req.Cookies()
for _, c := range cookies {
c.RawExpires, c.Raw = "", ""
}
return td.EqDeeply(cookies, td.Lax(expectedCookies))
})
}
71 changes: 71 additions & 0 deletions tdhttpmock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) 2022, Maxime Soulé
// All rights reserved.
//
// This source code is licensed under the BSD-style license found in the
// LICENSE file in the root directory of this source tree.

package tdhttpmock_test

import (
"io"
"net/http"
"strings"
"testing"

"github.com/jarcoal/httpmock"
"github.com/maxatome/go-testdeep/td"

"github.com/maxatome/tdhttpmock"
)

func TestTdhttpmock(t *testing.T) {
httpmock.Activate()
t.Cleanup(httpmock.DeactivateAndReset)

httpmock.RegisterNoResponder(httpmock.NewStringResponder(404, "Not found"))

httpmock.RegisterMatcherResponder(
http.MethodPost,
"/test",
tdhttpmock.Body(td.Re(`\d+ test`)).WithName("20-body"),
httpmock.NewStringResponder(200, "OK-20"),
)

httpmock.RegisterMatcherResponder(
http.MethodPost,
"/test",
tdhttpmock.Body(td.Re(`\d+ test`)).
And(tdhttpmock.Header(td.ContainsKey("X-Custom"))).
WithName("10-body+header"),
httpmock.NewStringResponder(200, "OK-10"),
)

assert := td.Assert(t)

assert.RunAssertRequire("20-body", func(assert, require *td.T) {
resp, err := http.Post("/test", "text/plain", strings.NewReader("42 test"))
require.CmpNoError(err)
assert.Cmp(resp.StatusCode, 200)
assert.Cmp(resp.Body, td.Smuggle(io.ReadAll, td.String("OK-20")))
})

assert.RunAssertRequire("not found", func(assert, require *td.T) {
resp, err := http.Post("/test", "text/plain", strings.NewReader("x test"))
require.CmpNoError(err)
assert.Cmp(resp.StatusCode, 404)
assert.Cmp(resp.Body, td.Smuggle(io.ReadAll, td.String("Not found")))
})

assert.RunAssertRequire("10-body+header", func(assert, require *td.T) {
req, err := http.NewRequest(http.MethodPost, "/test", strings.NewReader("42 test"))
require.CmpNoError(err)

req.Header.Set("Content-Type", "text/plain")
req.Header.Set("X-Custom", "YES")

resp, err := http.DefaultClient.Do(req)
require.CmpNoError(err)
assert.Cmp(resp.StatusCode, 200)
assert.Cmp(resp.Body, td.Smuggle(io.ReadAll, td.String("OK-10")))
})
}

0 comments on commit 6256cc6

Please sign in to comment.