├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── any.go ├── doc.go ├── env.go ├── env_test.go ├── export_test.go ├── file.go ├── file_test.go ├── go.mod ├── go.sum ├── internal ├── error.go ├── error_test.go ├── route_key.go ├── route_key_test.go ├── stack_tracer.go ├── stack_tracer_test.go ├── submatches.go └── submatches_test.go ├── match.go ├── match_test.go ├── race_test.go ├── response.go ├── response_test.go ├── transport.go ├── transport_test.go └── util_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [maxatome] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ v1 ] 6 | pull_request: 7 | branches: [ v1 ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | strategy: 13 | matrix: 14 | go-version: [1.16.x, 1.17.x, 1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x, 1.23.x, tip] 15 | full-tests: [false] 16 | include: 17 | - go-version: 1.24.x 18 | full-tests: true 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Setup go 24 | run: | 25 | curl -sL https://raw.githubusercontent.com/maxatome/install-go/v3.7/install-go.pl | 26 | perl - ${{ matrix.go-version }} $HOME/go 27 | 28 | - name: Checkout code 29 | uses: actions/checkout@v2 30 | 31 | - name: Linting 32 | if: matrix.full-tests 33 | run: | 34 | curl -sL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | 35 | sh -s -- -b $HOME/go/bin v2.0.2 36 | $HOME/go/bin/golangci-lint run 37 | 38 | - name: Testing 39 | continue-on-error: ${{ matrix.go-version == 'tip' }} 40 | run: | 41 | go version 42 | if [ ${{ matrix.full-tests }} = true ]; then 43 | GO_TEST_OPTS="-covermode=atomic -coverprofile=coverage.out" 44 | fi 45 | export GORACE="halt_on_error=1" 46 | go test -race $GO_TEST_OPTS ./... 47 | 48 | - name: Reporting 49 | if: matrix.full-tests 50 | env: 51 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | run: | 53 | go install github.com/mattn/goveralls@v0.0.11 54 | goveralls -coverprofile=coverage.out -service=github 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | issues: 3 | max-issues-per-linter: 0 4 | max-same-issues: 0 5 | linters: 6 | default: none 7 | enable: 8 | - asasalint 9 | - asciicheck 10 | - bidichk 11 | - dupl 12 | - durationcheck 13 | - errcheck 14 | - exhaustive 15 | - gocritic 16 | - godot 17 | - govet 18 | - importas 19 | - ineffassign 20 | - misspell 21 | - prealloc 22 | - revive 23 | - staticcheck 24 | - testableexamples 25 | - unconvert 26 | - unused 27 | - wastedassign 28 | - whitespace 29 | settings: 30 | staticcheck: 31 | checks: 32 | - all 33 | - -ST1012 34 | - -ST1000 35 | revive: 36 | rules: 37 | - name: unused-parameter 38 | disabled: true 39 | formatters: 40 | enable: 41 | - gci 42 | - goimports 43 | settings: 44 | gci: 45 | sections: 46 | - standard 47 | - default 48 | - localmodule 49 | custom-order: true 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Jared Morse 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpmock [![Build Status](https://github.com/jarcoal/httpmock/actions/workflows/ci.yml/badge.svg?branch=v1)](https://github.com/jarcoal/httpmock/actions?query=workflow%3ABuild) [![Coverage Status](https://coveralls.io/repos/github/jarcoal/httpmock/badge.svg?branch=v1)](https://coveralls.io/github/jarcoal/httpmock?branch=v1) [![GoDoc](https://godoc.org/github.com/jarcoal/httpmock?status.svg)](https://godoc.org/github.com/jarcoal/httpmock) [![Version](https://img.shields.io/github/tag/jarcoal/httpmock.svg)](https://github.com/jarcoal/httpmock/releases) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go/#testing) 2 | 3 | Easy mocking of http responses from external resources. 4 | 5 | ## Install 6 | 7 | Currently supports Go 1.16 to 1.24 and is regularly tested against tip. 8 | 9 | `v1` branch has to be used instead of `master`. 10 | 11 | In your go files, simply use: 12 | ```go 13 | import "github.com/jarcoal/httpmock" 14 | ``` 15 | 16 | Then next `go mod tidy` or `go test` invocation will automatically 17 | populate your `go.mod` with the latest httpmock release, now 18 | [![Version](https://img.shields.io/github/tag/jarcoal/httpmock.svg)](https://github.com/jarcoal/httpmock/releases). 19 | 20 | 21 | ## Usage 22 | 23 | ### Simple Example: 24 | ```go 25 | func TestFetchArticles(t *testing.T) { 26 | httpmock.Activate(t) 27 | 28 | // Exact URL match 29 | httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles", 30 | httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Article"}]`)) 31 | 32 | // Regexp match (could use httpmock.RegisterRegexpResponder instead) 33 | httpmock.RegisterResponder("GET", `=~^https://api\.mybiz\.com/articles/id/\d+\z`, 34 | httpmock.NewStringResponder(200, `{"id": 1, "name": "My Great Article"}`)) 35 | 36 | // do stuff that makes a request to articles 37 | ... 38 | 39 | // get count info 40 | httpmock.GetTotalCallCount() 41 | 42 | // get the amount of calls for the registered responder 43 | info := httpmock.GetCallCountInfo() 44 | info["GET https://api.mybiz.com/articles"] // number of GET calls made to https://api.mybiz.com/articles 45 | info["GET https://api.mybiz.com/articles/id/12"] // number of GET calls made to https://api.mybiz.com/articles/id/12 46 | info[`GET =~^https://api\.mybiz\.com/articles/id/\d+\z`] // number of GET calls made to https://api.mybiz.com/articles/id/ 47 | } 48 | ``` 49 | 50 | ### Advanced Example: 51 | ```go 52 | func TestFetchArticles(t *testing.T) { 53 | httpmock.Activate(t) 54 | 55 | // our database of articles 56 | articles := make([]map[string]interface{}, 0) 57 | 58 | // mock to list out the articles 59 | httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles", 60 | func(req *http.Request) (*http.Response, error) { 61 | resp, err := httpmock.NewJsonResponse(200, articles) 62 | if err != nil { 63 | return httpmock.NewStringResponse(500, ""), nil 64 | } 65 | return resp, nil 66 | }) 67 | 68 | // return an article related to the request with the help of regexp submatch (\d+) 69 | httpmock.RegisterResponder("GET", `=~^https://api\.mybiz\.com/articles/id/(\d+)\z`, 70 | func(req *http.Request) (*http.Response, error) { 71 | // Get ID from request 72 | id := httpmock.MustGetSubmatchAsUint(req, 1) // 1=first regexp submatch 73 | return httpmock.NewJsonResponse(200, map[string]interface{}{ 74 | "id": id, 75 | "name": "My Great Article", 76 | }) 77 | }) 78 | 79 | // mock to add a new article 80 | httpmock.RegisterResponder("POST", "https://api.mybiz.com/articles", 81 | func(req *http.Request) (*http.Response, error) { 82 | article := make(map[string]interface{}) 83 | if err := json.NewDecoder(req.Body).Decode(&article); err != nil { 84 | return httpmock.NewStringResponse(400, ""), nil 85 | } 86 | 87 | articles = append(articles, article) 88 | 89 | resp, err := httpmock.NewJsonResponse(200, article) 90 | if err != nil { 91 | return httpmock.NewStringResponse(500, ""), nil 92 | } 93 | return resp, nil 94 | }) 95 | 96 | // mock to add a specific article, send a Bad Request response 97 | // when the request body contains `"type":"toy"` 98 | httpmock.RegisterMatcherResponder("POST", "https://api.mybiz.com/articles", 99 | httpmock.BodyContainsString(`"type":"toy"`), 100 | httpmock.NewStringResponder(400, `{"reason":"Invalid article type"}`)) 101 | 102 | // do stuff that adds and checks articles 103 | } 104 | ``` 105 | 106 | ### Algorithm 107 | 108 | When `GET http://example.tld/some/path?b=12&a=foo&a=bar` request is 109 | caught, all standard responders are checked against the following URL 110 | or paths, the first match stops the search: 111 | 112 | 1. `http://example.tld/some/path?b=12&a=foo&a=bar` (original URL) 113 | 1. `http://example.tld/some/path?a=bar&a=foo&b=12` (sorted query params) 114 | 1. `http://example.tld/some/path` (without query params) 115 | 1. `/some/path?b=12&a=foo&a=bar` (original URL without scheme and host) 116 | 1. `/some/path?a=bar&a=foo&b=12` (same, but sorted query params) 117 | 1. `/some/path` (path only) 118 | 119 | If no standard responder matched, the regexp responders are checked, 120 | in the same order, the first match stops the search. 121 | 122 | 123 | ### [go-testdeep](https://go-testdeep.zetta.rocks/) + [tdsuite](https://pkg.go.dev/github.com/maxatome/go-testdeep/helpers/tdsuite) example: 124 | ```go 125 | // article_test.go 126 | 127 | import ( 128 | "testing" 129 | 130 | "github.com/jarcoal/httpmock" 131 | "github.com/maxatome/go-testdeep/helpers/tdsuite" 132 | "github.com/maxatome/go-testdeep/td" 133 | ) 134 | 135 | type MySuite struct{} 136 | 137 | func (s *MySuite) Setup(t *td.T) error { 138 | // block all HTTP requests 139 | httpmock.Activate(t) 140 | return nil 141 | } 142 | 143 | func (s *MySuite) PostTest(t *td.T, testName string) error { 144 | // remove any mocks after each test 145 | httpmock.Reset() 146 | return nil 147 | } 148 | 149 | func TestMySuite(t *testing.T) { 150 | tdsuite.Run(t, &MySuite{}) 151 | } 152 | 153 | func (s *MySuite) TestArticles(assert, require *td.T) { 154 | httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles.json", 155 | httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Article"}]`)) 156 | 157 | // do stuff that makes a request to articles.json 158 | } 159 | ``` 160 | 161 | 162 | ### [Ginkgo](https://onsi.github.io/ginkgo/) example: 163 | ```go 164 | // article_suite_test.go 165 | 166 | import ( 167 | // ... 168 | "github.com/jarcoal/httpmock" 169 | ) 170 | // ... 171 | var _ = BeforeSuite(func() { 172 | // block all HTTP requests 173 | httpmock.Activate() 174 | }) 175 | 176 | var _ = BeforeEach(func() { 177 | // remove any mocks 178 | httpmock.Reset() 179 | }) 180 | 181 | var _ = AfterSuite(func() { 182 | httpmock.DeactivateAndReset() 183 | }) 184 | 185 | 186 | // article_test.go 187 | 188 | import ( 189 | // ... 190 | "github.com/jarcoal/httpmock" 191 | ) 192 | 193 | var _ = Describe("Articles", func() { 194 | It("returns a list of articles", func() { 195 | httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles.json", 196 | httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Article"}]`)) 197 | 198 | // do stuff that makes a request to articles.json 199 | }) 200 | }) 201 | ``` 202 | 203 | ### [Ginkgo](https://onsi.github.io/ginkgo/) + [Resty](https://github.com/go-resty/resty) Example: 204 | ```go 205 | // article_suite_test.go 206 | 207 | import ( 208 | // ... 209 | "github.com/jarcoal/httpmock" 210 | "github.com/go-resty/resty/v2" 211 | ) 212 | // ... 213 | 214 | // global client (using resty.New() creates a new transport each time, 215 | // so you need to use the same one here and when making the request) 216 | var client = resty.New() 217 | 218 | var _ = BeforeSuite(func() { 219 | // block all HTTP requests 220 | httpmock.ActivateNonDefault(client.GetClient()) 221 | }) 222 | 223 | var _ = BeforeEach(func() { 224 | // remove any mocks 225 | httpmock.Reset() 226 | }) 227 | 228 | var _ = AfterSuite(func() { 229 | httpmock.DeactivateAndReset() 230 | }) 231 | 232 | 233 | // article_test.go 234 | 235 | import ( 236 | // ... 237 | "github.com/jarcoal/httpmock" 238 | ) 239 | 240 | type Article struct { 241 | Status struct { 242 | Message string `json:"message"` 243 | Code int `json:"code"` 244 | } `json:"status"` 245 | } 246 | 247 | var _ = Describe("Articles", func() { 248 | It("returns a list of articles", func() { 249 | fixture := `{"status":{"message": "Your message", "code": 200}}` 250 | // have to use NewJsonResponder to get an application/json content-type 251 | // alternatively, create a go object instead of using json.RawMessage 252 | responder, _ := httpmock.NewJsonResponder(200, json.RawMessage(`{"status":{"message": "Your message", "code": 200}}`) 253 | fakeUrl := "https://api.mybiz.com/articles.json" 254 | httpmock.RegisterResponder("GET", fakeUrl, responder) 255 | 256 | // fetch the article into struct 257 | articleObject := &Article{} 258 | _, err := resty.R().SetResult(articleObject).Get(fakeUrl) 259 | 260 | // do stuff with the article object ... 261 | }) 262 | }) 263 | ``` 264 | -------------------------------------------------------------------------------- /any.go: -------------------------------------------------------------------------------- 1 | //go:build !go1.18 2 | // +build !go1.18 3 | 4 | package httpmock 5 | 6 | type any = interface{} 7 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package httpmock provides tools for mocking HTTP responses. 3 | 4 | Simple Example: 5 | 6 | func TestFetchArticles(t *testing.T) { 7 | httpmock.Activate() 8 | defer httpmock.DeactivateAndReset() 9 | 10 | // Exact URL match 11 | httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles", 12 | httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Article"}]`)) 13 | 14 | // Regexp match (could use httpmock.RegisterRegexpResponder instead) 15 | httpmock.RegisterResponder("GET", `=~^https://api\.mybiz\.com/articles/id/\d+\z`, 16 | httpmock.NewStringResponder(200, `{"id": 1, "name": "My Great Article"}`)) 17 | 18 | // do stuff that makes a request to articles 19 | 20 | // get count info 21 | httpmock.GetTotalCallCount() 22 | 23 | // get the amount of calls for the registered responder 24 | info := httpmock.GetCallCountInfo() 25 | info["GET https://api.mybiz.com/articles"] // number of GET calls made to https://api.mybiz.com/articles 26 | info["GET https://api.mybiz.com/articles/id/12"] // number of GET calls made to https://api.mybiz.com/articles/id/12 27 | info[`GET =~^https://api\.mybiz\.com/articles/id/\d+\z`] // number of GET calls made to https://api.mybiz.com/articles/id/ 28 | } 29 | 30 | Advanced Example: 31 | 32 | func TestFetchArticles(t *testing.T) { 33 | httpmock.Activate() 34 | defer httpmock.DeactivateAndReset() 35 | 36 | // our database of articles 37 | articles := make([]map[string]any, 0) 38 | 39 | // mock to list out the articles 40 | httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles", 41 | func(req *http.Request) (*http.Response, error) { 42 | resp, err := httpmock.NewJsonResponse(200, articles) 43 | if err != nil { 44 | return httpmock.NewStringResponse(500, ""), nil 45 | } 46 | return resp, nil 47 | }, 48 | ) 49 | 50 | // return an article related to the request with the help of regexp submatch (\d+) 51 | httpmock.RegisterResponder("GET", `=~^https://api\.mybiz\.com/articles/id/(\d+)\z`, 52 | func(req *http.Request) (*http.Response, error) { 53 | // Get ID from request 54 | id := httpmock.MustGetSubmatchAsUint(req, 1) // 1=first regexp submatch 55 | return httpmock.NewJsonResponse(200, map[string]any{ 56 | "id": id, 57 | "name": "My Great Article", 58 | }) 59 | }, 60 | ) 61 | 62 | // mock to add a new article 63 | httpmock.RegisterResponder("POST", "https://api.mybiz.com/articles", 64 | func(req *http.Request) (*http.Response, error) { 65 | article := make(map[string]any) 66 | if err := json.NewDecoder(req.Body).Decode(&article); err != nil { 67 | return httpmock.NewStringResponse(400, ""), nil 68 | } 69 | 70 | articles = append(articles, article) 71 | 72 | resp, err := httpmock.NewJsonResponse(200, article) 73 | if err != nil { 74 | return httpmock.NewStringResponse(500, ""), nil 75 | } 76 | return resp, nil 77 | }, 78 | ) 79 | 80 | // do stuff that adds and checks articles 81 | } 82 | */ 83 | package httpmock 84 | -------------------------------------------------------------------------------- /env.go: -------------------------------------------------------------------------------- 1 | package httpmock 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | var envVarName = "GONOMOCKS" 8 | 9 | // Disabled allows to test whether httpmock is enabled or not. It 10 | // depends on GONOMOCKS environment variable. 11 | func Disabled() bool { 12 | return os.Getenv(envVarName) != "" 13 | } 14 | -------------------------------------------------------------------------------- /env_test.go: -------------------------------------------------------------------------------- 1 | package httpmock_test 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "testing" 7 | 8 | "github.com/maxatome/go-testdeep/td" 9 | 10 | "github.com/jarcoal/httpmock" 11 | ) 12 | 13 | const envVarName = "GONOMOCKS" 14 | 15 | func TestEnv(t *testing.T) { 16 | require := td.Require(t) 17 | 18 | httpmock.DeactivateAndReset() 19 | 20 | defer func(orig string) { 21 | require.CmpNoError(os.Setenv(envVarName, orig)) 22 | }(os.Getenv(envVarName)) 23 | 24 | // put it in an enabled state 25 | require.CmpNoError(os.Setenv(envVarName, "")) 26 | require.False(httpmock.Disabled(), "expected not to be disabled") 27 | 28 | client1 := &http.Client{Transport: &http.Transport{}} 29 | client2 := &http.Client{Transport: &http.Transport{}} 30 | 31 | // make sure an activation works 32 | httpmock.Activate() 33 | httpmock.ActivateNonDefault(client1) 34 | httpmock.ActivateNonDefault(client2) 35 | require.Cmp(http.DefaultTransport, httpmock.DefaultTransport, 36 | "expected http.DefaultTransport to be our DefaultTransport") 37 | require.Cmp(client1.Transport, httpmock.DefaultTransport, 38 | "expected client1.Transport to be our DefaultTransport") 39 | require.Cmp(client2.Transport, httpmock.DefaultTransport, 40 | "expected client2.Transport to be our DefaultTransport") 41 | httpmock.Deactivate() 42 | 43 | require.CmpNoError(os.Setenv(envVarName, "1")) 44 | require.True(httpmock.Disabled(), "expected to be disabled") 45 | 46 | // make sure activation doesn't work 47 | httpmock.Activate() 48 | httpmock.ActivateNonDefault(client1) 49 | httpmock.ActivateNonDefault(client2) 50 | require.Not(http.DefaultTransport, httpmock.DefaultTransport, 51 | "expected http.DefaultTransport to not be our DefaultTransport") 52 | require.Not(client1.Transport, httpmock.DefaultTransport, 53 | "expected client1.Transport to not be our DefaultTransport") 54 | require.Not(client2.Transport, httpmock.DefaultTransport, 55 | "expected client2.Transport to not be our DefaultTransport") 56 | httpmock.DeactivateNonDefault(client1) 57 | httpmock.Deactivate() 58 | } 59 | -------------------------------------------------------------------------------- /export_test.go: -------------------------------------------------------------------------------- 1 | package httpmock 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "reflect" 7 | "sync/atomic" 8 | 9 | "github.com/jarcoal/httpmock/internal" 10 | ) 11 | 12 | var ( 13 | GetPackage = getPackage 14 | ExtractPackage = extractPackage 15 | CalledFrom = calledFrom 16 | ) 17 | 18 | type ( 19 | MatchResponder = matchResponder 20 | MatchResponders = matchResponders 21 | ) 22 | 23 | func init() { 24 | atomic.AddInt64(&matcherID, 0xabcdef) 25 | } 26 | 27 | func GetIgnorePackages() map[string]bool { 28 | return ignorePackages 29 | } 30 | 31 | // bodyCopyOnRead 32 | 33 | func NewBodyCopyOnRead(body io.ReadCloser) *bodyCopyOnRead { //nolint: revive 34 | return &bodyCopyOnRead{body: body} 35 | } 36 | 37 | func (b *bodyCopyOnRead) Body() io.ReadCloser { 38 | return b.body 39 | } 40 | 41 | func (b *bodyCopyOnRead) Rearm() { 42 | b.rearm() 43 | } 44 | 45 | // matchRouteKey 46 | 47 | func NewMatchRouteKey(rk internal.RouteKey, name string) matchRouteKey { //nolint: revive 48 | return matchRouteKey{RouteKey: rk, name: name} 49 | } 50 | 51 | // matchResponder 52 | 53 | func NewMatchResponder(matcher Matcher, resp Responder) matchResponder { //nolint: revive 54 | return matchResponder{matcher: matcher, responder: resp} 55 | } 56 | 57 | func (mr matchResponder) ResponderPointer() uintptr { 58 | return reflect.ValueOf(mr.responder).Pointer() 59 | } 60 | 61 | func (mr matchResponder) Matcher() Matcher { 62 | return mr.matcher 63 | } 64 | 65 | // matchResponders 66 | 67 | func (mrs matchResponders) Add(mr matchResponder) matchResponders { 68 | return mrs.add(mr) 69 | } 70 | 71 | func (mrs matchResponders) Remove(name string) matchResponders { 72 | return mrs.remove(name) 73 | } 74 | 75 | func (mrs matchResponders) FindMatchResponder(req *http.Request) *matchResponder { 76 | return mrs.findMatchResponder(req) 77 | } 78 | 79 | // Matcher 80 | 81 | func (m Matcher) FnPointer() uintptr { 82 | return reflect.ValueOf(m.fn).Pointer() 83 | } 84 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | package httpmock 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // File is a file name. The contents of this file is loaded on demand 9 | // by the following methods. 10 | // 11 | // Note that: 12 | // 13 | // file := httpmock.File("file.txt") 14 | // fmt.Printf("file: %s\n", file) 15 | // 16 | // prints the content of file "file.txt" as [File.String] method is used. 17 | // 18 | // To print the file name, and not its content, simply do: 19 | // 20 | // file := httpmock.File("file.txt") 21 | // fmt.Printf("file: %s\n", string(file)) 22 | type File string 23 | 24 | // MarshalJSON implements [encoding/json.Marshaler]. 25 | // 26 | // Useful to be used in conjunction with [NewJsonResponse] or 27 | // [NewJsonResponder] as in: 28 | // 29 | // httpmock.NewJsonResponder(200, httpmock.File("body.json")) 30 | func (f File) MarshalJSON() ([]byte, error) { 31 | return f.bytes() 32 | } 33 | 34 | func (f File) bytes() ([]byte, error) { 35 | return os.ReadFile(string(f)) 36 | } 37 | 38 | // Bytes returns the content of file as a []byte. If an error occurs 39 | // during the opening or reading of the file, it panics. 40 | // 41 | // Useful to be used in conjunction with [NewBytesResponse] or 42 | // [NewBytesResponder] as in: 43 | // 44 | // httpmock.NewBytesResponder(200, httpmock.File("body.raw").Bytes()) 45 | func (f File) Bytes() []byte { 46 | b, err := f.bytes() 47 | if err != nil { 48 | panic(fmt.Sprintf("Cannot read %s: %s", string(f), err)) 49 | } 50 | return b 51 | } 52 | 53 | // String implements [fmt.Stringer] and returns the content of file as 54 | // a string. If an error occurs during the opening or reading of the 55 | // file, it panics. 56 | // 57 | // Useful to be used in conjunction with [NewStringResponse] or 58 | // [NewStringResponder] as in: 59 | // 60 | // httpmock.NewStringResponder(200, httpmock.File("body.txt").String()) 61 | func (f File) String() string { 62 | return string(f.Bytes()) 63 | } 64 | -------------------------------------------------------------------------------- /file_test.go: -------------------------------------------------------------------------------- 1 | package httpmock_test 2 | 3 | import ( 4 | "encoding/json" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/maxatome/go-testdeep/td" 9 | 10 | "github.com/jarcoal/httpmock" 11 | ) 12 | 13 | var _ json.Marshaler = httpmock.File("test.json") 14 | 15 | func TestFile(t *testing.T) { 16 | assert := td.Assert(t) 17 | 18 | dir := assert.TempDir() 19 | 20 | assert.Run("Valid JSON file", func(assert *td.T) { 21 | okFile := filepath.Join(dir, "ok.json") 22 | writeFile(assert, okFile, []byte(`{ "test": true }`)) 23 | 24 | encoded, err := json.Marshal(httpmock.File(okFile)) 25 | if !assert.CmpNoError(err, "json.Marshal(%s)", okFile) { 26 | return 27 | } 28 | assert.String(encoded, `{"test":true}`) 29 | }) 30 | 31 | assert.Run("Nonexistent JSON file", func(assert *td.T) { 32 | nonexistentFile := filepath.Join(dir, "nonexistent.json") 33 | _, err := json.Marshal(httpmock.File(nonexistentFile)) 34 | assert.CmpError(err, "json.Marshal(%s), error expected", nonexistentFile) 35 | }) 36 | 37 | assert.Run("Invalid JSON file", func(assert *td.T) { 38 | badFile := filepath.Join(dir, "bad.json") 39 | writeFile(assert, badFile, []byte(`[123`)) 40 | 41 | _, err := json.Marshal(httpmock.File(badFile)) 42 | assert.CmpError(err, "json.Marshal(%s), error expected", badFile) 43 | }) 44 | 45 | assert.Run("Bytes", func(assert *td.T) { 46 | file := filepath.Join(dir, "ok.raw") 47 | content := []byte(`abc123`) 48 | writeFile(assert, file, content) 49 | 50 | assert.Cmp(httpmock.File(file).Bytes(), content) 51 | }) 52 | 53 | assert.Run("Bytes panic", func(assert *td.T) { 54 | nonexistentFile := filepath.Join(dir, "nonexistent.raw") 55 | assert.CmpPanic(func() { httpmock.File(nonexistentFile).Bytes() }, 56 | td.HasPrefix("Cannot read "+nonexistentFile)) 57 | }) 58 | 59 | assert.Run("String", func(assert *td.T) { 60 | file := filepath.Join(dir, "ok.txt") 61 | content := `abc123` 62 | writeFile(assert, file, []byte(content)) 63 | 64 | assert.Cmp(httpmock.File(file).String(), content) 65 | }) 66 | 67 | assert.Run("String panic", func(assert *td.T) { 68 | nonexistentFile := filepath.Join(dir, "nonexistent.txt") 69 | assert.CmpPanic( 70 | func() { 71 | httpmock.File(nonexistentFile).String() //nolint: govet 72 | }, 73 | td.HasPrefix("Cannot read "+nonexistentFile)) 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jarcoal/httpmock 2 | 3 | go 1.18 4 | 5 | require github.com/maxatome/go-testdeep v1.14.0 6 | 7 | require github.com/davecgh/go-spew v1.1.1 // indirect 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI= 4 | github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= 5 | -------------------------------------------------------------------------------- /internal/error.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // NoResponderFound is returned when no responders are found for a 9 | // given HTTP method and URL. 10 | var NoResponderFound = errors.New("no responder found") //nolint: revive 11 | 12 | // ErrorNoResponderFoundMistake encapsulates a NoResponderFound 13 | // error probably due to a user error on the method or URL path. 14 | type ErrorNoResponderFoundMistake struct { 15 | Kind string // "method", "URL" or "matcher" 16 | Orig string // original wrong method/URL, without any matching responder 17 | Suggested string // suggested method/URL with a matching responder 18 | } 19 | 20 | var _ error = (*ErrorNoResponderFoundMistake)(nil) 21 | 22 | // Unwrap implements the interface needed by errors.Unwrap. 23 | func (e *ErrorNoResponderFoundMistake) Unwrap() error { 24 | return NoResponderFound 25 | } 26 | 27 | // Error implements error interface. 28 | func (e *ErrorNoResponderFoundMistake) Error() string { 29 | if e.Kind == "matcher" { 30 | return fmt.Sprintf("%s despite %s", 31 | NoResponderFound, 32 | e.Suggested, 33 | ) 34 | } 35 | return fmt.Sprintf("%[1]s for %[2]s %[3]q, but one matches %[2]s %[4]q", 36 | NoResponderFound, 37 | e.Kind, 38 | e.Orig, 39 | e.Suggested, 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /internal/error_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/maxatome/go-testdeep/td" 7 | 8 | "github.com/jarcoal/httpmock/internal" 9 | ) 10 | 11 | func TestErrorNoResponderFoundMistake(t *testing.T) { 12 | e := &internal.ErrorNoResponderFoundMistake{ 13 | Kind: "method", 14 | Orig: "pipo", 15 | Suggested: "BINGO", 16 | } 17 | td.Cmp(t, e.Error(), `no responder found for method "pipo", but one matches method "BINGO"`) 18 | td.Cmp(t, e.Unwrap(), internal.NoResponderFound) 19 | 20 | e = &internal.ErrorNoResponderFoundMistake{ 21 | Kind: "matcher", 22 | Orig: "--not--used--", 23 | Suggested: "BINGO", 24 | } 25 | td.Cmp(t, e.Error(), `no responder found despite BINGO`) 26 | td.Cmp(t, e.Unwrap(), internal.NoResponderFound) 27 | } 28 | -------------------------------------------------------------------------------- /internal/route_key.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | type RouteKey struct { 4 | Method string 5 | URL string 6 | } 7 | 8 | var NoResponder RouteKey 9 | 10 | func (r RouteKey) String() string { 11 | if r == NoResponder { 12 | return "NO_RESPONDER" 13 | } 14 | return r.Method + " " + r.URL 15 | } 16 | -------------------------------------------------------------------------------- /internal/route_key_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/maxatome/go-testdeep/td" 7 | 8 | "github.com/jarcoal/httpmock/internal" 9 | ) 10 | 11 | func TestRouteKey(t *testing.T) { 12 | td.Cmp(t, internal.NoResponder.String(), "NO_RESPONDER") 13 | 14 | td.Cmp(t, internal.RouteKey{Method: "GET", URL: "/foo"}.String(), "GET /foo") 15 | } 16 | -------------------------------------------------------------------------------- /internal/stack_tracer.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "runtime" 8 | "strings" 9 | ) 10 | 11 | type StackTracer struct { 12 | CustomFn func(...interface{}) 13 | Err error 14 | } 15 | 16 | // Error implements error interface. 17 | func (s StackTracer) Error() string { 18 | if s.Err == nil { 19 | return "" 20 | } 21 | return s.Err.Error() 22 | } 23 | 24 | // Unwrap implements the interface needed by errors.Unwrap. 25 | func (s StackTracer) Unwrap() error { 26 | return s.Err 27 | } 28 | 29 | // CheckStackTracer checks for specific error returned by 30 | // NewNotFoundResponder function or Trace Responder method. 31 | func CheckStackTracer(req *http.Request, err error) error { 32 | if nf, ok := err.(StackTracer); ok { 33 | if nf.CustomFn != nil { 34 | pc := make([]uintptr, 128) 35 | npc := runtime.Callers(2, pc) 36 | pc = pc[:npc] 37 | 38 | var mesg bytes.Buffer 39 | var netHTTPBegin, netHTTPEnd bool 40 | 41 | // Start recording at first net/http call if any... 42 | for { 43 | frames := runtime.CallersFrames(pc) 44 | 45 | var lastFn string 46 | for { 47 | frame, more := frames.Next() 48 | 49 | if !netHTTPEnd { 50 | if netHTTPBegin { 51 | netHTTPEnd = !strings.HasPrefix(frame.Function, "net/http.") 52 | } else { 53 | netHTTPBegin = strings.HasPrefix(frame.Function, "net/http.") 54 | } 55 | } 56 | 57 | if netHTTPEnd { 58 | if lastFn != "" { 59 | if mesg.Len() == 0 { 60 | if nf.Err != nil { 61 | mesg.WriteString(nf.Err.Error()) 62 | } else { 63 | fmt.Fprintf(&mesg, "%s %s", req.Method, req.URL) 64 | } 65 | mesg.WriteString("\nCalled from ") 66 | } else { 67 | mesg.WriteString("\n ") 68 | } 69 | fmt.Fprintf(&mesg, "%s()\n at %s:%d", lastFn, frame.File, frame.Line) 70 | } 71 | } 72 | lastFn = frame.Function 73 | 74 | if !more { 75 | break 76 | } 77 | } 78 | 79 | // At least one net/http frame found 80 | if mesg.Len() > 0 { 81 | break 82 | } 83 | netHTTPEnd = true // retry without looking at net/http frames 84 | } 85 | 86 | nf.CustomFn(mesg.String()) 87 | } 88 | err = nf.Err 89 | } 90 | return err 91 | } 92 | -------------------------------------------------------------------------------- /internal/stack_tracer_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/maxatome/go-testdeep/td" 9 | 10 | "github.com/jarcoal/httpmock/internal" 11 | ) 12 | 13 | func TestStackTracer(t *testing.T) { 14 | st := internal.StackTracer{} 15 | td.CmpEmpty(t, st.Error()) 16 | 17 | st = internal.StackTracer{ 18 | Err: errors.New("foo"), 19 | } 20 | td.Cmp(t, st.Error(), "foo") 21 | 22 | td.Cmp(t, st.Unwrap(), st.Err) 23 | } 24 | 25 | func TestCheckStackTracer(t *testing.T) { 26 | req, err := http.NewRequest("GET", "http://foo.bar/", nil) 27 | td.Require(t).CmpNoError(err) 28 | 29 | // no error 30 | td.CmpNoError(t, internal.CheckStackTracer(req, nil)) 31 | 32 | // Classic error 33 | err = errors.New("error") 34 | td.Cmp(t, internal.CheckStackTracer(req, err), err) 35 | 36 | // stackTracer without customFn 37 | origErr := errors.New("foo") 38 | errTracer := internal.StackTracer{ 39 | Err: origErr, 40 | } 41 | td.Cmp(t, internal.CheckStackTracer(req, errTracer), origErr) 42 | 43 | // stackTracer with nil error & without customFn 44 | errTracer = internal.StackTracer{} 45 | td.CmpNoError(t, internal.CheckStackTracer(req, errTracer)) 46 | 47 | // stackTracer 48 | var mesg string 49 | errTracer = internal.StackTracer{ 50 | Err: origErr, 51 | CustomFn: func(args ...interface{}) { 52 | mesg = args[0].(string) 53 | }, 54 | } 55 | gotErr := internal.CheckStackTracer(req, errTracer) 56 | td.Cmp(t, mesg, td.Re(`(?s)^foo\nCalled from .*[^\n]\z`)) 57 | td.Cmp(t, gotErr, origErr) 58 | 59 | // stackTracer with nil error but customFn 60 | mesg = "" 61 | errTracer = internal.StackTracer{ 62 | CustomFn: func(args ...interface{}) { 63 | mesg = args[0].(string) 64 | }, 65 | } 66 | gotErr = internal.CheckStackTracer(req, errTracer) 67 | td.Cmp(t, mesg, td.Re(`(?s)^GET http://foo\.bar/\nCalled from .*[^\n]\z`)) 68 | td.CmpNoError(t, gotErr) 69 | } 70 | -------------------------------------------------------------------------------- /internal/submatches.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type submatchesKeyType struct{} 9 | 10 | var submatchesKey submatchesKeyType 11 | 12 | func SetSubmatches(req *http.Request, submatches []string) *http.Request { 13 | if len(submatches) > 0 { 14 | return req.WithContext(context.WithValue(req.Context(), submatchesKey, submatches)) 15 | } 16 | return req 17 | } 18 | 19 | func GetSubmatches(req *http.Request) []string { 20 | sm, _ := req.Context().Value(submatchesKey).([]string) 21 | return sm 22 | } 23 | -------------------------------------------------------------------------------- /internal/submatches_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/maxatome/go-testdeep/td" 8 | 9 | "github.com/jarcoal/httpmock/internal" 10 | ) 11 | 12 | func TestSubmatches(t *testing.T) { 13 | req, err := http.NewRequest("GET", "/foo/bar", nil) 14 | td.Require(t).CmpNoError(err) 15 | 16 | var req2 *http.Request 17 | 18 | req2 = internal.SetSubmatches(req, nil) 19 | td.CmpShallow(t, req2, req) 20 | td.CmpNil(t, internal.GetSubmatches(req2)) 21 | 22 | req2 = internal.SetSubmatches(req, []string{}) 23 | td.Cmp(t, req2, td.Shallow(req)) 24 | td.CmpNil(t, internal.GetSubmatches(req2)) 25 | 26 | req2 = internal.SetSubmatches(req, []string{"foo", "123", "-123", "12.3"}) 27 | td.CmpNot(t, req2, td.Shallow(req)) 28 | td.CmpLen(t, internal.GetSubmatches(req2), 4) 29 | } 30 | -------------------------------------------------------------------------------- /match.go: -------------------------------------------------------------------------------- 1 | package httpmock 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "runtime" 9 | "strings" 10 | "sync/atomic" 11 | 12 | "github.com/jarcoal/httpmock/internal" 13 | ) 14 | 15 | var ignorePackages = map[string]bool{} 16 | 17 | func init() { 18 | IgnoreMatcherHelper() 19 | } 20 | 21 | // IgnoreMatcherHelper should be called by external helpers building 22 | // [Matcher], typically in an init() function, to avoid they appear in 23 | // the autogenerated [Matcher] names. 24 | func IgnoreMatcherHelper(skip ...int) { 25 | sk := 2 26 | if len(skip) > 0 { 27 | sk += skip[0] 28 | } 29 | if pkg := getPackage(sk); pkg != "" { 30 | ignorePackages[pkg] = true 31 | } 32 | } 33 | 34 | // Copied from github.com/maxatome/go-testdeep/internal/trace.getPackage. 35 | func getPackage(skip int) string { 36 | if pc, _, _, ok := runtime.Caller(skip); ok { 37 | if fn := runtime.FuncForPC(pc); fn != nil { 38 | return extractPackage(fn.Name()) 39 | } 40 | } 41 | return "" 42 | } 43 | 44 | // extractPackage extracts package part from a fully qualified function name: 45 | // 46 | // "foo/bar/test.fn" → "foo/bar/test" 47 | // "foo/bar/test.X.fn" → "foo/bar/test" 48 | // "foo/bar/test.(*X).fn" → "foo/bar/test" 49 | // "foo/bar/test.(*X).fn.func1" → "foo/bar/test" 50 | // "weird" → "" 51 | // 52 | // Derived from github.com/maxatome/go-testdeep/internal/trace.SplitPackageFunc. 53 | func extractPackage(fn string) string { 54 | sp := strings.LastIndexByte(fn, '/') 55 | if sp < 0 { 56 | sp = 0 // std package 57 | } 58 | 59 | dp := strings.IndexByte(fn[sp:], '.') 60 | if dp < 0 { 61 | return "" 62 | } 63 | 64 | return fn[:sp+dp] 65 | } 66 | 67 | // calledFrom returns a string like "@PKG.FUNC() FILE:LINE". 68 | func calledFrom(skip int) string { 69 | pc := make([]uintptr, 128) 70 | npc := runtime.Callers(skip+1, pc) 71 | pc = pc[:npc] 72 | 73 | frames := runtime.CallersFrames(pc) 74 | 75 | var lastFrame runtime.Frame 76 | 77 | for { 78 | frame, more := frames.Next() 79 | 80 | // If testing package is encountered, it is too late 81 | if strings.HasPrefix(frame.Function, "testing.") { 82 | break 83 | } 84 | lastFrame = frame 85 | // Stop if httpmock is not the caller 86 | if !ignorePackages[extractPackage(frame.Function)] || !more { 87 | break 88 | } 89 | } 90 | 91 | if lastFrame.Line == 0 { 92 | return "" 93 | } 94 | return fmt.Sprintf(" @%s() %s:%d", 95 | lastFrame.Function, lastFrame.File, lastFrame.Line) 96 | } 97 | 98 | // MatcherFunc type is the function to use to check a [Matcher] 99 | // matches an incoming request. When httpmock calls a function of this 100 | // type, it is guaranteed req.Body is never nil. If req.Body is nil in 101 | // the original request, it is temporarily replaced by an instance 102 | // returning always [io.EOF] for each Read() call, during the call. 103 | type MatcherFunc func(req *http.Request) bool 104 | 105 | func matcherFuncOr(mfs []MatcherFunc) MatcherFunc { 106 | return func(req *http.Request) bool { 107 | for _, mf := range mfs { 108 | rearmBody(req) 109 | if mf(req) { 110 | return true 111 | } 112 | } 113 | return false 114 | } 115 | } 116 | 117 | func matcherFuncAnd(mfs []MatcherFunc) MatcherFunc { 118 | if len(mfs) == 0 { 119 | return nil 120 | } 121 | return func(req *http.Request) bool { 122 | for _, mf := range mfs { 123 | rearmBody(req) 124 | if !mf(req) { 125 | return false 126 | } 127 | } 128 | return true 129 | } 130 | } 131 | 132 | // Check returns true if mf is nil, otherwise it returns mf(req). 133 | func (mf MatcherFunc) Check(req *http.Request) bool { 134 | return mf == nil || mf(req) 135 | } 136 | 137 | // Or combines mf and all mfs in a new [MatcherFunc]. This new 138 | // [MatcherFunc] succeeds if one of mf or mfs succeeds. Note that as 139 | // a nil [MatcherFunc] is considered succeeding, if mf or one of mfs 140 | // items is nil, nil is returned. 141 | func (mf MatcherFunc) Or(mfs ...MatcherFunc) MatcherFunc { 142 | if len(mfs) == 0 || mf == nil { 143 | return mf 144 | } 145 | cmfs := make([]MatcherFunc, len(mfs)+1) 146 | cmfs[0] = mf 147 | for i, cur := range mfs { 148 | if cur == nil { 149 | return nil 150 | } 151 | cmfs[i+1] = cur 152 | } 153 | return matcherFuncOr(cmfs) 154 | } 155 | 156 | // And combines mf and all mfs in a new [MatcherFunc]. This new 157 | // [MatcherFunc] succeeds if all of mf and mfs succeed. Note that a 158 | // [MatcherFunc] also succeeds if it is nil, so if mf and all mfs 159 | // items are nil, nil is returned. 160 | func (mf MatcherFunc) And(mfs ...MatcherFunc) MatcherFunc { 161 | if len(mfs) == 0 { 162 | return mf 163 | } 164 | cmfs := make([]MatcherFunc, 0, len(mfs)+1) 165 | if mf != nil { 166 | cmfs = append(cmfs, mf) 167 | } 168 | for _, cur := range mfs { 169 | if cur != nil { 170 | cmfs = append(cmfs, cur) 171 | } 172 | } 173 | return matcherFuncAnd(cmfs) 174 | } 175 | 176 | // Matcher type defines a match case. The zero Matcher{} corresponds 177 | // to the default case. Otherwise, use [NewMatcher] or any helper 178 | // building a [Matcher] like [BodyContainsBytes], [BodyContainsBytes], 179 | // [HeaderExists], [HeaderIs], [HeaderContains] or any of 180 | // [github.com/maxatome/tdhttpmock] functions. 181 | type Matcher struct { 182 | name string 183 | fn MatcherFunc // can be nil → means always true 184 | } 185 | 186 | var matcherID int64 187 | 188 | // NewMatcher returns a [Matcher]. If name is empty and fn is non-nil, 189 | // a name is automatically generated. When fn is nil, it is a default 190 | // [Matcher]: its name can be empty. 191 | // 192 | // Automatically generated names have the form: 193 | // 194 | // ~HEXANUMBER@PKG.FUNC() FILE:LINE 195 | // 196 | // Legend: 197 | // - HEXANUMBER is a unique 10 digit hexadecimal number, always increasing; 198 | // - PKG is the NewMatcher caller package (except if 199 | // [IgnoreMatcherHelper] has been previously called, in this case it 200 | // is the caller of the caller package and so on); 201 | // - FUNC is the function name of the caller in the previous PKG package; 202 | // - FILE and LINE are the location of the call in FUNC function. 203 | func NewMatcher(name string, fn MatcherFunc) Matcher { 204 | if name == "" && fn != nil { 205 | // Auto-name the matcher 206 | name = fmt.Sprintf("~%010x%s", atomic.AddInt64(&matcherID, 1), calledFrom(1)) 207 | } 208 | return Matcher{ 209 | name: name, 210 | fn: fn, 211 | } 212 | } 213 | 214 | // BodyContainsBytes returns a [Matcher] checking that request body 215 | // contains subslice. 216 | // 217 | // The name of the returned [Matcher] is auto-generated (see [NewMatcher]). 218 | // To name it explicitly, use [Matcher.WithName] as in: 219 | // 220 | // BodyContainsBytes([]byte("foo")).WithName("10-body-contains-foo") 221 | // 222 | // See also [github.com/maxatome/tdhttpmock.Body], 223 | // [github.com/maxatome/tdhttpmock.JSONBody] and 224 | // [github.com/maxatome/tdhttpmock.XMLBody] for powerful body testing. 225 | func BodyContainsBytes(subslice []byte) Matcher { 226 | return NewMatcher("", 227 | func(req *http.Request) bool { 228 | rearmBody(req) 229 | b, err := io.ReadAll(req.Body) 230 | return err == nil && bytes.Contains(b, subslice) 231 | }) 232 | } 233 | 234 | // BodyContainsString returns a [Matcher] checking that request body 235 | // contains substr. 236 | // 237 | // The name of the returned [Matcher] is auto-generated (see [NewMatcher]). 238 | // To name it explicitly, use [Matcher.WithName] as in: 239 | // 240 | // BodyContainsString("foo").WithName("10-body-contains-foo") 241 | // 242 | // See also [github.com/maxatome/tdhttpmock.Body], 243 | // [github.com/maxatome/tdhttpmock.JSONBody] and 244 | // [github.com/maxatome/tdhttpmock.XMLBody] for powerful body testing. 245 | func BodyContainsString(substr string) Matcher { 246 | return NewMatcher("", 247 | func(req *http.Request) bool { 248 | rearmBody(req) 249 | b, err := io.ReadAll(req.Body) 250 | return err == nil && bytes.Contains(b, []byte(substr)) 251 | }) 252 | } 253 | 254 | // HeaderExists returns a [Matcher] checking that request contains 255 | // key header. 256 | // 257 | // The name of the returned [Matcher] is auto-generated (see [NewMatcher]). 258 | // To name it explicitly, use [Matcher.WithName] as in: 259 | // 260 | // HeaderExists("X-Custom").WithName("10-custom-exists") 261 | // 262 | // See also [github.com/maxatome/tdhttpmock.Header] for powerful 263 | // header testing. 264 | func HeaderExists(key string) Matcher { 265 | return NewMatcher("", 266 | func(req *http.Request) bool { 267 | _, ok := req.Header[key] 268 | return ok 269 | }) 270 | } 271 | 272 | // HeaderIs returns a [Matcher] checking that request contains 273 | // key header set to value. 274 | // 275 | // The name of the returned [Matcher] is auto-generated (see [NewMatcher]). 276 | // To name it explicitly, use [Matcher.WithName] as in: 277 | // 278 | // HeaderIs("X-Custom", "VALUE").WithName("10-custom-is-value") 279 | // 280 | // See also [github.com/maxatome/tdhttpmock.Header] for powerful 281 | // header testing. 282 | func HeaderIs(key, value string) Matcher { 283 | return NewMatcher("", 284 | func(req *http.Request) bool { 285 | return req.Header.Get(key) == value 286 | }) 287 | } 288 | 289 | // HeaderContains returns a [Matcher] checking that request contains key 290 | // header itself containing substr. 291 | // 292 | // The name of the returned [Matcher] is auto-generated (see [NewMatcher]). 293 | // To name it explicitly, use [Matcher.WithName] as in: 294 | // 295 | // HeaderContains("X-Custom", "VALUE").WithName("10-custom-contains-value") 296 | // 297 | // See also [github.com/maxatome/tdhttpmock.Header] for powerful 298 | // header testing. 299 | func HeaderContains(key, substr string) Matcher { 300 | return NewMatcher("", 301 | func(req *http.Request) bool { 302 | return strings.Contains(req.Header.Get(key), substr) 303 | }) 304 | } 305 | 306 | // Name returns the m's name. 307 | func (m Matcher) Name() string { 308 | return m.name 309 | } 310 | 311 | // WithName returns a new [Matcher] based on m with name name. 312 | func (m Matcher) WithName(name string) Matcher { 313 | return NewMatcher(name, m.fn) 314 | } 315 | 316 | // Check returns true if req is matched by m. 317 | func (m Matcher) Check(req *http.Request) bool { 318 | return m.fn.Check(req) 319 | } 320 | 321 | // Or combines m and all ms in a new [Matcher]. This new [Matcher] 322 | // succeeds if one of m or ms succeeds. Note that as a [Matcher] 323 | // succeeds if internal fn is nil, if m's internal fn or any of ms 324 | // item's internal fn is nil, the returned [Matcher] always 325 | // succeeds. The name of returned [Matcher] is m's one. 326 | func (m Matcher) Or(ms ...Matcher) Matcher { 327 | if len(ms) == 0 || m.fn == nil { 328 | return m 329 | } 330 | mfs := make([]MatcherFunc, 1, len(ms)+1) 331 | mfs[0] = m.fn 332 | for _, cur := range ms { 333 | if cur.fn == nil { 334 | return Matcher{} 335 | } 336 | mfs = append(mfs, cur.fn) 337 | } 338 | m.fn = matcherFuncOr(mfs) 339 | return m 340 | } 341 | 342 | // And combines m and all ms in a new [Matcher]. This new [Matcher] 343 | // succeeds if all of m and ms succeed. Note that a [Matcher] also 344 | // succeeds if [Matcher] [MatcherFunc] is nil. The name of returned 345 | // [Matcher] is m's one if the empty/default [Matcher] is returned. 346 | func (m Matcher) And(ms ...Matcher) Matcher { 347 | if len(ms) == 0 { 348 | return m 349 | } 350 | mfs := make([]MatcherFunc, 0, len(ms)+1) 351 | if m.fn != nil { 352 | mfs = append(mfs, m.fn) 353 | } 354 | for _, cur := range ms { 355 | if cur.fn != nil { 356 | mfs = append(mfs, cur.fn) 357 | } 358 | } 359 | m.fn = matcherFuncAnd(mfs) 360 | if m.fn != nil { 361 | return m 362 | } 363 | return Matcher{} 364 | } 365 | 366 | type matchResponder struct { 367 | matcher Matcher 368 | responder Responder 369 | } 370 | 371 | type matchResponders []matchResponder 372 | 373 | // add adds or replaces a matchResponder. 374 | func (mrs matchResponders) add(mr matchResponder) matchResponders { 375 | // default is always at end 376 | if mr.matcher.fn == nil { 377 | if len(mrs) > 0 && (mrs)[len(mrs)-1].matcher.fn == nil { 378 | mrs[len(mrs)-1] = mr 379 | return mrs 380 | } 381 | return append(mrs, mr) 382 | } 383 | 384 | for i, cur := range mrs { 385 | if cur.matcher.name == mr.matcher.name { 386 | mrs[i] = mr 387 | return mrs 388 | } 389 | } 390 | 391 | for i, cur := range mrs { 392 | if cur.matcher.fn == nil || cur.matcher.name > mr.matcher.name { 393 | mrs = append(mrs, matchResponder{}) 394 | copy(mrs[i+1:], mrs[i:len(mrs)-1]) 395 | mrs[i] = mr 396 | return mrs 397 | } 398 | } 399 | return append(mrs, mr) 400 | } 401 | 402 | func (mrs matchResponders) checkEmptiness() matchResponders { 403 | if len(mrs) == 0 { 404 | return nil 405 | } 406 | return mrs 407 | } 408 | 409 | func (mrs matchResponders) shrink() matchResponders { 410 | mrs[len(mrs)-1] = matchResponder{} 411 | mrs = mrs[:len(mrs)-1] 412 | return mrs.checkEmptiness() 413 | } 414 | 415 | func (mrs matchResponders) remove(name string) matchResponders { 416 | // Special case, even if default has been renamed, we consider "" 417 | // matching this default 418 | if name == "" { 419 | // default is always at end 420 | if len(mrs) > 0 && mrs[len(mrs)-1].matcher.fn == nil { 421 | return mrs.shrink() 422 | } 423 | return mrs.checkEmptiness() 424 | } 425 | 426 | for i, cur := range mrs { 427 | if cur.matcher.name == name { 428 | copy(mrs[i:], mrs[i+1:]) 429 | return mrs.shrink() 430 | } 431 | } 432 | return mrs.checkEmptiness() 433 | } 434 | 435 | func (mrs matchResponders) findMatchResponder(req *http.Request) *matchResponder { 436 | if len(mrs) == 0 { 437 | return nil 438 | } 439 | if mrs[0].matcher.fn == nil { // nil match is always the last 440 | return &mrs[0] 441 | } 442 | 443 | copyBody := &bodyCopyOnRead{body: req.Body} 444 | req.Body = copyBody 445 | defer func() { 446 | copyBody.rearm() 447 | req.Body = copyBody.body 448 | }() 449 | 450 | for _, mr := range mrs { 451 | copyBody.rearm() 452 | if mr.matcher.Check(req) { 453 | return &mr 454 | } 455 | } 456 | return nil 457 | } 458 | 459 | type matchRouteKey struct { 460 | internal.RouteKey 461 | name string 462 | } 463 | 464 | func (m matchRouteKey) String() string { 465 | if m.name == "" { 466 | return m.RouteKey.String() 467 | } 468 | return m.RouteKey.String() + " <" + m.name + ">" 469 | } 470 | 471 | func rearmBody(req *http.Request) { 472 | if req != nil { 473 | if body, ok := req.Body.(interface{ rearm() }); ok { 474 | body.rearm() 475 | } 476 | } 477 | } 478 | 479 | type buffer struct { 480 | *bytes.Reader 481 | } 482 | 483 | func (b buffer) Close() error { 484 | return nil 485 | } 486 | 487 | // bodyCopyOnRead mutates body into a buffer on first Read(), except 488 | // if body is nil or http.NoBody. In this case, EOF is returned for 489 | // each Read() and body stays untouched. 490 | type bodyCopyOnRead struct { 491 | body io.ReadCloser 492 | } 493 | 494 | func (b *bodyCopyOnRead) rearm() { 495 | if buf, ok := b.body.(buffer); ok { 496 | buf.Seek(0, io.SeekStart) //nolint:errcheck 497 | } // else b.body contains the original body, so don't touch 498 | } 499 | 500 | func (b *bodyCopyOnRead) copy() { 501 | if _, ok := b.body.(buffer); !ok && b.body != nil && b.body != http.NoBody { 502 | buf, _ := io.ReadAll(b.body) 503 | b.body.Close() //nolint: errcheck 504 | b.body = buffer{bytes.NewReader(buf)} 505 | } 506 | } 507 | 508 | func (b *bodyCopyOnRead) Read(p []byte) (n int, err error) { 509 | b.copy() 510 | if b.body == nil { 511 | return 0, io.EOF 512 | } 513 | return b.body.Read(p) 514 | } 515 | 516 | func (b *bodyCopyOnRead) Close() error { 517 | return nil 518 | } 519 | -------------------------------------------------------------------------------- /match_test.go: -------------------------------------------------------------------------------- 1 | package httpmock_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/maxatome/go-testdeep/td" 14 | 15 | "github.com/jarcoal/httpmock" 16 | "github.com/jarcoal/httpmock/internal" 17 | ) 18 | 19 | func TestMatcherFunc_AndOr(t *testing.T) { 20 | ok := httpmock.MatcherFunc(func(*http.Request) bool { return true }) 21 | bad := httpmock.MatcherFunc(func(*http.Request) bool { return false }) 22 | 23 | td.CmpTrue(t, ok(nil)) 24 | td.CmpFalse(t, bad(nil)) 25 | 26 | t.Run("Or", func(t *testing.T) { 27 | td.CmpTrue(t, ok.Or(bad).Or(bad).Or(bad)(nil)) 28 | td.CmpTrue(t, bad.Or(bad).Or(bad).Or(ok)(nil)) 29 | td.CmpFalse(t, bad.Or(bad).Or(bad).Or(bad)(nil)) 30 | td.CmpNil(t, bad.Or(bad).Or(bad).Or(nil)) 31 | td.CmpNil(t, (httpmock.MatcherFunc)(nil).Or(bad).Or(bad).Or(bad)) 32 | td.CmpTrue(t, ok.Or()(nil)) 33 | }) 34 | 35 | t.Run("And", func(t *testing.T) { 36 | td.CmpTrue(t, ok.And(ok).And(ok).And(ok)(nil)) 37 | td.CmpTrue(t, ok.And(ok).And(nil).And(ok)(nil)) 38 | td.CmpFalse(t, ok.And(ok).And(bad).And(ok)(nil)) 39 | td.CmpFalse(t, bad.And(ok).And(ok).And(nil)(nil)) 40 | td.CmpTrue(t, ok.And()(nil)) 41 | td.CmpTrue(t, ok.And(nil)(nil)) 42 | td.CmpNil(t, (httpmock.MatcherFunc)(nil).And(nil).And(nil).And(nil)) 43 | td.CmpTrue(t, (httpmock.MatcherFunc)(nil).And(ok)(nil)) 44 | }) 45 | } 46 | 47 | func TestMatcherFunc_Check(t *testing.T) { 48 | ok := httpmock.MatcherFunc(func(*http.Request) bool { return true }) 49 | bad := httpmock.MatcherFunc(func(*http.Request) bool { return false }) 50 | 51 | td.CmpTrue(t, ok.Check(nil)) 52 | td.CmpTrue(t, (httpmock.MatcherFunc)(nil).Check(nil)) 53 | td.CmpFalse(t, bad.Check(nil)) 54 | } 55 | 56 | func TestNewMatcher(t *testing.T) { 57 | autogenName := td.Re(`^~[0-9a-f]{10} @.*/httpmock_test\.TestNewMatcher.*/match_test.go:\d+\z`) 58 | 59 | t.Run("NewMatcher", func(t *testing.T) { 60 | td.Cmp(t, 61 | httpmock.NewMatcher("xxx", func(*http.Request) bool { return true }), 62 | td.Struct(httpmock.Matcher{}, td.StructFields{ 63 | "name": "xxx", 64 | "fn": td.NotNil(), 65 | })) 66 | 67 | td.Cmp(t, httpmock.NewMatcher("", nil), httpmock.Matcher{}) 68 | 69 | td.Cmp(t, httpmock.NewMatcher("", func(*http.Request) bool { return true }), 70 | td.Struct(httpmock.Matcher{}, td.StructFields{ 71 | "name": autogenName, 72 | "fn": td.NotNil(), 73 | })) 74 | }) 75 | 76 | req := func(t testing.TB, body string, header ...string) *http.Request { 77 | req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) 78 | td.Require(t).CmpNoError(err) 79 | req.Header.Set("Content-Type", "text/plain") 80 | for i := 0; i < len(header)-1; i += 2 { 81 | req.Header.Set(header[i], header[i+1]) 82 | } 83 | return req 84 | } 85 | 86 | reqCopyBody := func(t testing.TB, body string, header ...string) *http.Request { 87 | req := req(t, body, header...) 88 | req.Body = httpmock.NewBodyCopyOnRead(req.Body) 89 | return req 90 | } 91 | 92 | t.Run("BodyContainsBytes", func(t *testing.T) { 93 | m := httpmock.BodyContainsBytes([]byte("ip")) 94 | td.Cmp(t, m.Name(), autogenName) 95 | td.CmpTrue(t, m.Check(req(t, "pipo"))) 96 | td.CmpFalse(t, m.Check(req(t, "bingo"))) 97 | 98 | td.CmpTrue(t, m.Check(reqCopyBody(t, "pipo"))) 99 | td.CmpFalse(t, m.Check(reqCopyBody(t, "bingo"))) 100 | }) 101 | 102 | t.Run("BodyContainsString", func(t *testing.T) { 103 | m := httpmock.BodyContainsString("ip") 104 | td.Cmp(t, m.Name(), autogenName) 105 | td.CmpTrue(t, m.Check(req(t, "pipo"))) 106 | td.CmpFalse(t, m.Check(req(t, "bingo"))) 107 | 108 | td.CmpTrue(t, m.Check(reqCopyBody(t, "pipo"))) 109 | td.CmpFalse(t, m.Check(reqCopyBody(t, "bingo"))) 110 | }) 111 | 112 | t.Run("HeaderExists", func(t *testing.T) { 113 | m := httpmock.HeaderExists("X-Custom") 114 | td.Cmp(t, m.Name(), autogenName) 115 | td.CmpTrue(t, m.Check(req(t, "pipo", "X-Custom", "zzz"))) 116 | td.CmpFalse(t, m.Check(req(t, "bingo"))) 117 | }) 118 | 119 | t.Run("HeaderIs", func(t *testing.T) { 120 | m := httpmock.HeaderIs("X-Custom", "zzz") 121 | td.Cmp(t, m.Name(), autogenName) 122 | td.CmpTrue(t, m.Check(req(t, "pipo", "X-Custom", "zzz"))) 123 | td.CmpFalse(t, m.Check(req(t, "bingo", "X-Custom", "aaa"))) 124 | td.CmpFalse(t, m.Check(req(t, "bingo"))) 125 | }) 126 | 127 | t.Run("HeaderContains", func(t *testing.T) { 128 | m := httpmock.HeaderContains("X-Custom", "zzz") 129 | td.Cmp(t, m.Name(), autogenName) 130 | td.CmpTrue(t, m.Check(req(t, "pipo", "X-Custom", "aaa zzz bbb"))) 131 | td.CmpFalse(t, m.Check(req(t, "bingo"))) 132 | }) 133 | } 134 | 135 | func TestMatcher_NameWithName(t *testing.T) { 136 | autogenName := td.Re(`^~[0-9a-f]{10} @.*/httpmock_test\.TestMatcher_NameWithName.*/match_test.go:\d+\z`) 137 | 138 | t.Run("default", func(t *testing.T) { 139 | m := httpmock.NewMatcher("", nil) 140 | td.Cmp(t, m.Name(), "", "no autogen for nil fn (= default)") 141 | 142 | td.Cmp(t, m.WithName("pipo").Name(), "pipo") 143 | td.Cmp(t, m.Name(), "", "original Matcher stay untouched") 144 | 145 | td.Cmp(t, m.WithName("pipo").WithName("").Name(), "", "no autogen for nil fn") 146 | }) 147 | 148 | t.Run("non-default", func(t *testing.T) { 149 | m := httpmock.NewMatcher("xxx", func(*http.Request) bool { return true }) 150 | td.Cmp(t, m.Name(), "xxx") 151 | 152 | td.Cmp(t, m.WithName("pipo").Name(), "pipo") 153 | td.Cmp(t, m.Name(), "xxx", "original Matcher stay untouched") 154 | 155 | td.Cmp(t, m.WithName("pipo").WithName("").Name(), autogenName) 156 | }) 157 | } 158 | 159 | func TestMatcher_AndOr(t *testing.T) { 160 | ok := httpmock.MatcherFunc(func(*http.Request) bool { return true }) 161 | bad := httpmock.MatcherFunc(func(*http.Request) bool { return false }) 162 | 163 | t.Run("Or", func(t *testing.T) { 164 | m := httpmock.NewMatcher("a", ok). 165 | Or(httpmock.NewMatcher("b", bad)). 166 | Or(httpmock.NewMatcher("c", ok)) 167 | td.Cmp(t, m.Name(), "a") 168 | td.CmpTrue(t, m.Check(nil)) 169 | 170 | m = httpmock.NewMatcher("a", ok). 171 | Or(httpmock.NewMatcher("", nil)). 172 | Or(httpmock.NewMatcher("c", ok)) 173 | td.Cmp(t, m.Name(), "") 174 | td.CmpZero(t, m.FnPointer()) 175 | 176 | m = httpmock.NewMatcher("a", ok).Or() 177 | td.Cmp(t, m.Name(), "a") 178 | td.CmpTrue(t, m.Check(nil)) 179 | 180 | m = httpmock.NewMatcher("a", bad). 181 | Or(httpmock.NewMatcher("b", bad)). 182 | Or(httpmock.NewMatcher("c", ok)) 183 | td.Cmp(t, m.Name(), "a") 184 | td.CmpTrue(t, m.Check(nil)) 185 | 186 | m = httpmock.NewMatcher("a", bad). 187 | Or(httpmock.NewMatcher("b", bad)). 188 | Or(httpmock.NewMatcher("c", bad)) 189 | td.Cmp(t, m.Name(), "a") 190 | td.CmpFalse(t, m.Check(nil)) 191 | }) 192 | 193 | t.Run("And", func(t *testing.T) { 194 | m := httpmock.NewMatcher("a", ok). 195 | And(httpmock.NewMatcher("b", ok)). 196 | And(httpmock.NewMatcher("c", ok)) 197 | td.Cmp(t, m.Name(), "a") 198 | td.CmpTrue(t, m.Check(nil)) 199 | 200 | m = httpmock.NewMatcher("a", ok). 201 | And(httpmock.NewMatcher("b", bad)). 202 | And(httpmock.NewMatcher("c", ok)) 203 | td.Cmp(t, m.Name(), "a") 204 | td.CmpFalse(t, m.Check(nil)) 205 | 206 | mInit := httpmock.NewMatcher("", nil) 207 | m = mInit.And(httpmock.NewMatcher("", nil)). 208 | And(httpmock.NewMatcher("", nil)) 209 | td.Cmp(t, m.Name(), mInit.Name()) 210 | td.CmpZero(t, m.FnPointer()) 211 | 212 | m = httpmock.NewMatcher("a", ok).And() 213 | td.Cmp(t, m.Name(), "a") 214 | td.CmpTrue(t, m.Check(nil)) 215 | }) 216 | } 217 | 218 | var matchers = []httpmock.MatcherFunc{ 219 | func(*http.Request) bool { return false }, 220 | func(*http.Request) bool { return true }, 221 | } 222 | 223 | func findMatcher(fnPtr uintptr) int { 224 | if fnPtr == 0 { 225 | return -1 226 | } 227 | for i, gm := range matchers { 228 | if fnPtr == reflect.ValueOf(gm).Pointer() { 229 | return i 230 | } 231 | } 232 | return -2 233 | } 234 | 235 | func newMR(name string, num int) httpmock.MatchResponder { 236 | if num < 0 { 237 | // default matcher 238 | return httpmock.NewMatchResponder(httpmock.NewMatcher(name, nil), nil) 239 | } 240 | return httpmock.NewMatchResponder(httpmock.NewMatcher(name, matchers[num]), nil) 241 | } 242 | 243 | func checkMRs(t testing.TB, mrs httpmock.MatchResponders, names ...string) { 244 | td.Cmp(t, mrs, td.Smuggle( 245 | func(mrs httpmock.MatchResponders) []string { 246 | var ns []string 247 | for _, mr := range mrs { 248 | ns = append(ns, fmt.Sprintf("%s:%d", 249 | mr.Matcher().Name(), findMatcher(mr.Matcher().FnPointer()))) 250 | } 251 | return ns 252 | }, 253 | names)) 254 | } 255 | 256 | func TestMatchResponders_add_remove(t *testing.T) { 257 | var mrs httpmock.MatchResponders 258 | mrs = mrs.Add(newMR("foo", 0)) 259 | mrs = mrs.Add(newMR("bar", 0)) 260 | checkMRs(t, mrs, "bar:0", "foo:0") 261 | mrs = mrs.Add(newMR("bar", 1)) 262 | mrs = mrs.Add(newMR("", -1)) 263 | checkMRs(t, mrs, "bar:1", "foo:0", ":-1") 264 | 265 | mrs = mrs.Remove("foo") 266 | checkMRs(t, mrs, "bar:1", ":-1") 267 | mrs = mrs.Remove("foo") 268 | checkMRs(t, mrs, "bar:1", ":-1") 269 | 270 | mrs = mrs.Remove("") 271 | checkMRs(t, mrs, "bar:1") 272 | mrs = mrs.Remove("") 273 | checkMRs(t, mrs, "bar:1") 274 | 275 | mrs = mrs.Remove("bar") 276 | td.CmpNil(t, mrs) 277 | mrs = mrs.Remove("bar") 278 | td.CmpNil(t, mrs) 279 | 280 | mrs = nil 281 | mrs = mrs.Add(newMR("DEFAULT", -1)) 282 | mrs = mrs.Add(newMR("foo", 0)) 283 | checkMRs(t, mrs, "foo:0", "DEFAULT:-1") 284 | mrs = mrs.Add(newMR("bar", 0)) 285 | mrs = mrs.Add(newMR("bar", 1)) 286 | checkMRs(t, mrs, "bar:1", "foo:0", "DEFAULT:-1") 287 | 288 | mrs = mrs.Remove("") // remove DEFAULT 289 | checkMRs(t, mrs, "bar:1", "foo:0") 290 | mrs = mrs.Remove("") 291 | checkMRs(t, mrs, "bar:1", "foo:0") 292 | 293 | mrs = mrs.Remove("bar") 294 | checkMRs(t, mrs, "foo:0") 295 | 296 | mrs = mrs.Remove("foo") 297 | td.CmpNil(t, mrs) 298 | } 299 | 300 | func TestMatchResponders_findMatchResponder(t *testing.T) { 301 | newReq := func() *http.Request { 302 | req, _ := http.NewRequest("GET", "/foo", io.NopCloser(bytes.NewReader([]byte(`BODY`)))) 303 | req.Header.Set("X-Foo", "bar") 304 | return req 305 | } 306 | 307 | assert := td.Assert(t). 308 | WithCmpHooks( 309 | func(a, b httpmock.MatchResponder) error { 310 | if a.Matcher().Name() != b.Matcher().Name() { 311 | return errors.New("name field mismatch") 312 | } 313 | if a.Matcher().FnPointer() != b.Matcher().FnPointer() { 314 | return errors.New("fn field mismatch") 315 | } 316 | if a.ResponderPointer() != b.ResponderPointer() { 317 | return errors.New("responder field mismatch") 318 | } 319 | return nil 320 | }) 321 | 322 | var mrs httpmock.MatchResponders 323 | 324 | resp := httpmock.NewStringResponder(200, "OK") 325 | 326 | req := newReq() 327 | assert.Nil(mrs.FindMatchResponder(req)) 328 | 329 | mrDefault := httpmock.NewMatchResponder(httpmock.Matcher{}, resp) 330 | mrs = mrs.Add(mrDefault) 331 | assert.Cmp(mrs.FindMatchResponder(req), &mrDefault) 332 | 333 | mrHeader1 := httpmock.NewMatchResponder( 334 | httpmock.NewMatcher("header-foo-zip", func(req *http.Request) bool { 335 | return req.Header.Get("X-Foo") == "zip" 336 | }), 337 | resp) 338 | mrs = mrs.Add(mrHeader1) 339 | assert.Cmp(mrs.FindMatchResponder(req), &mrDefault) 340 | 341 | mrHeader2 := httpmock.NewMatchResponder( 342 | httpmock.NewMatcher("header-foo-bar", func(req *http.Request) bool { 343 | return req.Header.Get("X-Foo") == "bar" 344 | }), 345 | resp) 346 | mrs = mrs.Add(mrHeader2) 347 | assert.Cmp(mrs.FindMatchResponder(req), &mrHeader2) 348 | 349 | mrs = mrs.Remove(mrHeader2.Matcher().Name()). 350 | Remove(mrDefault.Matcher().Name()) 351 | assert.Nil(mrs.FindMatchResponder(req)) 352 | 353 | mrBody1 := httpmock.NewMatchResponder( 354 | httpmock.NewMatcher("body-FOO", func(req *http.Request) bool { 355 | b, err := io.ReadAll(req.Body) 356 | return err == nil && bytes.Equal(b, []byte("FOO")) 357 | }), 358 | resp) 359 | mrs = mrs.Add(mrBody1) 360 | 361 | req = newReq() 362 | assert.Nil(mrs.FindMatchResponder(req)) 363 | 364 | mrBody2 := httpmock.NewMatchResponder( 365 | httpmock.NewMatcher("body-BODY", func(req *http.Request) bool { 366 | b, err := io.ReadAll(req.Body) 367 | return err == nil && bytes.Equal(b, []byte("BODY")) 368 | }), 369 | resp) 370 | mrs = mrs.Add(mrBody2) 371 | 372 | req = newReq() 373 | assert.Cmp(mrs.FindMatchResponder(req), &mrBody2) 374 | 375 | // The request body should still be readable 376 | b, err := io.ReadAll(req.Body) 377 | assert.CmpNoError(err) 378 | assert.String(b, "BODY") 379 | } 380 | 381 | func TestMatchRouteKey(t *testing.T) { 382 | td.Cmp(t, httpmock.NewMatchRouteKey( 383 | internal.RouteKey{ 384 | Method: "GET", 385 | URL: "/foo", 386 | }, 387 | ""). 388 | String(), 389 | "GET /foo") 390 | 391 | td.Cmp(t, httpmock.NewMatchRouteKey( 392 | internal.RouteKey{ 393 | Method: "GET", 394 | URL: "/foo", 395 | }, 396 | "check-header"). 397 | String(), 398 | "GET /foo ") 399 | } 400 | 401 | func TestBodyCopyOnRead(t *testing.T) { 402 | t.Run("non-nil body", func(t *testing.T) { 403 | body := io.NopCloser(bytes.NewReader([]byte(`BODY`))) 404 | 405 | bc := httpmock.NewBodyCopyOnRead(body) 406 | 407 | bc.Rearm() 408 | td.Cmp(t, body, bc.Body(), "rearm didn't touch anything") 409 | 410 | var buf [4]byte 411 | n, err := bc.Read(buf[:]) 412 | td.CmpNoError(t, err) 413 | td.Cmp(t, n, 4) 414 | td.CmpString(t, buf[:], "BODY") 415 | 416 | td.Cmp(t, body, td.Not(bc.Body()), "Original body has been copied internally") 417 | 418 | td.CmpNoError(t, bc.Body().Close()) // for coverage... :) 419 | 420 | n, err = bc.Read(buf[:]) 421 | td.Cmp(t, err, io.EOF) 422 | td.Cmp(t, n, 0) 423 | 424 | bc.Rearm() 425 | 426 | n, err = bc.Read(buf[:]) 427 | td.CmpNoError(t, err) 428 | td.Cmp(t, n, 4) 429 | td.CmpString(t, buf[:], "BODY") 430 | 431 | td.CmpNoError(t, bc.Close()) 432 | }) 433 | 434 | testCases := []struct { 435 | name string 436 | body io.ReadCloser 437 | }{ 438 | { 439 | name: "nil body", 440 | }, 441 | { 442 | name: "no body", 443 | body: http.NoBody, 444 | }, 445 | } 446 | for _, tc := range testCases { 447 | t.Run(tc.name, func(t *testing.T) { 448 | bc := httpmock.NewBodyCopyOnRead(tc.body) 449 | 450 | bc.Rearm() 451 | td.Cmp(t, tc.body, bc.Body(), "rearm didn't touch anything") 452 | 453 | var buf [4]byte 454 | n, err := bc.Read(buf[:]) 455 | td.Cmp(t, err, io.EOF) 456 | td.Cmp(t, n, 0) 457 | td.Cmp(t, bc.Body(), tc.body, "body is not altered") 458 | 459 | bc.Rearm() 460 | 461 | n, err = bc.Read(buf[:]) 462 | td.Cmp(t, err, io.EOF) 463 | td.Cmp(t, n, 0) 464 | td.Cmp(t, bc.Body(), tc.body, "body is not altered") 465 | 466 | td.CmpNoError(t, bc.Close()) 467 | }) 468 | } 469 | } 470 | 471 | func TestExtractPackage(t *testing.T) { 472 | td.Cmp(t, httpmock.ExtractPackage("foo/bar/test.fn"), "foo/bar/test") 473 | td.Cmp(t, httpmock.ExtractPackage("foo/bar/test.X.fn"), "foo/bar/test") 474 | td.Cmp(t, httpmock.ExtractPackage("foo/bar/test.(*X).fn"), "foo/bar/test") 475 | td.Cmp(t, httpmock.ExtractPackage("foo/bar/test.(*X).fn.func1"), "foo/bar/test") 476 | td.Cmp(t, httpmock.ExtractPackage("weird"), "") 477 | } 478 | 479 | func TestIgnorePackages(t *testing.T) { 480 | ignorePackages := httpmock.GetIgnorePackages() 481 | 482 | td.Cmp(t, ignorePackages, td.Len(1)) 483 | td.Cmp(t, ignorePackages, td.ContainsKey(td.HasSuffix("/httpmock"))) 484 | 485 | httpmock.IgnoreMatcherHelper() 486 | td.Cmp(t, ignorePackages, td.Len(2), "current httpmock_test package added") 487 | td.Cmp(t, ignorePackages, td.ContainsKey(td.HasSuffix("/httpmock_test"))) 488 | 489 | httpmock.IgnoreMatcherHelper(1) 490 | td.Cmp(t, ignorePackages, td.Len(3), "caller of TestIgnorePackages() → testing") 491 | td.Cmp(t, ignorePackages, td.ContainsKey("testing")) 492 | 493 | td.Cmp(t, httpmock.GetPackage(1000), "") 494 | } 495 | 496 | func TestCalledFrom(t *testing.T) { 497 | td.Cmp(t, httpmock.CalledFrom(0), td.Re(`^ @.*/httpmock_test\.TestCalledFrom\(\) .*/match_test.go:\d+\z`)) 498 | 499 | td.Cmp(t, httpmock.CalledFrom(1000), "") 500 | } 501 | -------------------------------------------------------------------------------- /race_test.go: -------------------------------------------------------------------------------- 1 | package httpmock_test 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | "testing" 7 | 8 | "github.com/jarcoal/httpmock" 9 | ) 10 | 11 | func TestActivateNonDefaultRace(t *testing.T) { 12 | var wg sync.WaitGroup 13 | wg.Add(10) 14 | for i := 0; i < 10; i++ { 15 | go func() { 16 | defer wg.Done() 17 | httpmock.ActivateNonDefault(&http.Client{}) 18 | }() 19 | } 20 | wg.Wait() 21 | } 22 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package httpmock 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "encoding/xml" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "reflect" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "github.com/jarcoal/httpmock/internal" 19 | ) 20 | 21 | // fromThenKeyType is used by Then(). 22 | type fromThenKeyType struct{} 23 | 24 | var fromThenKey = fromThenKeyType{} 25 | 26 | type suggestedInfo struct { 27 | kind string 28 | suggested string 29 | } 30 | 31 | // suggestedMethodKeyType is used by NewNotFoundResponder(). 32 | type suggestedKeyType struct{} 33 | 34 | var suggestedKey = suggestedKeyType{} 35 | 36 | // Responder is a callback that receives an [*http.Request] and returns 37 | // a mocked response. 38 | type Responder func(*http.Request) (*http.Response, error) 39 | 40 | func (r Responder) times(name string, n int, fn ...func(...any)) Responder { 41 | count := 0 42 | return func(req *http.Request) (*http.Response, error) { 43 | count++ 44 | if count > n { 45 | err := internal.StackTracer{ 46 | Err: fmt.Errorf("Responder not found for %s %s (coz %s and already called %d times)", req.Method, req.URL, name, count), 47 | } 48 | if len(fn) > 0 { 49 | err.CustomFn = fn[0] 50 | } 51 | return nil, err 52 | } 53 | return r(req) 54 | } 55 | } 56 | 57 | // Times returns a [Responder] callable n times before returning an 58 | // error. If the [Responder] is called more than n times and fn is 59 | // passed and non-nil, it acts as the fn parameter of 60 | // [NewNotFoundResponder], allowing to dump the stack trace to 61 | // localize the origin of the call. 62 | // 63 | // import ( 64 | // "testing" 65 | // "github.com/jarcoal/httpmock" 66 | // ) 67 | // ... 68 | // func TestMyApp(t *testing.T) { 69 | // ... 70 | // // This responder is callable 3 times, then an error is returned and 71 | // // the stacktrace of the call logged using t.Log() 72 | // httpmock.RegisterResponder("GET", "/foo/bar", 73 | // httpmock.NewStringResponder(200, "{}").Times(3, t.Log), 74 | // ) 75 | func (r Responder) Times(n int, fn ...func(...any)) Responder { 76 | return r.times("Times", n, fn...) 77 | } 78 | 79 | // Once returns a new [Responder] callable once before returning an 80 | // error. If the [Responder] is called 2 or more times and fn is passed 81 | // and non-nil, it acts as the fn parameter of [NewNotFoundResponder], 82 | // allowing to dump the stack trace to localize the origin of the 83 | // call. 84 | // 85 | // import ( 86 | // "testing" 87 | // "github.com/jarcoal/httpmock" 88 | // ) 89 | // ... 90 | // func TestMyApp(t *testing.T) { 91 | // ... 92 | // // This responder is callable only once, then an error is returned and 93 | // // the stacktrace of the call logged using t.Log() 94 | // httpmock.RegisterResponder("GET", "/foo/bar", 95 | // httpmock.NewStringResponder(200, "{}").Once(t.Log), 96 | // ) 97 | func (r Responder) Once(fn ...func(...any)) Responder { 98 | return r.times("Once", 1, fn...) 99 | } 100 | 101 | // Trace returns a new [Responder] that allows to easily trace the calls 102 | // of the original [Responder] using fn. It can be used in conjunction 103 | // with the testing package as in the example below with the help of 104 | // [*testing.T.Log] method: 105 | // 106 | // import ( 107 | // "testing" 108 | // "github.com/jarcoal/httpmock" 109 | // ) 110 | // ... 111 | // func TestMyApp(t *testing.T) { 112 | // ... 113 | // httpmock.RegisterResponder("GET", "/foo/bar", 114 | // httpmock.NewStringResponder(200, "{}").Trace(t.Log), 115 | // ) 116 | func (r Responder) Trace(fn func(...any)) Responder { 117 | return func(req *http.Request) (*http.Response, error) { 118 | resp, err := r(req) 119 | return resp, internal.StackTracer{ 120 | CustomFn: fn, 121 | Err: err, 122 | } 123 | } 124 | } 125 | 126 | // Delay returns a new [Responder] that calls the original r Responder 127 | // after a delay of d. 128 | // 129 | // import ( 130 | // "testing" 131 | // "time" 132 | // "github.com/jarcoal/httpmock" 133 | // ) 134 | // ... 135 | // func TestMyApp(t *testing.T) { 136 | // ... 137 | // httpmock.RegisterResponder("GET", "/foo/bar", 138 | // httpmock.NewStringResponder(200, "{}").Delay(100*time.Millisecond), 139 | // ) 140 | func (r Responder) Delay(d time.Duration) Responder { 141 | return func(req *http.Request) (*http.Response, error) { 142 | time.Sleep(d) 143 | return r(req) 144 | } 145 | } 146 | 147 | var errThenDone = errors.New("ThenDone") 148 | 149 | // similar is simple but a bit tricky. Here we consider two Responder 150 | // are similar if they share the same function, but not necessarily 151 | // the same environment. It is only used by Then below. 152 | func (r Responder) similar(other Responder) bool { 153 | return reflect.ValueOf(r).Pointer() == reflect.ValueOf(other).Pointer() 154 | } 155 | 156 | // Then returns a new [Responder] that calls r on first invocation, then 157 | // next on following ones, except when Then is chained, in this case 158 | // next is called only once: 159 | // 160 | // A := httpmock.NewStringResponder(200, "A") 161 | // B := httpmock.NewStringResponder(200, "B") 162 | // C := httpmock.NewStringResponder(200, "C") 163 | // 164 | // httpmock.RegisterResponder("GET", "/pipo", A.Then(B).Then(C)) 165 | // 166 | // http.Get("http://foo.bar/pipo") // A is called 167 | // http.Get("http://foo.bar/pipo") // B is called 168 | // http.Get("http://foo.bar/pipo") // C is called 169 | // http.Get("http://foo.bar/pipo") // C is called, and so on 170 | // 171 | // A panic occurs if next is the result of another Then call (because 172 | // allowing it could cause inextricable problems at runtime). Then 173 | // calls can be chained, but cannot call each other by 174 | // parameter. Example: 175 | // 176 | // A.Then(B).Then(C) // is OK 177 | // A.Then(B.Then(C)) // panics as A.Then() parameter is another Then() call 178 | // 179 | // See also [ResponderFromMultipleResponses]. 180 | func (r Responder) Then(next Responder) (x Responder) { 181 | var done int 182 | var mu sync.Mutex 183 | x = func(req *http.Request) (*http.Response, error) { 184 | mu.Lock() 185 | defer mu.Unlock() 186 | 187 | ctx := req.Context() 188 | thenCalledUs, _ := ctx.Value(fromThenKey).(bool) 189 | if !thenCalledUs { 190 | req = req.WithContext(context.WithValue(ctx, fromThenKey, true)) 191 | } 192 | 193 | switch done { 194 | case 0: 195 | resp, err := r(req) 196 | if err != errThenDone { 197 | if !x.similar(r) { // r is NOT a Then 198 | done = 1 199 | } 200 | return resp, err 201 | } 202 | fallthrough 203 | 204 | case 1: 205 | done = 2 // next is NEVER a Then, as it is forbidden by design 206 | return next(req) 207 | } 208 | if thenCalledUs { 209 | return nil, errThenDone 210 | } 211 | return next(req) 212 | } 213 | 214 | if next.similar(x) { 215 | panic("Then() does not accept another Then() Responder as parameter") 216 | } 217 | return 218 | } 219 | 220 | // SetContentLength returns a new [Responder] based on r that ensures 221 | // the returned [*http.Response] ContentLength field and 222 | // Content-Length header are set to the right value. 223 | // 224 | // If r returns an [*http.Response] with a nil Body or equal to 225 | // [http.NoBody], the length is always set to 0. 226 | // 227 | // If r returned response.Body implements: 228 | // 229 | // Len() int 230 | // 231 | // then the length is set to the Body.Len() returned value. All 232 | // httpmock generated bodies implement this method. Beware that 233 | // [strings.Builder], [strings.Reader], [bytes.Buffer] and 234 | // [bytes.Reader] types used with [io.NopCloser] do not implement 235 | // Len() anymore. 236 | // 237 | // Otherwise, r returned response.Body is entirely copied into an 238 | // internal buffer to get its length, then it is closed. The Body of 239 | // the [*http.Response] returned by the [Responder] returned by 240 | // SetContentLength can then be read again to return its content as 241 | // usual. But keep in mind that each time this [Responder] is called, 242 | // r is called first. So this one has to carefully handle its body: it 243 | // is highly recommended to use [NewRespBodyFromString] or 244 | // [NewRespBodyFromBytes] to set the body once (as 245 | // [NewStringResponder] and [NewBytesResponder] do behind the scene), 246 | // or to build the body each time r is called. 247 | // 248 | // The following calls are all correct: 249 | // 250 | // responder = httpmock.NewStringResponder(200, "BODY").SetContentLength() 251 | // responder = httpmock.NewBytesResponder(200, []byte("BODY")).SetContentLength() 252 | // responder = ResponderFromResponse(&http.Response{ 253 | // // build a body once, but httpmock knows how to "rearm" it once read 254 | // Body: NewRespBodyFromString("BODY"), 255 | // StatusCode: 200, 256 | // }).SetContentLength() 257 | // responder = httpmock.Responder(func(req *http.Request) (*http.Response, error) { 258 | // // build a new body for each call 259 | // return &http.Response{ 260 | // StatusCode: 200, 261 | // Body: io.NopCloser(strings.NewReader("BODY")), 262 | // }, nil 263 | // }).SetContentLength() 264 | // 265 | // But the following is not correct: 266 | // 267 | // responder = httpmock.ResponderFromResponse(&http.Response{ 268 | // StatusCode: 200, 269 | // Body: io.NopCloser(strings.NewReader("BODY")), 270 | // }).SetContentLength() 271 | // 272 | // it will only succeed for the first responder call. The following 273 | // calls will deliver responses with an empty body, as it will already 274 | // been read by the first call. 275 | func (r Responder) SetContentLength() Responder { 276 | return func(req *http.Request) (*http.Response, error) { 277 | resp, err := r(req) 278 | if err != nil { 279 | return nil, err 280 | } 281 | nr := *resp 282 | switch nr.Body { 283 | case nil: 284 | nr.Body = http.NoBody 285 | fallthrough 286 | case http.NoBody: 287 | nr.ContentLength = 0 288 | default: 289 | bl, ok := nr.Body.(interface{ Len() int }) 290 | if !ok { 291 | copyBody := &dummyReadCloser{orig: nr.Body} 292 | bl, nr.Body = copyBody, copyBody 293 | } 294 | nr.ContentLength = int64(bl.Len()) 295 | } 296 | if nr.Header == nil { 297 | nr.Header = http.Header{} 298 | } 299 | nr.Header = nr.Header.Clone() 300 | nr.Header.Set("Content-Length", strconv.FormatInt(nr.ContentLength, 10)) 301 | return &nr, nil 302 | } 303 | } 304 | 305 | // HeaderAdd returns a new [Responder] based on r that ensures the 306 | // returned [*http.Response] includes h header. It adds each h entry 307 | // to the header. It appends to any existing values associated with 308 | // each h key. Each key is case insensitive; it is canonicalized by 309 | // [http.CanonicalHeaderKey]. 310 | // 311 | // See also [Responder.HeaderSet] and [Responder.SetContentLength]. 312 | func (r Responder) HeaderAdd(h http.Header) Responder { 313 | return func(req *http.Request) (*http.Response, error) { 314 | resp, err := r(req) 315 | if err != nil { 316 | return nil, err 317 | } 318 | nr := *resp 319 | if nr.Header == nil { 320 | nr.Header = make(http.Header, len(h)) 321 | } 322 | nr.Header = nr.Header.Clone() 323 | for k, v := range h { 324 | k = http.CanonicalHeaderKey(k) 325 | if v == nil { 326 | if _, ok := nr.Header[k]; !ok { 327 | nr.Header[k] = nil 328 | } 329 | continue 330 | } 331 | nr.Header[k] = append(nr.Header[k], v...) 332 | } 333 | return &nr, nil 334 | } 335 | } 336 | 337 | // HeaderSet returns a new [Responder] based on r that ensures the 338 | // returned [*http.Response] includes h header. It sets the header 339 | // entries associated with each h key. It replaces any existing values 340 | // associated each h key. Each key is case insensitive; it is 341 | // canonicalized by [http.CanonicalHeaderKey]. 342 | // 343 | // See also [Responder.HeaderAdd] and [Responder.SetContentLength]. 344 | func (r Responder) HeaderSet(h http.Header) Responder { 345 | return func(req *http.Request) (*http.Response, error) { 346 | resp, err := r(req) 347 | if err != nil { 348 | return nil, err 349 | } 350 | nr := *resp 351 | if nr.Header == nil { 352 | nr.Header = make(http.Header, len(h)) 353 | } 354 | nr.Header = nr.Header.Clone() 355 | for k, v := range h { 356 | k = http.CanonicalHeaderKey(k) 357 | if v == nil { 358 | nr.Header[k] = nil 359 | continue 360 | } 361 | nr.Header[k] = append([]string(nil), v...) 362 | } 363 | return &nr, nil 364 | } 365 | } 366 | 367 | // ResponderFromResponse wraps an [*http.Response] in a [Responder]. 368 | // 369 | // Be careful, except for responses generated by httpmock 370 | // ([NewStringResponse] and [NewBytesResponse] functions) for which 371 | // there is no problems, it is the caller responsibility to ensure the 372 | // response body can be read several times and concurrently if needed, 373 | // as it is shared among all [Responder] returned responses. 374 | // 375 | // For home-made responses, [NewRespBodyFromString] and 376 | // [NewRespBodyFromBytes] functions can be used to produce response 377 | // bodies that can be read several times and concurrently. 378 | func ResponderFromResponse(resp *http.Response) Responder { 379 | return func(req *http.Request) (*http.Response, error) { 380 | res := *resp 381 | 382 | // Our stuff: generate a new io.ReadCloser instance sharing the same buffer 383 | if body, ok := resp.Body.(*dummyReadCloser); ok { 384 | res.Body = body.copy() 385 | } 386 | 387 | res.Request = req 388 | return &res, nil 389 | } 390 | } 391 | 392 | // ResponderFromMultipleResponses wraps an [*http.Response] list in a 393 | // [Responder]. 394 | // 395 | // Each response will be returned in the order of the provided list. 396 | // If the [Responder] is called more than the size of the provided 397 | // list, an error will be thrown. 398 | // 399 | // Be careful, except for responses generated by httpmock 400 | // ([NewStringResponse] and [NewBytesResponse] functions) for which 401 | // there is no problems, it is the caller responsibility to ensure the 402 | // response body can be read several times and concurrently if needed, 403 | // as it is shared among all [Responder] returned responses. 404 | // 405 | // For home-made responses, [NewRespBodyFromString] and 406 | // [NewRespBodyFromBytes] functions can be used to produce response 407 | // bodies that can be read several times and concurrently. 408 | // 409 | // If all responses have been returned and fn is passed and non-nil, 410 | // it acts as the fn parameter of [NewNotFoundResponder], allowing to 411 | // dump the stack trace to localize the origin of the call. 412 | // 413 | // import ( 414 | // "github.com/jarcoal/httpmock" 415 | // "testing" 416 | // ) 417 | // ... 418 | // func TestMyApp(t *testing.T) { 419 | // ... 420 | // // This responder is callable only once, then an error is returned and 421 | // // the stacktrace of the call logged using t.Log() 422 | // httpmock.RegisterResponder("GET", "/foo/bar", 423 | // httpmock.ResponderFromMultipleResponses( 424 | // []*http.Response{ 425 | // httpmock.NewStringResponse(200, `{"name":"bar"}`), 426 | // httpmock.NewStringResponse(404, `{"mesg":"Not found"}`), 427 | // }, 428 | // t.Log), 429 | // ) 430 | // } 431 | // 432 | // See also [Responder.Then]. 433 | func ResponderFromMultipleResponses(responses []*http.Response, fn ...func(...any)) Responder { 434 | responseIndex := 0 435 | mutex := sync.Mutex{} 436 | return func(req *http.Request) (*http.Response, error) { 437 | mutex.Lock() 438 | defer mutex.Unlock() 439 | defer func() { responseIndex++ }() 440 | if responseIndex >= len(responses) { 441 | err := internal.StackTracer{ 442 | Err: fmt.Errorf("not enough responses provided: responder called %d time(s) but %d response(s) provided", responseIndex+1, len(responses)), 443 | } 444 | if len(fn) > 0 { 445 | err.CustomFn = fn[0] 446 | } 447 | return nil, err 448 | } 449 | res := *responses[responseIndex] 450 | // Our stuff: generate a new io.ReadCloser instance sharing the same buffer 451 | if body, ok := responses[responseIndex].Body.(*dummyReadCloser); ok { 452 | res.Body = body.copy() 453 | } 454 | 455 | res.Request = req 456 | return &res, nil 457 | } 458 | } 459 | 460 | // NewErrorResponder creates a [Responder] that returns an empty request and the 461 | // given error. This can be used to e.g. imitate more deep http errors for the 462 | // client. 463 | func NewErrorResponder(err error) Responder { 464 | return func(req *http.Request) (*http.Response, error) { 465 | return nil, err 466 | } 467 | } 468 | 469 | // NewNotFoundResponder creates a [Responder] typically used in 470 | // conjunction with [RegisterNoResponder] function and [testing] 471 | // package, to be proactive when a [Responder] is not found. fn is 472 | // called with a unique string parameter containing the name of the 473 | // missing route and the stack trace to localize the origin of the 474 | // call. If fn returns (= if it does not panic), the [Responder] returns 475 | // an error of the form: "Responder not found for GET http://foo.bar/path". 476 | // Note that fn can be nil. 477 | // 478 | // It is useful when writing tests to ensure that all routes have been 479 | // mocked. 480 | // 481 | // Example of use: 482 | // 483 | // import ( 484 | // "testing" 485 | // "github.com/jarcoal/httpmock" 486 | // ) 487 | // ... 488 | // func TestMyApp(t *testing.T) { 489 | // ... 490 | // // Calls testing.Fatal with the name of Responder-less route and 491 | // // the stack trace of the call. 492 | // httpmock.RegisterNoResponder(httpmock.NewNotFoundResponder(t.Fatal)) 493 | // 494 | // Will abort the current test and print something like: 495 | // 496 | // transport_test.go:735: Called from net/http.Get() 497 | // at /go/src/github.com/jarcoal/httpmock/transport_test.go:714 498 | // github.com/jarcoal/httpmock.TestCheckStackTracer() 499 | // at /go/src/testing/testing.go:865 500 | // testing.tRunner() 501 | // at /go/src/runtime/asm_amd64.s:1337 502 | func NewNotFoundResponder(fn func(...any)) Responder { 503 | return func(req *http.Request) (*http.Response, error) { 504 | var extra string 505 | suggested, _ := req.Context().Value(suggestedKey).(*suggestedInfo) 506 | if suggested != nil { 507 | if suggested.kind == "matcher" { 508 | extra = fmt.Sprintf(` despite %s`, suggested.suggested) 509 | } else { 510 | extra = fmt.Sprintf(`, but one matches %s %q`, suggested.kind, suggested.suggested) 511 | } 512 | } 513 | return nil, internal.StackTracer{ 514 | CustomFn: fn, 515 | Err: fmt.Errorf("Responder not found for %s %s%s", req.Method, req.URL, extra), 516 | } 517 | } 518 | } 519 | 520 | // NewStringResponse creates an [*http.Response] with a body based on 521 | // the given string. Also accepts an HTTP status code. 522 | // 523 | // To pass the content of an existing file as body use [File] as in: 524 | // 525 | // httpmock.NewStringResponse(200, httpmock.File("body.txt").String()) 526 | func NewStringResponse(status int, body string) *http.Response { 527 | return &http.Response{ 528 | Status: fmt.Sprintf("%03d %s", status, http.StatusText(status)), 529 | StatusCode: status, 530 | Body: NewRespBodyFromString(body), 531 | Header: http.Header{}, 532 | ContentLength: -1, 533 | } 534 | } 535 | 536 | // NewStringResponder creates a [Responder] from a given body (as a 537 | // string) and status code. 538 | // 539 | // To pass the content of an existing file as body use [File] as in: 540 | // 541 | // httpmock.NewStringResponder(200, httpmock.File("body.txt").String()) 542 | func NewStringResponder(status int, body string) Responder { 543 | return ResponderFromResponse(NewStringResponse(status, body)) 544 | } 545 | 546 | // NewBytesResponse creates an [*http.Response] with a body based on the 547 | // given bytes. Also accepts an HTTP status code. 548 | // 549 | // To pass the content of an existing file as body use [File] as in: 550 | // 551 | // httpmock.NewBytesResponse(200, httpmock.File("body.raw").Bytes()) 552 | func NewBytesResponse(status int, body []byte) *http.Response { 553 | return &http.Response{ 554 | Status: fmt.Sprintf("%03d %s", status, http.StatusText(status)), 555 | StatusCode: status, 556 | Body: NewRespBodyFromBytes(body), 557 | Header: http.Header{}, 558 | ContentLength: -1, 559 | } 560 | } 561 | 562 | // NewBytesResponder creates a [Responder] from a given body (as a byte 563 | // slice) and status code. 564 | // 565 | // To pass the content of an existing file as body use [File] as in: 566 | // 567 | // httpmock.NewBytesResponder(200, httpmock.File("body.raw").Bytes()) 568 | func NewBytesResponder(status int, body []byte) Responder { 569 | return ResponderFromResponse(NewBytesResponse(status, body)) 570 | } 571 | 572 | // NewJsonResponse creates an [*http.Response] with a body that is a 573 | // JSON encoded representation of the given any. Also accepts 574 | // an HTTP status code. 575 | // 576 | // To pass the content of an existing file as body use [File] as in: 577 | // 578 | // httpmock.NewJsonResponse(200, httpmock.File("body.json")) 579 | func NewJsonResponse(status int, body any) (*http.Response, error) { //nolint: revive,staticcheck 580 | encoded, err := json.Marshal(body) 581 | if err != nil { 582 | return nil, err 583 | } 584 | response := NewBytesResponse(status, encoded) 585 | response.Header.Set("Content-Type", "application/json") 586 | return response, nil 587 | } 588 | 589 | // NewJsonResponseOrPanic is like [NewJsonResponse] but panics in case of error. 590 | // 591 | // It simplifies the call of [ResponderFromMultipleResponses], avoiding the 592 | // use of a temporary variable and an error check, and so can be used in such 593 | // context: 594 | // 595 | // httpmock.RegisterResponder( 596 | // "GET", 597 | // "/test/path", 598 | // httpmock.ResponderFromMultipleResponses([]*http.Response{ 599 | // httpmock.NewJsonResponseOrPanic(200, &MyFirstResponseBody), 600 | // httpmock.NewJsonResponseOrPanic(200, &MySecondResponseBody), 601 | // }), 602 | // ) 603 | // 604 | // To pass the content of an existing file as body use [File] as in: 605 | // 606 | // httpmock.NewJsonResponseOrPanic(200, httpmock.File("body.json")) 607 | func NewJsonResponseOrPanic(status int, body any) *http.Response { //nolint: revive,staticcheck 608 | response, err := NewJsonResponse(status, body) 609 | if err != nil { 610 | panic(err) 611 | } 612 | return response 613 | } 614 | 615 | // NewJsonResponder creates a [Responder] from a given body (as an 616 | // any that is encoded to JSON) and status code. 617 | // 618 | // To pass the content of an existing file as body use [File] as in: 619 | // 620 | // httpmock.NewJsonResponder(200, httpmock.File("body.json")) 621 | func NewJsonResponder(status int, body any) (Responder, error) { //nolint: revive,staticcheck 622 | resp, err := NewJsonResponse(status, body) 623 | if err != nil { 624 | return nil, err 625 | } 626 | return ResponderFromResponse(resp), nil 627 | } 628 | 629 | // NewJsonResponderOrPanic is like [NewJsonResponder] but panics in 630 | // case of error. 631 | // 632 | // It simplifies the call of [RegisterResponder], avoiding the use of a 633 | // temporary variable and an error check, and so can be used as 634 | // [NewStringResponder] or [NewBytesResponder] in such context: 635 | // 636 | // httpmock.RegisterResponder( 637 | // "GET", 638 | // "/test/path", 639 | // httpmock.NewJsonResponderOrPanic(200, &MyBody), 640 | // ) 641 | // 642 | // To pass the content of an existing file as body use [File] as in: 643 | // 644 | // httpmock.NewJsonResponderOrPanic(200, httpmock.File("body.json")) 645 | func NewJsonResponderOrPanic(status int, body any) Responder { //nolint: revive,staticcheck 646 | responder, err := NewJsonResponder(status, body) 647 | if err != nil { 648 | panic(err) 649 | } 650 | return responder 651 | } 652 | 653 | // NewXmlResponse creates an [*http.Response] with a body that is an 654 | // XML encoded representation of the given any. Also accepts an HTTP 655 | // status code. 656 | // 657 | // To pass the content of an existing file as body use [File] as in: 658 | // 659 | // httpmock.NewXmlResponse(200, httpmock.File("body.xml")) 660 | func NewXmlResponse(status int, body any) (*http.Response, error) { //nolint: revive,staticcheck 661 | var ( 662 | encoded []byte 663 | err error 664 | ) 665 | if f, ok := body.(File); ok { 666 | encoded, err = f.bytes() 667 | } else { 668 | encoded, err = xml.Marshal(body) 669 | } 670 | if err != nil { 671 | return nil, err 672 | } 673 | response := NewBytesResponse(status, encoded) 674 | response.Header.Set("Content-Type", "application/xml") 675 | return response, nil 676 | } 677 | 678 | // NewXmlResponder creates a [Responder] from a given body (as an 679 | // any that is encoded to XML) and status code. 680 | // 681 | // To pass the content of an existing file as body use [File] as in: 682 | // 683 | // httpmock.NewXmlResponder(200, httpmock.File("body.xml")) 684 | func NewXmlResponder(status int, body any) (Responder, error) { //nolint: revive,staticcheck 685 | resp, err := NewXmlResponse(status, body) 686 | if err != nil { 687 | return nil, err 688 | } 689 | return ResponderFromResponse(resp), nil 690 | } 691 | 692 | // NewXmlResponderOrPanic is like [NewXmlResponder] but panics in case 693 | // of error. 694 | // 695 | // It simplifies the call of [RegisterResponder], avoiding the use of a 696 | // temporary variable and an error check, and so can be used as 697 | // [NewStringResponder] or [NewBytesResponder] in such context: 698 | // 699 | // httpmock.RegisterResponder( 700 | // "GET", 701 | // "/test/path", 702 | // httpmock.NewXmlResponderOrPanic(200, &MyBody), 703 | // ) 704 | // 705 | // To pass the content of an existing file as body use [File] as in: 706 | // 707 | // httpmock.NewXmlResponderOrPanic(200, httpmock.File("body.xml")) 708 | func NewXmlResponderOrPanic(status int, body any) Responder { //nolint: revive,staticcheck 709 | responder, err := NewXmlResponder(status, body) 710 | if err != nil { 711 | panic(err) 712 | } 713 | return responder 714 | } 715 | 716 | // NewRespBodyFromString creates an [io.ReadCloser] from a string that 717 | // is suitable for use as an HTTP response body. 718 | // 719 | // To pass the content of an existing file as body use [File] as in: 720 | // 721 | // httpmock.NewRespBodyFromString(httpmock.File("body.txt").String()) 722 | func NewRespBodyFromString(body string) io.ReadCloser { 723 | return &dummyReadCloser{orig: body} 724 | } 725 | 726 | // NewRespBodyFromBytes creates an [io.ReadCloser] from a byte slice 727 | // that is suitable for use as an HTTP response body. 728 | // 729 | // To pass the content of an existing file as body use [File] as in: 730 | // 731 | // httpmock.NewRespBodyFromBytes(httpmock.File("body.txt").Bytes()) 732 | func NewRespBodyFromBytes(body []byte) io.ReadCloser { 733 | return &dummyReadCloser{orig: body} 734 | } 735 | 736 | type lenReadSeeker interface { 737 | io.ReadSeeker 738 | Len() int 739 | } 740 | 741 | type dummyReadCloser struct { 742 | orig any // string or []byte 743 | body lenReadSeeker // instanciated on demand from orig 744 | } 745 | 746 | // copy returns a new instance resetting d.body to nil. 747 | func (d *dummyReadCloser) copy() *dummyReadCloser { 748 | return &dummyReadCloser{orig: d.orig} 749 | } 750 | 751 | // setup ensures d.body is correctly initialized. 752 | func (d *dummyReadCloser) setup() { 753 | if d.body == nil { 754 | switch body := d.orig.(type) { 755 | case string: 756 | d.body = strings.NewReader(body) 757 | case []byte: 758 | d.body = bytes.NewReader(body) 759 | case io.ReadCloser: 760 | var buf bytes.Buffer 761 | io.Copy(&buf, body) //nolint: errcheck 762 | body.Close() //nolint: errcheck 763 | d.body = bytes.NewReader(buf.Bytes()) 764 | } 765 | } 766 | } 767 | 768 | func (d *dummyReadCloser) Read(p []byte) (n int, err error) { 769 | d.setup() 770 | return d.body.Read(p) 771 | } 772 | 773 | func (d *dummyReadCloser) Close() error { 774 | d.setup() 775 | d.body.Seek(0, io.SeekEnd) //nolint: errcheck 776 | return nil 777 | } 778 | 779 | func (d *dummyReadCloser) Len() int { 780 | d.setup() 781 | return d.body.Len() 782 | } 783 | -------------------------------------------------------------------------------- /response_test.go: -------------------------------------------------------------------------------- 1 | package httpmock_test 2 | 3 | import ( 4 | "encoding/xml" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "testing" 14 | "time" 15 | 16 | "github.com/maxatome/go-testdeep/td" 17 | 18 | "github.com/jarcoal/httpmock" 19 | "github.com/jarcoal/httpmock/internal" 20 | ) 21 | 22 | func TestResponderFromResponse(t *testing.T) { 23 | assert, require := td.AssertRequire(t) 24 | 25 | responder := httpmock.ResponderFromResponse(httpmock.NewStringResponse(200, "hello world")) 26 | 27 | req, err := http.NewRequest(http.MethodGet, testURL, nil) 28 | require.CmpNoError(err) 29 | 30 | response1, err := responder(req) 31 | require.CmpNoError(err) 32 | 33 | testURLWithQuery := testURL + "?a=1" 34 | req, err = http.NewRequest(http.MethodGet, testURLWithQuery, nil) 35 | require.CmpNoError(err) 36 | 37 | response2, err := responder(req) 38 | require.CmpNoError(err) 39 | 40 | // Body should be the same for both responses 41 | assertBody(assert, response1, "hello world") 42 | assertBody(assert, response2, "hello world") 43 | 44 | // Request should be non-nil and different for each response 45 | require.NotNil(response1.Request) 46 | assert.String(response1.Request.URL, testURL) 47 | 48 | require.NotNil(response2.Request) 49 | assert.String(response2.Request.URL, testURLWithQuery) 50 | } 51 | 52 | func TestResponderFromResponses(t *testing.T) { 53 | assert, require := td.AssertRequire(t) 54 | 55 | jsonResponse, err := httpmock.NewJsonResponse(200, map[string]string{"test": "toto"}) 56 | require.CmpNoError(err) 57 | 58 | responder := httpmock.ResponderFromMultipleResponses( 59 | []*http.Response{ 60 | jsonResponse, 61 | httpmock.NewStringResponse(200, "hello world"), 62 | }, 63 | ) 64 | 65 | req, err := http.NewRequest(http.MethodGet, testURL, nil) 66 | require.CmpNoError(err) 67 | 68 | response1, err := responder(req) 69 | require.CmpNoError(err) 70 | 71 | testURLWithQuery := testURL + "?a=1" 72 | req, err = http.NewRequest(http.MethodGet, testURLWithQuery, nil) 73 | require.CmpNoError(err) 74 | 75 | response2, err := responder(req) 76 | require.CmpNoError(err) 77 | 78 | // Body should be the same for both responses 79 | assertBody(assert, response1, `{"test":"toto"}`) 80 | assertBody(assert, response2, "hello world") 81 | 82 | // Request should be non-nil and different for each response 83 | require.NotNil(response1.Request) 84 | assert.String(response1.Request.URL, testURL) 85 | 86 | require.NotNil(response2.Request) 87 | assert.String(response2.Request.URL, testURLWithQuery) 88 | 89 | // ensure we can't call the responder more than the number of responses it embeds 90 | _, err = responder(req) 91 | assert.String(err, "not enough responses provided: responder called 3 time(s) but 2 response(s) provided") 92 | 93 | // fn usage 94 | responder = httpmock.ResponderFromMultipleResponses([]*http.Response{}, func(args ...interface{}) {}) 95 | _, err = responder(req) 96 | assert.String(err, "not enough responses provided: responder called 1 time(s) but 0 response(s) provided") 97 | if assert.Isa(err, internal.StackTracer{}) { 98 | assert.NotNil(err.(internal.StackTracer).CustomFn) 99 | } 100 | } 101 | 102 | func TestNewNotFoundResponder(t *testing.T) { 103 | assert, require := td.AssertRequire(t) 104 | 105 | responder := httpmock.NewNotFoundResponder(func(args ...interface{}) {}) 106 | 107 | req, err := http.NewRequest("GET", "http://foo.bar/path", nil) 108 | require.CmpNoError(err) 109 | 110 | const title = "Responder not found for GET http://foo.bar/path" 111 | 112 | resp, err := responder(req) 113 | assert.Nil(resp) 114 | assert.String(err, title) 115 | if assert.Isa(err, internal.StackTracer{}) { 116 | assert.NotNil(err.(internal.StackTracer).CustomFn) 117 | } 118 | 119 | // nil fn 120 | responder = httpmock.NewNotFoundResponder(nil) 121 | 122 | resp, err = responder(req) 123 | assert.Nil(resp) 124 | assert.String(err, title) 125 | if assert.Isa(err, internal.StackTracer{}) { 126 | assert.Nil(err.(internal.StackTracer).CustomFn) 127 | } 128 | } 129 | 130 | func TestNewStringResponse(t *testing.T) { 131 | assert, require := td.AssertRequire(t) 132 | 133 | const ( 134 | body = "hello world" 135 | status = 200 136 | ) 137 | response := httpmock.NewStringResponse(status, body) 138 | 139 | data, err := io.ReadAll(response.Body) 140 | require.CmpNoError(err) 141 | 142 | assert.String(data, body) 143 | assert.Cmp(response.StatusCode, status) 144 | } 145 | 146 | func TestNewBytesResponse(t *testing.T) { 147 | assert, require := td.AssertRequire(t) 148 | 149 | const ( 150 | body = "hello world" 151 | status = 200 152 | ) 153 | response := httpmock.NewBytesResponse(status, []byte(body)) 154 | 155 | data, err := io.ReadAll(response.Body) 156 | require.CmpNoError(err) 157 | 158 | assert.String(data, body) 159 | assert.Cmp(response.StatusCode, status) 160 | } 161 | 162 | func TestNewJsonResponse(t *testing.T) { 163 | assert := td.Assert(t) 164 | 165 | type schema struct { 166 | Hello string `json:"hello"` 167 | } 168 | 169 | dir := assert.TempDir() 170 | fileName := filepath.Join(dir, "ok.json") 171 | writeFile(assert, fileName, []byte(`{ "test": true }`)) 172 | 173 | for i, test := range []struct { 174 | body interface{} 175 | expected string 176 | }{ 177 | {body: &schema{"world"}, expected: `{"hello":"world"}`}, 178 | {body: httpmock.File(fileName), expected: `{"test":true}`}, 179 | } { 180 | assert.Run(fmt.Sprintf("#%d", i), func(assert *td.T) { 181 | response, err := httpmock.NewJsonResponse(200, test.body) 182 | if !assert.CmpNoError(err) { 183 | return 184 | } 185 | assert.Cmp(response.StatusCode, 200) 186 | assert.Cmp(response.Header.Get("Content-Type"), "application/json") 187 | assertBody(assert, response, test.expected) 188 | }) 189 | } 190 | 191 | // Error case 192 | response, err := httpmock.NewJsonResponse(200, func() {}) 193 | assert.CmpError(err) 194 | assert.Nil(response) 195 | } 196 | 197 | func TestNewJsonResponseOrPanic(t *testing.T) { 198 | assert := td.Assert(t) 199 | 200 | type schema struct { 201 | Hello string `json:"hello"` 202 | } 203 | 204 | dir := assert.TempDir() 205 | fileName := filepath.Join(dir, "ok.json") 206 | writeFile(assert, fileName, []byte(`{ "test": true }`)) 207 | 208 | for i, test := range []struct { 209 | body interface{} 210 | expected string 211 | }{ 212 | {body: &schema{"world"}, expected: `{"hello":"world"}`}, 213 | {body: httpmock.File(fileName), expected: `{"test":true}`}, 214 | } { 215 | assert.Run(fmt.Sprintf("#%d", i), func(assert *td.T) { 216 | assert.CmpNotPanic(func() { 217 | response := httpmock.NewJsonResponseOrPanic(200, test.body) 218 | assert.Cmp(response.StatusCode, 200) 219 | assert.Cmp(response.Header.Get("Content-Type"), "application/json") 220 | assertBody(assert, response, test.expected) 221 | }) 222 | }) 223 | } 224 | 225 | // Error case 226 | assert.CmpPanic( 227 | func() { httpmock.NewJsonResponseOrPanic(200, func() {}) }, 228 | td.Contains("json: unsupported type")) 229 | } 230 | 231 | func checkResponder(assert *td.T, r httpmock.Responder, expectedStatus int, expectedBody string) { 232 | assert.Helper() 233 | 234 | req, err := http.NewRequest(http.MethodGet, "/foo", nil) 235 | assert.FailureIsFatal().CmpNoError(err) 236 | 237 | resp, err := r(req) 238 | if !assert.CmpNoError(err, "Responder returned no error") { 239 | return 240 | } 241 | 242 | if !assert.NotNil(resp, "Responder returned a non-nil response") { 243 | return 244 | } 245 | 246 | assert.Cmp(resp.StatusCode, expectedStatus, "Status code is OK") 247 | assertBody(assert, resp, expectedBody) 248 | } 249 | 250 | func TestNewJsonResponder(t *testing.T) { 251 | assert := td.Assert(t) 252 | 253 | assert.Run("OK", func(assert *td.T) { 254 | r, err := httpmock.NewJsonResponder(200, map[string]int{"foo": 42}) 255 | if assert.CmpNoError(err) { 256 | checkResponder(assert, r, 200, `{"foo":42}`) 257 | } 258 | }) 259 | 260 | assert.Run("OK file", func(assert *td.T) { 261 | dir := assert.TempDir() 262 | fileName := filepath.Join(dir, "ok.json") 263 | writeFile(assert, fileName, []byte(`{ "foo" : 42 }`)) 264 | 265 | r, err := httpmock.NewJsonResponder(200, httpmock.File(fileName)) 266 | if assert.CmpNoError(err) { 267 | checkResponder(assert, r, 200, `{"foo":42}`) 268 | } 269 | }) 270 | 271 | assert.Run("Error", func(assert *td.T) { 272 | r, err := httpmock.NewJsonResponder(200, func() {}) 273 | assert.CmpError(err) 274 | assert.Nil(r) 275 | }) 276 | 277 | assert.Run("OK don't panic", func(assert *td.T) { 278 | assert.CmpNotPanic( 279 | func() { 280 | r := httpmock.NewJsonResponderOrPanic(200, map[string]int{"foo": 42}) 281 | checkResponder(assert, r, 200, `{"foo":42}`) 282 | }) 283 | }) 284 | 285 | assert.Run("Panic", func(assert *td.T) { 286 | assert.CmpPanic( 287 | func() { httpmock.NewJsonResponderOrPanic(200, func() {}) }, 288 | td.Ignore()) 289 | }) 290 | } 291 | 292 | type schemaXML struct { 293 | Hello string `xml:"hello"` 294 | } 295 | 296 | func TestNewXmlResponse(t *testing.T) { 297 | assert := td.Assert(t) 298 | 299 | body := &schemaXML{"world"} 300 | 301 | b, err := xml.Marshal(body) 302 | if err != nil { 303 | t.Fatalf("Cannot xml.Marshal expected body: %s", err) 304 | } 305 | expectedBody := string(b) 306 | 307 | dir := assert.TempDir() 308 | fileName := filepath.Join(dir, "ok.xml") 309 | writeFile(assert, fileName, b) 310 | 311 | for i, test := range []struct { 312 | body interface{} 313 | expected string 314 | }{ 315 | {body: body, expected: expectedBody}, 316 | {body: httpmock.File(fileName), expected: expectedBody}, 317 | } { 318 | assert.Run(fmt.Sprintf("#%d", i), func(assert *td.T) { 319 | response, err := httpmock.NewXmlResponse(200, test.body) 320 | if !assert.CmpNoError(err) { 321 | return 322 | } 323 | assert.Cmp(response.StatusCode, 200) 324 | assert.Cmp(response.Header.Get("Content-Type"), "application/xml") 325 | assertBody(assert, response, test.expected) 326 | }) 327 | } 328 | 329 | // Error case 330 | response, err := httpmock.NewXmlResponse(200, func() {}) 331 | assert.CmpError(err) 332 | assert.Nil(response) 333 | } 334 | 335 | func TestNewXmlResponder(t *testing.T) { 336 | assert, require := td.AssertRequire(t) 337 | 338 | body := &schemaXML{"world"} 339 | 340 | b, err := xml.Marshal(body) 341 | require.CmpNoError(err) 342 | expectedBody := string(b) 343 | 344 | assert.Run("OK", func(assert *td.T) { 345 | r, err := httpmock.NewXmlResponder(200, body) 346 | if assert.CmpNoError(err) { 347 | checkResponder(assert, r, 200, expectedBody) 348 | } 349 | }) 350 | 351 | assert.Run("OK file", func(assert *td.T) { 352 | dir := assert.TempDir() 353 | fileName := filepath.Join(dir, "ok.xml") 354 | writeFile(assert, fileName, b) 355 | 356 | r, err := httpmock.NewXmlResponder(200, httpmock.File(fileName)) 357 | if assert.CmpNoError(err) { 358 | checkResponder(assert, r, 200, expectedBody) 359 | } 360 | }) 361 | 362 | assert.Run("Error", func(assert *td.T) { 363 | r, err := httpmock.NewXmlResponder(200, func() {}) 364 | assert.CmpError(err) 365 | assert.Nil(r) 366 | }) 367 | 368 | assert.Run("OK don't panic", func(assert *td.T) { 369 | assert.CmpNotPanic( 370 | func() { 371 | r := httpmock.NewXmlResponderOrPanic(200, body) 372 | checkResponder(assert, r, 200, expectedBody) 373 | }) 374 | }) 375 | 376 | assert.Run("Panic", func(assert *td.T) { 377 | assert.CmpPanic( 378 | func() { httpmock.NewXmlResponderOrPanic(200, func() {}) }, 379 | td.Ignore()) 380 | }) 381 | } 382 | 383 | func TestNewErrorResponder(t *testing.T) { 384 | assert, require := td.AssertRequire(t) 385 | 386 | origError := errors.New("oh no") 387 | responder := httpmock.NewErrorResponder(origError) 388 | 389 | req, err := http.NewRequest(http.MethodGet, testURL, nil) 390 | require.CmpNoError(err) 391 | 392 | response, err := responder(req) 393 | assert.Cmp(err, origError) 394 | assert.Nil(response) 395 | } 396 | 397 | func TestResponseBody(t *testing.T) { 398 | assert := td.Assert(t) 399 | 400 | const ( 401 | body = "hello world" 402 | status = 200 403 | ) 404 | 405 | assert.Run("http.Response", func(assert *td.T) { 406 | for i, response := range []*http.Response{ 407 | httpmock.NewBytesResponse(status, []byte(body)), 408 | httpmock.NewStringResponse(status, body), 409 | } { 410 | assert.Run(fmt.Sprintf("resp #%d", i), func(assert *td.T) { 411 | assertBody(assert, response, body) 412 | 413 | assert.Cmp(response.StatusCode, status) 414 | 415 | var buf [1]byte 416 | _, err := response.Body.Read(buf[:]) 417 | assert.Cmp(err, io.EOF) 418 | }) 419 | } 420 | }) 421 | 422 | assert.Run("Responder", func(assert *td.T) { 423 | for i, responder := range []httpmock.Responder{ 424 | httpmock.NewBytesResponder(200, []byte(body)), 425 | httpmock.NewStringResponder(200, body), 426 | } { 427 | assert.Run(fmt.Sprintf("resp #%d", i), func(assert *td.T) { 428 | req, _ := http.NewRequest("GET", "http://foo.bar", nil) 429 | response, err := responder(req) 430 | if !assert.CmpNoError(err) { 431 | return 432 | } 433 | 434 | assertBody(assert, response, body) 435 | 436 | var buf [1]byte 437 | _, err = response.Body.Read(buf[:]) 438 | assert.Cmp(err, io.EOF) 439 | }) 440 | } 441 | }) 442 | } 443 | 444 | func TestResponder(t *testing.T) { 445 | req, err := http.NewRequest(http.MethodGet, "http://foo.bar", nil) 446 | td.Require(t).CmpNoError(err) 447 | 448 | resp := &http.Response{} 449 | 450 | chk := func(r httpmock.Responder, expectedResp *http.Response, expectedErr string) { 451 | t.Helper() 452 | gotResp, gotErr := r(req) 453 | td.CmpShallow(t, gotResp, expectedResp) 454 | var gotErrStr string 455 | if gotErr != nil { 456 | gotErrStr = gotErr.Error() 457 | } 458 | td.Cmp(t, gotErrStr, expectedErr) 459 | } 460 | called := false 461 | chkNotCalled := func() { 462 | if called { 463 | t.Helper() 464 | t.Errorf("Original responder should not be called") 465 | called = false 466 | } 467 | } 468 | chkCalled := func() { 469 | if !called { 470 | t.Helper() 471 | t.Errorf("Original responder should be called") 472 | } 473 | called = false 474 | } 475 | 476 | r := httpmock.Responder(func(*http.Request) (*http.Response, error) { 477 | called = true 478 | return resp, nil 479 | }) 480 | chk(r, resp, "") 481 | chkCalled() 482 | 483 | // 484 | // Once 485 | ro := r.Once() 486 | chk(ro, resp, "") 487 | chkCalled() 488 | 489 | chk(ro, nil, "Responder not found for GET http://foo.bar (coz Once and already called 2 times)") 490 | chkNotCalled() 491 | 492 | chk(ro, nil, "Responder not found for GET http://foo.bar (coz Once and already called 3 times)") 493 | chkNotCalled() 494 | 495 | ro = r.Once(func(args ...interface{}) {}) 496 | chk(ro, resp, "") 497 | chkCalled() 498 | 499 | chk(ro, nil, "Responder not found for GET http://foo.bar (coz Once and already called 2 times)") 500 | chkNotCalled() 501 | 502 | // 503 | // Times 504 | rt := r.Times(2) 505 | chk(rt, resp, "") 506 | chkCalled() 507 | 508 | chk(rt, resp, "") 509 | chkCalled() 510 | 511 | chk(rt, nil, "Responder not found for GET http://foo.bar (coz Times and already called 3 times)") 512 | chkNotCalled() 513 | 514 | chk(rt, nil, "Responder not found for GET http://foo.bar (coz Times and already called 4 times)") 515 | chkNotCalled() 516 | 517 | rt = r.Times(1, func(args ...interface{}) {}) 518 | chk(rt, resp, "") 519 | chkCalled() 520 | 521 | chk(rt, nil, "Responder not found for GET http://foo.bar (coz Times and already called 2 times)") 522 | chkNotCalled() 523 | 524 | // 525 | // Trace 526 | rt = r.Trace(func(args ...interface{}) {}) 527 | chk(rt, resp, "") 528 | chkCalled() 529 | 530 | chk(rt, resp, "") 531 | chkCalled() 532 | 533 | // 534 | // Delay 535 | rt = r.Delay(100 * time.Millisecond) 536 | before := time.Now() 537 | chk(rt, resp, "") 538 | duration := time.Since(before) 539 | chkCalled() 540 | td.Cmp(t, duration, td.Gte(100*time.Millisecond), "Responder is delayed") 541 | } 542 | 543 | func TestResponder_Then(t *testing.T) { 544 | assert, require := td.AssertRequire(t) 545 | 546 | req, err := http.NewRequest(http.MethodGet, "http://foo.bar", nil) 547 | require.CmpNoError(err) 548 | 549 | // 550 | // Then 551 | var stack string 552 | newResponder := func(level string) httpmock.Responder { 553 | return func(*http.Request) (*http.Response, error) { 554 | stack += level 555 | return httpmock.NewStringResponse(200, level), nil 556 | } 557 | } 558 | var rt httpmock.Responder 559 | chk := func(assert *td.T, expectedLevel, expectedStack string) { 560 | assert.Helper() 561 | resp, err := rt(req) 562 | if !assert.CmpNoError(err, "Responder call") { 563 | return 564 | } 565 | b, err := io.ReadAll(resp.Body) 566 | if !assert.CmpNoError(err, "Read response") { 567 | return 568 | } 569 | assert.String(b, expectedLevel) 570 | assert.Cmp(stack, expectedStack) 571 | } 572 | 573 | A, B, C := newResponder("A"), newResponder("B"), newResponder("C") 574 | D, E, F := newResponder("D"), newResponder("E"), newResponder("F") 575 | 576 | assert.Run("simple", func(assert *td.T) { 577 | // (r=A,then=B) 578 | rt = A.Then(B) 579 | 580 | chk(assert, "A", "A") 581 | chk(assert, "B", "AB") 582 | chk(assert, "B", "ABB") 583 | chk(assert, "B", "ABBB") 584 | }) 585 | 586 | stack = "" 587 | 588 | assert.Run("simple chained", func(assert *td.T) { 589 | // (r=A,then=B) 590 | // (r=↑,then=C) 591 | // (r=↑,then=D) 592 | // (r=↑,then=E) 593 | // (r=↑,then=F) 594 | rt = A.Then(B). 595 | Then(C). 596 | Then(D). 597 | Then(E). 598 | Then(F) 599 | 600 | chk(assert, "A", "A") 601 | chk(assert, "B", "AB") 602 | chk(assert, "C", "ABC") 603 | chk(assert, "D", "ABCD") 604 | chk(assert, "E", "ABCDE") 605 | chk(assert, "F", "ABCDEF") 606 | chk(assert, "F", "ABCDEFF") 607 | chk(assert, "F", "ABCDEFFF") 608 | }) 609 | 610 | stack = "" 611 | 612 | assert.Run("Then Responder as Then param", func(assert *td.T) { 613 | assert.CmpPanic( 614 | func() { A.Then(B.Then(C)) }, 615 | "Then() does not accept another Then() Responder as parameter") 616 | }) 617 | } 618 | 619 | func TestResponder_SetContentLength(t *testing.T) { 620 | assert, require := td.AssertRequire(t) 621 | 622 | req, err := http.NewRequest(http.MethodGet, "http://foo.bar", nil) 623 | require.CmpNoError(err) 624 | 625 | testCases := []struct { 626 | name string 627 | r httpmock.Responder 628 | expLen int 629 | }{ 630 | { 631 | name: "nil body", 632 | r: httpmock.ResponderFromResponse(&http.Response{ 633 | StatusCode: 200, 634 | ContentLength: -1, 635 | }), 636 | expLen: 0, 637 | }, 638 | { 639 | name: "http.NoBody", 640 | r: httpmock.ResponderFromResponse(&http.Response{ 641 | Body: http.NoBody, 642 | StatusCode: 200, 643 | ContentLength: -1, 644 | }), 645 | expLen: 0, 646 | }, 647 | { 648 | name: "string", 649 | r: httpmock.NewStringResponder(200, "BODY"), 650 | expLen: 4, 651 | }, 652 | { 653 | name: "bytes", 654 | r: httpmock.NewBytesResponder(200, []byte("BODY")), 655 | expLen: 4, 656 | }, 657 | { 658 | name: "from response OK", 659 | r: httpmock.ResponderFromResponse(&http.Response{ 660 | Body: httpmock.NewRespBodyFromString("BODY"), 661 | StatusCode: 200, 662 | ContentLength: -1, 663 | }), 664 | expLen: 4, 665 | }, 666 | { 667 | name: "custom without Len", 668 | r: func(req *http.Request) (*http.Response, error) { 669 | return &http.Response{ 670 | Body: io.NopCloser(strings.NewReader("BODY")), 671 | StatusCode: 200, 672 | ContentLength: -1, 673 | }, nil 674 | }, 675 | expLen: 4, 676 | }, 677 | } 678 | for _, tc := range testCases { 679 | assert.Run(tc.name, func(assert *td.T) { 680 | sclr := tc.r.SetContentLength() 681 | 682 | for i := 1; i <= 3; i++ { 683 | assert.RunAssertRequire(fmt.Sprintf("#%d", i), func(assert, require *td.T) { 684 | resp, err := sclr(req) 685 | require.CmpNoError(err) 686 | assert.CmpLax(resp.ContentLength, tc.expLen) 687 | assert.Cmp(resp.Header.Get("Content-Length"), strconv.Itoa(tc.expLen)) 688 | }) 689 | } 690 | }) 691 | } 692 | 693 | assert.Run("error", func(assert *td.T) { 694 | resp, err := httpmock.NewErrorResponder(errors.New("an error occurred")). 695 | SetContentLength()(req) 696 | assert.Nil(resp) 697 | assert.String(err, "an error occurred") 698 | }) 699 | } 700 | 701 | func TestResponder_HeaderAddSet(t *testing.T) { 702 | assert, require := td.AssertRequire(t) 703 | 704 | req, err := http.NewRequest(http.MethodGet, "http://foo.bar", nil) 705 | require.CmpNoError(err) 706 | 707 | orig := httpmock.NewStringResponder(200, "body") 708 | origNilHeader := httpmock.ResponderFromResponse(&http.Response{ 709 | Status: "200 OK", 710 | StatusCode: 200, 711 | Body: httpmock.NewRespBodyFromString("body"), 712 | ContentLength: -1, 713 | }) 714 | 715 | // until go1.17, http.Header cannot contain nil values after a Header.Clone() 716 | clonedNil := http.Header{"Nil": nil}.Clone()["Nil"] 717 | 718 | testCases := []struct { 719 | name string 720 | orig httpmock.Responder 721 | }{ 722 | {name: "orig", orig: orig}, 723 | {name: "nil header", orig: origNilHeader}, 724 | } 725 | assert.RunAssertRequire("HeaderAdd", func(assert, require *td.T) { 726 | for _, tc := range testCases { 727 | assert.RunAssertRequire(tc.name, func(assert, require *td.T) { 728 | r := tc.orig.HeaderAdd(http.Header{"foo": {"bar"}, "nil": nil}) 729 | resp, err := r(req) 730 | require.CmpNoError(err) 731 | assert.Cmp(resp.Header, http.Header{"Foo": {"bar"}, "Nil": nil}) 732 | 733 | r = r.HeaderAdd(http.Header{"foo": {"zip"}, "test": {"pipo"}}) 734 | resp, err = r(req) 735 | require.CmpNoError(err) 736 | assert.Cmp(resp.Header, http.Header{"Foo": {"bar", "zip"}, "Test": {"pipo"}, "Nil": clonedNil}) 737 | }) 738 | } 739 | 740 | resp, err := orig(req) 741 | require.CmpNoError(err) 742 | assert.Empty(resp.Header) 743 | }) 744 | 745 | assert.RunAssertRequire("HeaderSet", func(assert, require *td.T) { 746 | for _, tc := range testCases { 747 | assert.RunAssertRequire(tc.name, func(assert, require *td.T) { 748 | r := tc.orig.HeaderSet(http.Header{"foo": {"bar"}, "nil": nil}) 749 | resp, err := r(req) 750 | require.CmpNoError(err) 751 | assert.Cmp(resp.Header, http.Header{"Foo": {"bar"}, "Nil": nil}) 752 | 753 | r = r.HeaderSet(http.Header{"foo": {"zip"}, "test": {"pipo"}}) 754 | resp, err = r(req) 755 | require.CmpNoError(err) 756 | assert.Cmp(resp.Header, http.Header{"Foo": {"zip"}, "Test": {"pipo"}, "Nil": clonedNil}) 757 | }) 758 | } 759 | 760 | resp, err := orig(req) 761 | require.CmpNoError(err) 762 | assert.Empty(resp.Header) 763 | }) 764 | 765 | assert.Run("error", func(assert *td.T) { 766 | origErr := httpmock.NewErrorResponder(errors.New("an error occurred")) 767 | 768 | assert.Run("HeaderAdd", func(assert *td.T) { 769 | r := origErr.HeaderAdd(http.Header{"foo": {"bar"}}) 770 | resp, err := r(req) 771 | assert.Nil(resp) 772 | assert.String(err, "an error occurred") 773 | }) 774 | 775 | assert.Run("HeaderSet", func(assert *td.T) { 776 | r := origErr.HeaderSet(http.Header{"foo": {"bar"}}) 777 | resp, err := r(req) 778 | assert.Nil(resp) 779 | assert.String(err, "an error occurred") 780 | }) 781 | }) 782 | } 783 | 784 | func TestParallelResponder(t *testing.T) { 785 | req, err := http.NewRequest(http.MethodGet, "http://foo.bar", nil) 786 | td.Require(t).CmpNoError(err) 787 | 788 | body := strings.Repeat("ABC-", 1000) 789 | 790 | for ir, r := range []httpmock.Responder{ 791 | httpmock.NewStringResponder(200, body), 792 | httpmock.NewBytesResponder(200, []byte(body)), 793 | } { 794 | var wg sync.WaitGroup 795 | for n := 0; n < 100; n++ { 796 | wg.Add(1) 797 | go func() { 798 | defer wg.Done() 799 | resp, _ := r(req) 800 | b, err := io.ReadAll(resp.Body) 801 | td.CmpNoError(t, err, "resp #%d", ir) 802 | td.CmpLen(t, b, 4000, "resp #%d", ir) 803 | td.CmpHasPrefix(t, b, "ABC-", "resp #%d", ir) 804 | }() 805 | } 806 | wg.Wait() 807 | } 808 | } 809 | -------------------------------------------------------------------------------- /transport_test.go: -------------------------------------------------------------------------------- 1 | package httpmock_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "regexp" 14 | "strings" 15 | "testing" 16 | "time" 17 | 18 | "github.com/maxatome/go-testdeep/td" 19 | 20 | "github.com/jarcoal/httpmock" 21 | "github.com/jarcoal/httpmock/internal" 22 | ) 23 | 24 | const testURL = "http://www.example.com/" 25 | 26 | type errTransport struct{} 27 | 28 | var errTransportErr = errors.New("httpmock test error") 29 | 30 | // RoundTrip implements [http.RoundTripper] and always returns an error. 31 | func (errTransport) RoundTrip(*http.Request) (*http.Response, error) { 32 | return nil, errTransportErr 33 | } 34 | 35 | func TestMockTransport(t *testing.T) { 36 | httpmock.Activate(t) 37 | 38 | url := "https://github.com/foo/bar" 39 | body := `["hello world"]` + "\n" 40 | 41 | httpmock.RegisterResponder("GET", url, httpmock.NewStringResponder(200, body)) 42 | httpmock.RegisterResponder("GET", `=~/xxx\z`, httpmock.NewStringResponder(200, body)) 43 | 44 | assert := td.Assert(t) 45 | 46 | // Read it as a simple string (io.ReadAll of assertBody will 47 | // trigger io.EOF) 48 | assert.RunAssertRequire("simple", func(assert, require *td.T) { 49 | resp, err := http.Get(url) 50 | require.CmpNoError(err) 51 | assertBody(assert, resp, body) 52 | 53 | // the http client wraps our NoResponderFound error, so we just try and match on text 54 | _, err = http.Get(testURL) 55 | assert.HasSuffix(err, httpmock.NoResponderFound.Error()) 56 | 57 | // Use wrongly cased method, the error should warn us 58 | req, err := http.NewRequest("Get", url, nil) 59 | require.CmpNoError(err) 60 | 61 | c := http.Client{} 62 | _, err = c.Do(req) 63 | assert.HasSuffix(err, httpmock.NoResponderFound.Error()+` for method "Get", but one matches method "GET"`) 64 | 65 | // Use POST instead of GET, the error should warn us 66 | req, err = http.NewRequest("POST", url, nil) 67 | require.CmpNoError(err) 68 | 69 | _, err = c.Do(req) 70 | assert.HasSuffix(err, httpmock.NoResponderFound.Error()+` for method "POST", but one matches method "GET"`) 71 | 72 | // Same using a regexp responder 73 | req, err = http.NewRequest("POST", "http://pipo.com/xxx", nil) 74 | require.CmpNoError(err) 75 | 76 | _, err = c.Do(req) 77 | assert.HasSuffix(err, httpmock.NoResponderFound.Error()+` for method "POST", but one matches method "GET"`) 78 | 79 | // Use a URL with squashable "/" in path 80 | _, err = http.Get("https://github.com////foo//bar") 81 | assert.HasSuffix(err, httpmock.NoResponderFound.Error()+` for URL "https://github.com////foo//bar", but one matches URL "https://github.com/foo/bar"`) 82 | 83 | // Use a URL terminated by "/" 84 | _, err = http.Get("https://github.com/foo/bar/") 85 | assert.HasSuffix(err, httpmock.NoResponderFound.Error()+` for URL "https://github.com/foo/bar/", but one matches URL "https://github.com/foo/bar"`) 86 | }) 87 | 88 | // Do it again, but twice with json decoder (json Decode will not 89 | // reach EOF, but Close is called as the JSON response is complete) 90 | for i := 1; i <= 2; i++ { 91 | assert.RunAssertRequire(fmt.Sprintf("try #%d", i), func(assert, require *td.T) { 92 | resp, err := http.Get(url) 93 | require.CmpNoError(err) 94 | defer resp.Body.Close() //nolint: errcheck 95 | 96 | var res []string 97 | err = json.NewDecoder(resp.Body).Decode(&res) 98 | require.CmpNoError(err) 99 | 100 | assert.Cmp(res, []string{"hello world"}) 101 | }) 102 | } 103 | } 104 | 105 | func TestRegisterMatcherResponder(t *testing.T) { 106 | httpmock.Activate(t) 107 | 108 | httpmock.RegisterMatcherResponder("POST", "/foo", 109 | httpmock.NewMatcher( 110 | "00-header-foo=bar", 111 | func(r *http.Request) bool { return r.Header.Get("Foo") == "bar" }, 112 | ), 113 | httpmock.NewStringResponder(200, "header-foo")) 114 | 115 | httpmock.RegisterMatcherResponder("POST", "/foo", 116 | httpmock.NewMatcher( 117 | "01-body-BAR", 118 | func(r *http.Request) bool { 119 | b, err := io.ReadAll(r.Body) 120 | return err == nil && bytes.Contains(b, []byte("BAR")) 121 | }), 122 | httpmock.NewStringResponder(200, "body-BAR")) 123 | 124 | httpmock.RegisterMatcherResponder("POST", "/foo", 125 | httpmock.NewMatcher( 126 | "02-body-FOO", 127 | func(r *http.Request) bool { 128 | b, err := io.ReadAll(r.Body) 129 | return err == nil && bytes.Contains(b, []byte("FOO")) 130 | }), 131 | httpmock.NewStringResponder(200, "body-FOO")) 132 | 133 | httpmock.RegisterMatcherResponder("POST", "/foo", 134 | httpmock.BodyContainsString("xxx"). 135 | Or(httpmock.BodyContainsString("yyy")). 136 | WithName("03-body-xxx|yyy"), 137 | httpmock.NewStringResponder(200, "body-xxx|yyy")) 138 | 139 | httpmock.RegisterResponder("POST", "/foo", httpmock.NewStringResponder(200, "default")) 140 | 141 | httpmock.RegisterNoResponder(httpmock.NewNotFoundResponder(nil)) 142 | 143 | testCases := []struct { 144 | name string 145 | body string 146 | fooHeader string 147 | expectedBody string 148 | }{ 149 | { 150 | name: "header", 151 | body: "pipo", 152 | fooHeader: "bar", 153 | expectedBody: "header-foo", 154 | }, 155 | { 156 | name: "header+body=header", 157 | body: "BAR", 158 | fooHeader: "bar", 159 | expectedBody: "header-foo", 160 | }, 161 | { 162 | name: "body BAR", 163 | body: "BAR", 164 | fooHeader: "xxx", 165 | expectedBody: "body-BAR", 166 | }, 167 | { 168 | name: "body FOO", 169 | body: "FOO", 170 | expectedBody: "body-FOO", 171 | }, 172 | { 173 | name: "body xxx", 174 | body: "...xxx...", 175 | expectedBody: "body-xxx|yyy", 176 | }, 177 | { 178 | name: "body yyy", 179 | body: "...yyy...", 180 | expectedBody: "body-xxx|yyy", 181 | }, 182 | { 183 | name: "default", 184 | body: "ANYTHING", 185 | fooHeader: "zzz", 186 | expectedBody: "default", 187 | }, 188 | } 189 | assert := td.Assert(t) 190 | for _, tc := range testCases { 191 | assert.RunAssertRequire(tc.name, func(assert, require *td.T) { 192 | req, err := http.NewRequest( 193 | "POST", 194 | "http://test.com/foo", 195 | strings.NewReader(tc.body), 196 | ) 197 | require.CmpNoError(err) 198 | 199 | req.Header.Set("Content-Type", "text/plain") 200 | if tc.fooHeader != "" { 201 | req.Header.Set("Foo", tc.fooHeader) 202 | } 203 | 204 | resp, err := http.DefaultClient.Do(req) 205 | require.CmpNoError(err) 206 | 207 | assertBody(assert, resp, tc.expectedBody) 208 | }) 209 | } 210 | 211 | // Remove the default responder 212 | httpmock.RegisterResponder("POST", "/foo", nil) 213 | 214 | assert.Run("not found despite 3", func(assert *td.T) { 215 | _, err := http.Post( 216 | "http://test.com/foo", 217 | "text/plain", 218 | strings.NewReader("ANYTHING"), 219 | ) 220 | assert.HasSuffix(err, `Responder not found for POST http://test.com/foo despite 4 matchers: ["00-header-foo=bar" "01-body-BAR" "02-body-FOO" "03-body-xxx|yyy"]`) 221 | }) 222 | 223 | // Remove 3 matcher responders 224 | httpmock.RegisterMatcherResponder("POST", "/foo", httpmock.NewMatcher("01-body-BAR", nil), nil) 225 | httpmock.RegisterMatcherResponder("POST", "/foo", httpmock.NewMatcher("02-body-FOO", nil), nil) 226 | httpmock.RegisterMatcherResponder("POST", "/foo", httpmock.NewMatcher("03-body-xxx|yyy", nil), nil) 227 | 228 | assert.Run("not found despite 1", func(assert *td.T) { 229 | _, err := http.Post( 230 | "http://test.com/foo", 231 | "text/plain", 232 | strings.NewReader("ANYTHING"), 233 | ) 234 | assert.HasSuffix(err, `Responder not found for POST http://test.com/foo despite matcher "00-header-foo=bar"`) 235 | }) 236 | 237 | // Add a regexp responder without a Matcher: as the exact match 238 | // didn't succeed because of the "00-header-foo=bar" Matcher, the 239 | // following one must be tried ans also succeed 240 | httpmock.RegisterResponder("POST", "=~^/foo", httpmock.NewStringResponder(200, "regexp")) 241 | 242 | assert.RunAssertRequire("default regexp", func(assert, require *td.T) { 243 | resp, err := http.Post( 244 | "http://test.com/foo", 245 | "text/plain", 246 | strings.NewReader("ANYTHING"), 247 | ) 248 | // The exact match responder "00-header-foo=bar" fails because of 249 | // its Matcher, so regexp responders have to be checked and ^/foo 250 | // has to match 251 | require.CmpNoError(err) 252 | assertBody(assert, resp, "regexp") 253 | }) 254 | 255 | // Remove the previous regexp responder 256 | httpmock.RegisterResponder("POST", "=~^/foo", nil) 257 | 258 | // Add a regexp Matcher responder that should match ZIP body 259 | httpmock.RegisterMatcherResponder("POST", "=~^/foo", 260 | httpmock.BodyContainsString("ZIP").WithName("10-body-ZIP"), 261 | httpmock.NewStringResponder(200, "body-ZIP")) 262 | 263 | assert.RunAssertRequire("regexp matcher OK", func(assert, require *td.T) { 264 | resp, err := http.Post( 265 | "http://test.com/foo", 266 | "text/plain", 267 | strings.NewReader("ZIP"), 268 | ) 269 | // The exact match responder "00-header-foo=bar" fails because of 270 | // its Matcher, so regexp responders have to be checked and ^/foo 271 | // + body ZIP has to match 272 | require.CmpNoError(err) 273 | assertBody(assert, resp, "body-ZIP") 274 | }) 275 | 276 | assert.Run("regexp matcher no match", func(assert *td.T) { 277 | _, err := http.Post( 278 | "http://test.com/foo", 279 | "text/plain", 280 | strings.NewReader("ANYTHING"), 281 | ) 282 | // The exact match responder "00-header-foo=bar" fails because of 283 | // its Matcher, so regexp responders have to be checked BUT none 284 | // match. In this case the returned error has to be the first 285 | // encountered, so the one corresponding to the exact match phase, 286 | // not the regexp one 287 | assert.HasSuffix(err, `Responder not found for POST http://test.com/foo despite matcher "00-header-foo=bar"`) 288 | }) 289 | } 290 | 291 | // We should be able to find GET handlers when using an http.Request with a 292 | // default (zero-value) .Method. 293 | func TestMockTransportDefaultMethod(t *testing.T) { 294 | assert, require := td.AssertRequire(t) 295 | 296 | httpmock.Activate(assert) 297 | 298 | const urlString = "https://github.com/" 299 | url, err := url.Parse(urlString) 300 | require.CmpNoError(err) 301 | body := "hello world" 302 | 303 | httpmock.RegisterResponder("GET", urlString, httpmock.NewStringResponder(200, body)) 304 | 305 | req := &http.Request{ 306 | URL: url, 307 | // Note: Method unspecified (zero-value) 308 | } 309 | 310 | client := &http.Client{} 311 | resp, err := client.Do(req) 312 | require.CmpNoError(err) 313 | 314 | assertBody(assert, resp, body) 315 | } 316 | 317 | func TestMockTransportReset(t *testing.T) { 318 | httpmock.DeactivateAndReset() 319 | 320 | td.CmpZero(t, httpmock.DefaultTransport.NumResponders(), 321 | "expected no responders at this point") 322 | td.Cmp(t, httpmock.DefaultTransport.Responders(), []string{}) 323 | 324 | r := httpmock.NewStringResponder(200, "hey") 325 | 326 | httpmock.RegisterResponder("GET", testURL, r) 327 | httpmock.RegisterResponder("POST", testURL, r) 328 | httpmock.RegisterResponder("PATCH", testURL, r) 329 | httpmock.RegisterResponder("GET", "/pipo/bingo", r) 330 | 331 | httpmock.RegisterResponder("GET", "=~/pipo/bingo", r) 332 | httpmock.RegisterResponder("GET", "=~/bingo/pipo", r) 333 | 334 | td.Cmp(t, httpmock.DefaultTransport.NumResponders(), 6, "expected one responder") 335 | td.Cmp(t, httpmock.DefaultTransport.Responders(), []string{ 336 | // Sorted by URL then method 337 | "GET /pipo/bingo", 338 | "GET " + testURL, 339 | "PATCH " + testURL, 340 | "POST " + testURL, 341 | // Regexp responders, in the same order they have been registered 342 | "GET =~/pipo/bingo", 343 | "GET =~/bingo/pipo", 344 | }) 345 | 346 | httpmock.Reset() 347 | 348 | td.CmpZero(t, httpmock.DefaultTransport.NumResponders(), 349 | "expected no responders as they were just reset") 350 | td.Cmp(t, httpmock.DefaultTransport.Responders(), []string{}) 351 | } 352 | 353 | func TestMockTransportNoResponder(t *testing.T) { 354 | httpmock.Activate(t) 355 | 356 | httpmock.Reset() 357 | 358 | _, err := http.Get(testURL) 359 | td.CmpError(t, err, "expected to receive a connection error due to lack of responders") 360 | 361 | httpmock.RegisterNoResponder(httpmock.NewStringResponder(200, "hello world")) 362 | 363 | resp, err := http.Get(testURL) 364 | if td.CmpNoError(t, err, "expected request to succeed") { 365 | assertBody(t, resp, "hello world") 366 | } 367 | 368 | // Using NewNotFoundResponder() 369 | httpmock.RegisterNoResponder(httpmock.NewNotFoundResponder(nil)) 370 | _, err = http.Get(testURL) 371 | td.CmpHasSuffix(t, err, "Responder not found for GET http://www.example.com/") 372 | 373 | const url = "http://www.example.com/foo/bar" 374 | httpmock.RegisterResponder("POST", url, httpmock.NewStringResponder(200, "hello world")) 375 | 376 | // Help the user in case a Responder exists for another method 377 | _, err = http.Get(url) 378 | td.CmpHasSuffix(t, err, `Responder not found for GET `+url+`, but one matches method "POST"`) 379 | 380 | // Help the user in case a Responder exists for another path without final "/" 381 | _, err = http.Post(url+"/", "", nil) 382 | td.CmpHasSuffix(t, err, `Responder not found for POST `+url+`/, but one matches URL "`+url+`"`) 383 | 384 | // Help the user in case a Responder exists for another path without double "/" 385 | _, err = http.Post("http://www.example.com///foo//bar", "", nil) 386 | td.CmpHasSuffix(t, err, `Responder not found for POST http://www.example.com///foo//bar, but one matches URL "`+url+`"`) 387 | } 388 | 389 | func TestMockTransportQuerystringFallback(t *testing.T) { 390 | assert := td.Assert(t) 391 | 392 | httpmock.Activate(assert) 393 | 394 | // register the testURL responder 395 | httpmock.RegisterResponder("GET", testURL, httpmock.NewStringResponder(200, "hello world")) 396 | 397 | for _, suffix := range []string{"?", "?hello=world", "?hello=world#foo", "?hello=world&hello=all", "#foo"} { 398 | assert.RunAssertRequire(suffix, func(assert, require *td.T) { 399 | reqURL := testURL + suffix 400 | 401 | // make a request for the testURL with a querystring 402 | resp, err := http.Get(reqURL) 403 | require.CmpNoError(err) 404 | 405 | assertBody(assert, resp, "hello world") 406 | }) 407 | } 408 | } 409 | 410 | func TestMockTransportPathOnlyFallback(t *testing.T) { 411 | // Just in case a panic occurs 412 | t.Cleanup(httpmock.DeactivateAndReset) 413 | 414 | for _, test := range []struct { 415 | Responder string 416 | Paths []string 417 | }{ 418 | { 419 | // unsorted query string matches exactly 420 | Responder: "/hello/world?query=string&abc=zz#fragment", 421 | Paths: []string{ 422 | testURL + "hello/world?query=string&abc=zz#fragment", 423 | }, 424 | }, 425 | { 426 | // sorted query string matches all cases 427 | Responder: "/hello/world?abc=zz&query=string#fragment", 428 | Paths: []string{ 429 | testURL + "hello/world?query=string&abc=zz#fragment", 430 | testURL + "hello/world?abc=zz&query=string#fragment", 431 | }, 432 | }, 433 | { 434 | // unsorted query string matches exactly 435 | Responder: "/hello/world?query=string&abc=zz", 436 | Paths: []string{ 437 | testURL + "hello/world?query=string&abc=zz", 438 | }, 439 | }, 440 | { 441 | // sorted query string matches all cases 442 | Responder: "/hello/world?abc=zz&query=string", 443 | Paths: []string{ 444 | testURL + "hello/world?query=string&abc=zz", 445 | testURL + "hello/world?abc=zz&query=string", 446 | }, 447 | }, 448 | { 449 | // unsorted query string matches exactly 450 | Responder: "/hello/world?query=string&query=string2&abc=zz", 451 | Paths: []string{ 452 | testURL + "hello/world?query=string&query=string2&abc=zz", 453 | }, 454 | }, 455 | // sorted query string matches all cases 456 | { 457 | Responder: "/hello/world?abc=zz&query=string&query=string2", 458 | Paths: []string{ 459 | testURL + "hello/world?query=string&query=string2&abc=zz", 460 | testURL + "hello/world?query=string2&query=string&abc=zz", 461 | testURL + "hello/world?abc=zz&query=string2&query=string", 462 | }, 463 | }, 464 | { 465 | Responder: "/hello/world?query", 466 | Paths: []string{ 467 | testURL + "hello/world?query", 468 | }, 469 | }, 470 | { 471 | Responder: "/hello/world?query&abc", 472 | Paths: []string{ 473 | testURL + "hello/world?query&abc", 474 | // testURL + "hello/world?abc&query" won't work as "=" is needed, see below 475 | }, 476 | }, 477 | { 478 | // In case the sorting does not matter for received params without 479 | // values, we must register params with "=" 480 | Responder: "/hello/world?abc=&query=", 481 | Paths: []string{ 482 | testURL + "hello/world?query&abc", 483 | testURL + "hello/world?abc&query", 484 | }, 485 | }, 486 | { 487 | Responder: "/hello/world#fragment", 488 | Paths: []string{ 489 | testURL + "hello/world#fragment", 490 | }, 491 | }, 492 | { 493 | Responder: "/hello/world", 494 | Paths: []string{ 495 | testURL + "hello/world?query=string&abc=zz#fragment", 496 | testURL + "hello/world?query=string&abc=zz", 497 | testURL + "hello/world#fragment", 498 | testURL + "hello/world", 499 | }, 500 | }, 501 | { 502 | Responder: "/hello%2fworl%64", 503 | Paths: []string{ 504 | testURL + "hello%2fworl%64?query=string&abc=zz#fragment", 505 | testURL + "hello%2fworl%64?query=string&abc=zz", 506 | testURL + "hello%2fworl%64#fragment", 507 | testURL + "hello%2fworl%64", 508 | }, 509 | }, 510 | // Regexp cases 511 | { 512 | Responder: `=~^http://.*/hello/.*ld\z`, 513 | Paths: []string{ 514 | testURL + "hello/world?query=string&abc=zz#fragment", 515 | testURL + "hello/world?query=string&abc=zz", 516 | testURL + "hello/world#fragment", 517 | testURL + "hello/world", 518 | }, 519 | }, 520 | { 521 | Responder: `=~^http://.*/hello/.*ld(\z|[?#])`, 522 | Paths: []string{ 523 | testURL + "hello/world?query=string&abc=zz#fragment", 524 | testURL + "hello/world?query=string&abc=zz", 525 | testURL + "hello/world#fragment", 526 | testURL + "hello/world", 527 | }, 528 | }, 529 | { 530 | Responder: `=~^/hello/.*ld\z`, 531 | Paths: []string{ 532 | testURL + "hello/world?query=string&abc=zz#fragment", 533 | testURL + "hello/world?query=string&abc=zz", 534 | testURL + "hello/world#fragment", 535 | testURL + "hello/world", 536 | }, 537 | }, 538 | { 539 | Responder: `=~^/hello/.*ld(\z|[?#])`, 540 | Paths: []string{ 541 | testURL + "hello/world?query=string&abc=zz#fragment", 542 | testURL + "hello/world?query=string&abc=zz", 543 | testURL + "hello/world#fragment", 544 | testURL + "hello/world", 545 | }, 546 | }, 547 | { 548 | Responder: `=~abc=zz`, 549 | Paths: []string{ 550 | testURL + "hello/world?query=string&abc=zz#fragment", 551 | testURL + "hello/world?query=string&abc=zz", 552 | }, 553 | }, 554 | } { 555 | httpmock.Activate() 556 | 557 | // register the responder 558 | httpmock.RegisterResponder("GET", test.Responder, httpmock.NewStringResponder(200, "hello world")) 559 | 560 | for _, reqURL := range test.Paths { 561 | t.Logf("%s: %s", test.Responder, reqURL) 562 | 563 | // make a request for the testURL with a querystring 564 | resp, err := http.Get(reqURL) 565 | if td.CmpNoError(t, err) { 566 | assertBody(t, resp, "hello world") 567 | } 568 | } 569 | 570 | httpmock.DeactivateAndReset() 571 | } 572 | } 573 | 574 | type dummyTripper struct{} 575 | 576 | func (d *dummyTripper) RoundTrip(*http.Request) (*http.Response, error) { 577 | return nil, nil 578 | } 579 | 580 | func TestMockTransportInitialTransport(t *testing.T) { 581 | httpmock.DeactivateAndReset() 582 | 583 | tripper := &dummyTripper{} 584 | http.DefaultTransport = tripper 585 | 586 | httpmock.Activate() 587 | 588 | td.CmpNot(t, http.DefaultTransport, td.Shallow(tripper), 589 | "expected http.DefaultTransport to be a mock transport") 590 | 591 | httpmock.Deactivate() 592 | 593 | td.Cmp(t, http.DefaultTransport, td.Shallow(tripper), 594 | "expected http.DefaultTransport to be dummy") 595 | } 596 | 597 | func TestMockTransportNonDefault(t *testing.T) { 598 | assert, require := td.AssertRequire(t) 599 | 600 | // create a custom http client w/ custom Roundtripper 601 | client := &http.Client{ 602 | Transport: errTransport{}, 603 | } 604 | 605 | // activate mocks for the client 606 | httpmock.ActivateNonDefault(client) 607 | t.Cleanup(httpmock.DeactivateAndReset) 608 | 609 | body := "hello world!" 610 | 611 | httpmock.RegisterResponder("GET", testURL, httpmock.NewStringResponder(200, body)) 612 | 613 | req, err := http.NewRequest("GET", testURL, nil) 614 | require.CmpNoError(err) 615 | 616 | resp, err := client.Do(req) 617 | require.CmpNoError(err) 618 | 619 | assertBody(assert, resp, body) 620 | 621 | // Restore the initial transport 622 | httpmock.DeactivateNonDefault(client) 623 | _, err = client.Do(req) 624 | td.Cmp(t, err, td.ErrorIs(errTransportErr)) 625 | 626 | // Can be called again, should be a no-op 627 | td.CmpNotPanic(t, func() { httpmock.DeactivateNonDefault(client) }) 628 | } 629 | 630 | func TestMockTransportRespectsCancel(t *testing.T) { 631 | assert := td.Assert(t) 632 | 633 | httpmock.Activate(assert) 634 | 635 | const ( 636 | cancelNone = iota 637 | cancelReq 638 | cancelCtx 639 | ) 640 | 641 | cases := []struct { 642 | withCancel int 643 | cancelNow bool 644 | withPanic bool 645 | expectedBody string 646 | expectedErr error 647 | }{ 648 | // No cancel specified at all. Falls back to normal behavior 649 | {cancelNone, false, false, "hello world", nil}, 650 | 651 | // Cancel returns error 652 | {cancelReq, true, false, "", errors.New("request canceled")}, 653 | 654 | // Cancel via context returns error 655 | {cancelCtx, true, false, "", errors.New("context canceled")}, 656 | 657 | // Request can be cancelled but it is not cancelled. 658 | {cancelReq, false, false, "hello world", nil}, 659 | 660 | // Request can be cancelled but it is not cancelled. 661 | {cancelCtx, false, false, "hello world", nil}, 662 | 663 | // Panic in cancelled request is handled 664 | {cancelReq, false, true, "", errors.New(`panic in responder: got "oh no"`)}, 665 | 666 | // Panic in cancelled request is handled 667 | {cancelCtx, false, true, "", errors.New(`panic in responder: got "oh no"`)}, 668 | } 669 | 670 | for ic, c := range cases { 671 | assert.RunAssertRequire(fmt.Sprintf("case #%d", ic), func(assert, require *td.T) { 672 | httpmock.Reset() 673 | if c.withPanic { 674 | httpmock.RegisterResponder("GET", testURL, func(r *http.Request) (*http.Response, error) { 675 | time.Sleep(10 * time.Millisecond) 676 | panic("oh no") 677 | }) 678 | } else { 679 | httpmock.RegisterResponder("GET", testURL, func(r *http.Request) (*http.Response, error) { 680 | time.Sleep(10 * time.Millisecond) 681 | return httpmock.NewStringResponse(http.StatusOK, "hello world"), nil 682 | }) 683 | } 684 | 685 | req, err := http.NewRequest("GET", testURL, nil) 686 | require.CmpNoError(err) 687 | 688 | switch c.withCancel { 689 | case cancelReq: 690 | cancel := make(chan struct{}, 1) 691 | req.Cancel = cancel //nolint: staticcheck 692 | if c.cancelNow { 693 | cancel <- struct{}{} 694 | } 695 | case cancelCtx: 696 | ctx, cancel := context.WithCancel(req.Context()) 697 | req = req.WithContext(ctx) 698 | if c.cancelNow { 699 | cancel() 700 | } else { 701 | defer cancel() // avoid ctx leak 702 | } 703 | } 704 | 705 | resp, err := http.DefaultClient.Do(req) 706 | 707 | if c.expectedErr != nil { 708 | // err is a *url.Error here, so with a Err field 709 | assert.Cmp(err, td.Smuggle("Err", td.String(c.expectedErr.Error()))) 710 | } else { 711 | assert.CmpNoError(err) 712 | } 713 | 714 | if c.expectedBody != "" { 715 | assertBody(assert, resp, c.expectedBody) 716 | } 717 | }) 718 | } 719 | } 720 | 721 | func TestMockTransportRespectsTimeout(t *testing.T) { 722 | timeout := time.Millisecond 723 | client := &http.Client{ 724 | Timeout: timeout, 725 | } 726 | 727 | httpmock.ActivateNonDefault(client) 728 | t.Cleanup(httpmock.DeactivateAndReset) 729 | 730 | httpmock.RegisterResponder( 731 | "GET", testURL, 732 | func(r *http.Request) (*http.Response, error) { 733 | time.Sleep(100 * timeout) 734 | return httpmock.NewStringResponse(http.StatusOK, ""), nil 735 | }, 736 | ) 737 | 738 | _, err := client.Get(testURL) 739 | td.CmpError(t, err) 740 | } 741 | 742 | func TestMockTransportCallCountReset(t *testing.T) { 743 | assert, require := td.AssertRequire(t) 744 | 745 | httpmock.Reset() 746 | httpmock.Activate(assert) 747 | 748 | const ( 749 | url = "https://github.com/path?b=1&a=2" 750 | url2 = "https://gitlab.com/" 751 | ) 752 | 753 | httpmock.RegisterResponder("GET", url, httpmock.NewStringResponder(200, "body")) 754 | httpmock.RegisterResponder("POST", "=~gitlab", httpmock.NewStringResponder(200, "body")) 755 | httpmock.RegisterMatcherResponder("POST", "=~gitlab", 756 | httpmock.BodyContainsString("pipo").WithName("pipo-in-body"), 757 | httpmock.NewStringResponder(200, "body")) 758 | 759 | _, err := http.Get(url) 760 | require.CmpNoError(err) 761 | 762 | buff := new(bytes.Buffer) 763 | json.NewEncoder(buff).Encode("{}") //nolint: errcheck 764 | _, err = http.Post(url2, "application/json", buff) 765 | require.CmpNoError(err) 766 | 767 | buff.Reset() 768 | json.NewEncoder(buff).Encode(`{"pipo":"bingo"}`) //nolint: errcheck 769 | _, err = http.Post(url2, "application/json", buff) 770 | require.CmpNoError(err) 771 | 772 | _, err = http.Get(url) 773 | require.CmpNoError(err) 774 | 775 | assert.Cmp(httpmock.GetTotalCallCount(), 2+1+1) 776 | assert.Cmp(httpmock.GetCallCountInfo(), map[string]int{ 777 | "GET " + url: 2, 778 | // Regexp match generates 2 entries: 779 | "POST " + url2: 1, // the matched call 780 | "POST =~gitlab": 1, // the regexp responder 781 | // Regexp + matcher match also generates 2 entries: 782 | "POST " + url2 + " ": 1, // matched call 783 | "POST =~gitlab ": 1, // the regexp responder with matcher 784 | }) 785 | 786 | httpmock.Reset() 787 | 788 | assert.Zero(httpmock.GetTotalCallCount()) 789 | assert.Empty(httpmock.GetCallCountInfo()) 790 | } 791 | 792 | func TestMockTransportCallCountZero(t *testing.T) { 793 | assert, require := td.AssertRequire(t) 794 | 795 | httpmock.Reset() 796 | httpmock.Activate(assert) 797 | 798 | const ( 799 | url = "https://github.com/path?b=1&a=2" 800 | url2 = "https://gitlab.com/" 801 | ) 802 | 803 | httpmock.RegisterResponder("GET", url, httpmock.NewStringResponder(200, "body")) 804 | httpmock.RegisterResponder("POST", "=~gitlab", httpmock.NewStringResponder(200, "body")) 805 | httpmock.RegisterMatcherResponder("POST", "=~gitlab", 806 | httpmock.BodyContainsString("pipo").WithName("pipo-in-body"), 807 | httpmock.NewStringResponder(200, "body")) 808 | 809 | _, err := http.Get(url) 810 | require.CmpNoError(err) 811 | 812 | buff := new(bytes.Buffer) 813 | json.NewEncoder(buff).Encode("{}") //nolint: errcheck 814 | _, err = http.Post(url2, "application/json", buff) 815 | require.CmpNoError(err) 816 | 817 | buff.Reset() 818 | json.NewEncoder(buff).Encode(`{"pipo":"bingo"}`) //nolint: errcheck 819 | _, err = http.Post(url2, "application/json", buff) 820 | require.CmpNoError(err) 821 | 822 | _, err = http.Get(url) 823 | require.CmpNoError(err) 824 | 825 | assert.Cmp(httpmock.GetTotalCallCount(), 2+1+1) 826 | assert.Cmp(httpmock.GetCallCountInfo(), map[string]int{ 827 | "GET " + url: 2, 828 | // Regexp match generates 2 entries: 829 | "POST " + url2: 1, // the matched call 830 | "POST =~gitlab": 1, // the regexp responder 831 | // Regexp + matcher match also generates 2 entries: 832 | "POST " + url2 + " ": 1, // matched call 833 | "POST =~gitlab ": 1, // the regexp responder with matcher 834 | }) 835 | 836 | httpmock.ZeroCallCounters() 837 | 838 | assert.Zero(httpmock.GetTotalCallCount()) 839 | assert.Cmp(httpmock.GetCallCountInfo(), map[string]int{ 840 | "GET " + url: 0, 841 | // Regexp match generates 2 entries: 842 | "POST " + url2: 0, // the matched call 843 | "POST =~gitlab": 0, // the regexp responder 844 | // Regexp + matcher match also generates 2 entries: 845 | "POST " + url2 + " ": 0, // matched call 846 | "POST =~gitlab ": 0, // the regexp responder with matcher 847 | }) 848 | 849 | // Unregister each responder 850 | httpmock.RegisterResponder("GET", url, nil) 851 | httpmock.RegisterResponder("POST", "=~gitlab", nil) 852 | httpmock.RegisterMatcherResponder("POST", "=~gitlab", httpmock.NewMatcher("pipo-in-body", nil), nil) 853 | 854 | assert.Cmp(httpmock.GetCallCountInfo(), map[string]int{ 855 | // these ones remain as they are not directly related to a 856 | // registered responder but a consequence of a regexp match 857 | "POST " + url2: 0, 858 | "POST " + url2 + " ": 0, 859 | }) 860 | } 861 | 862 | func TestRegisterResponderWithQuery(t *testing.T) { 863 | assert, require := td.AssertRequire(t) 864 | 865 | httpmock.Reset() 866 | 867 | // Just in case a panic occurs 868 | defer httpmock.DeactivateAndReset() 869 | 870 | // create a custom http client w/ custom Roundtripper 871 | client := &http.Client{ 872 | Transport: &http.Transport{ 873 | Proxy: http.ProxyFromEnvironment, 874 | Dial: (&net.Dialer{ 875 | Timeout: 60 * time.Second, 876 | KeepAlive: 30 * time.Second, 877 | }).Dial, 878 | TLSHandshakeTimeout: 60 * time.Second, 879 | }, 880 | } 881 | 882 | body := "hello world!" 883 | testURLPath := "http://acme.test/api" 884 | 885 | for _, test := range []struct { 886 | URL string 887 | Queries []interface{} 888 | URLs []string 889 | }{ 890 | { 891 | Queries: []interface{}{ 892 | map[string]string{"a": "1", "b": "2"}, 893 | "a=1&b=2", 894 | "b=2&a=1", 895 | url.Values{"a": []string{"1"}, "b": []string{"2"}}, 896 | }, 897 | URLs: []string{ 898 | "http://acme.test/api?a=1&b=2", 899 | "http://acme.test/api?b=2&a=1", 900 | }, 901 | }, 902 | { 903 | Queries: []interface{}{ 904 | url.Values{ 905 | "a": []string{"3", "2", "1"}, 906 | "b": []string{"4", "2"}, 907 | "c": []string{""}, // is the net/url way to record params without values 908 | // Test: 909 | // u, _ := url.Parse("/hello/world?query") 910 | // fmt.Printf("%d<%s>\n", len(u.Query()["query"]), u.Query()["query"][0]) 911 | // // prints "1<>" 912 | }, 913 | "a=1&b=2&a=3&c&b=4&a=2", 914 | "b=2&a=1&c=&b=4&a=2&a=3", 915 | nil, 916 | }, 917 | URLs: []string{ 918 | testURLPath + "?a=1&b=2&a=3&c&b=4&a=2", 919 | testURLPath + "?a=1&b=2&a=3&c=&b=4&a=2", 920 | testURLPath + "?b=2&a=1&c=&b=4&a=2&a=3", 921 | testURLPath + "?b=2&a=1&c&b=4&a=2&a=3", 922 | }, 923 | }, 924 | } { 925 | for _, query := range test.Queries { 926 | httpmock.ActivateNonDefault(client) 927 | httpmock.RegisterResponderWithQuery("GET", testURLPath, query, httpmock.NewStringResponder(200, body)) 928 | 929 | for _, url := range test.URLs { 930 | assert.Logf("query=%v URL=%s", query, url) 931 | 932 | req, err := http.NewRequest("GET", url, nil) 933 | require.CmpNoError(err) 934 | 935 | resp, err := client.Do(req) 936 | require.CmpNoError(err) 937 | 938 | assertBody(assert, resp, body) 939 | } 940 | 941 | if info := httpmock.GetCallCountInfo(); len(info) != 1 { 942 | t.Fatalf("%s: len(GetCallCountInfo()) should be 1 but contains %+v", testURLPath, info) 943 | } 944 | 945 | // Remove... 946 | httpmock.RegisterResponderWithQuery("GET", testURLPath, query, nil) 947 | require.Len(httpmock.GetCallCountInfo(), 0) 948 | 949 | for _, url := range test.URLs { 950 | t.Logf("query=%v URL=%s", query, url) 951 | 952 | req, err := http.NewRequest("GET", url, nil) 953 | require.CmpNoError(err) 954 | 955 | _, err = client.Do(req) 956 | assert.HasSuffix(err, "no responder found") 957 | } 958 | 959 | httpmock.DeactivateAndReset() 960 | } 961 | } 962 | } 963 | 964 | func TestRegisterResponderWithQueryPanic(t *testing.T) { 965 | resp := httpmock.NewStringResponder(200, "hello world!") 966 | 967 | for _, test := range []struct { 968 | Path string 969 | Query interface{} 970 | PanicPrefix string 971 | }{ 972 | { 973 | Path: "foobar", 974 | Query: "%", 975 | PanicPrefix: "RegisterResponderWithQuery bad query string: ", 976 | }, 977 | { 978 | Path: "foobar", 979 | Query: 1234, 980 | PanicPrefix: "RegisterResponderWithQuery bad query type int. Only url.Values, map[string]string and string are allowed", 981 | }, 982 | { 983 | Path: `=~regexp.*\z`, 984 | Query: "", 985 | PanicPrefix: `path begins with "=~", RegisterResponder should be used instead of RegisterResponderWithQuery`, 986 | }, 987 | } { 988 | td.CmpPanic(t, 989 | func() { httpmock.RegisterResponderWithQuery("GET", test.Path, test.Query, resp) }, 990 | td.HasPrefix(test.PanicPrefix), 991 | `RegisterResponderWithQuery + query=%v`, test.Query) 992 | } 993 | } 994 | 995 | func TestRegisterRegexpResponder(t *testing.T) { 996 | httpmock.Activate(t) 997 | 998 | rx := regexp.MustCompile("ex.mple") 999 | 1000 | httpmock.RegisterRegexpResponder("POST", rx, httpmock.NewStringResponder(200, "first")) 1001 | // Overwrite responder 1002 | httpmock.RegisterRegexpResponder("POST", rx, httpmock.NewStringResponder(200, "second")) 1003 | 1004 | resp, err := http.Post(testURL, "text/plain", strings.NewReader("PIPO")) 1005 | td.Require(t).CmpNoError(err) 1006 | assertBody(t, resp, "second") 1007 | 1008 | httpmock.RegisterRegexpMatcherResponder("POST", rx, 1009 | httpmock.BodyContainsString("PIPO").WithName("01-body-PIPO"), 1010 | httpmock.NewStringResponder(200, "matcher-PIPO")) 1011 | 1012 | httpmock.RegisterRegexpMatcherResponder("POST", rx, 1013 | httpmock.BodyContainsString("BINGO").WithName("02-body-BINGO"), 1014 | httpmock.NewStringResponder(200, "matcher-BINGO")) 1015 | 1016 | resp, err = http.Post(testURL, "text/plain", strings.NewReader("PIPO")) 1017 | td.Require(t).CmpNoError(err) 1018 | assertBody(t, resp, "matcher-PIPO") 1019 | 1020 | resp, err = http.Post(testURL, "text/plain", strings.NewReader("BINGO")) 1021 | td.Require(t).CmpNoError(err) 1022 | assertBody(t, resp, "matcher-BINGO") 1023 | 1024 | // Remove 01-body-PIPO matcher 1025 | httpmock.RegisterRegexpMatcherResponder("POST", rx, httpmock.NewMatcher("01-body-PIPO", nil), nil) 1026 | 1027 | resp, err = http.Post(testURL, "text/plain", strings.NewReader("PIPO")) 1028 | td.Require(t).CmpNoError(err) 1029 | assertBody(t, resp, "second") 1030 | 1031 | resp, err = http.Post(testURL, "text/plain", strings.NewReader("BINGO")) 1032 | td.Require(t).CmpNoError(err) 1033 | assertBody(t, resp, "matcher-BINGO") 1034 | 1035 | // Remove 02-body-BINGO matcher 1036 | httpmock.RegisterRegexpMatcherResponder("POST", rx, httpmock.NewMatcher("02-body-BINGO", nil), nil) 1037 | 1038 | resp, err = http.Post(testURL, "text/plain", strings.NewReader("BINGO")) 1039 | td.Require(t).CmpNoError(err) 1040 | assertBody(t, resp, "second") 1041 | } 1042 | 1043 | func TestSubmatches(t *testing.T) { 1044 | assert, require := td.AssertRequire(t) 1045 | 1046 | req, err := http.NewRequest("GET", "/foo/bar", nil) 1047 | require.CmpNoError(err) 1048 | 1049 | req2 := internal.SetSubmatches(req, []string{"foo", "123", "-123", "12.3"}) 1050 | 1051 | assert.Run("GetSubmatch", func(assert *td.T) { 1052 | _, err := httpmock.GetSubmatch(req, 1) 1053 | assert.Cmp(err, httpmock.ErrSubmatchNotFound) 1054 | 1055 | _, err = httpmock.GetSubmatch(req2, 5) 1056 | assert.Cmp(err, httpmock.ErrSubmatchNotFound) 1057 | 1058 | s, err := httpmock.GetSubmatch(req2, 1) 1059 | assert.CmpNoError(err) 1060 | assert.Cmp(s, "foo") 1061 | 1062 | s, err = httpmock.GetSubmatch(req2, 4) 1063 | assert.CmpNoError(err) 1064 | assert.Cmp(s, "12.3") 1065 | 1066 | s = httpmock.MustGetSubmatch(req2, 4) 1067 | assert.Cmp(s, "12.3") 1068 | }) 1069 | 1070 | assert.Run("GetSubmatchAsInt", func(assert *td.T) { 1071 | _, err := httpmock.GetSubmatchAsInt(req, 1) 1072 | assert.Cmp(err, httpmock.ErrSubmatchNotFound) 1073 | 1074 | _, err = httpmock.GetSubmatchAsInt(req2, 4) // not an int 1075 | assert.CmpError(err) 1076 | assert.Not(err, httpmock.ErrSubmatchNotFound) 1077 | 1078 | i, err := httpmock.GetSubmatchAsInt(req2, 3) 1079 | assert.CmpNoError(err) 1080 | assert.CmpLax(i, -123) 1081 | 1082 | i = httpmock.MustGetSubmatchAsInt(req2, 3) 1083 | assert.CmpLax(i, -123) 1084 | }) 1085 | 1086 | assert.Run("GetSubmatchAsUint", func(assert *td.T) { 1087 | _, err := httpmock.GetSubmatchAsUint(req, 1) 1088 | assert.Cmp(err, httpmock.ErrSubmatchNotFound) 1089 | 1090 | _, err = httpmock.GetSubmatchAsUint(req2, 3) // not a uint 1091 | assert.CmpError(err) 1092 | assert.Not(err, httpmock.ErrSubmatchNotFound) 1093 | 1094 | u, err := httpmock.GetSubmatchAsUint(req2, 2) 1095 | assert.CmpNoError(err) 1096 | assert.CmpLax(u, 123) 1097 | 1098 | u = httpmock.MustGetSubmatchAsUint(req2, 2) 1099 | assert.CmpLax(u, 123) 1100 | }) 1101 | 1102 | assert.Run("GetSubmatchAsFloat", func(assert *td.T) { 1103 | _, err := httpmock.GetSubmatchAsFloat(req, 1) 1104 | assert.Cmp(err, httpmock.ErrSubmatchNotFound) 1105 | 1106 | _, err = httpmock.GetSubmatchAsFloat(req2, 1) // not a float 1107 | assert.CmpError(err) 1108 | assert.Not(err, httpmock.ErrSubmatchNotFound) 1109 | 1110 | f, err := httpmock.GetSubmatchAsFloat(req2, 4) 1111 | assert.CmpNoError(err) 1112 | assert.Cmp(f, 12.3) 1113 | 1114 | f = httpmock.MustGetSubmatchAsFloat(req2, 4) 1115 | assert.Cmp(f, 12.3) 1116 | }) 1117 | 1118 | assert.Run("GetSubmatch* panics", func(assert *td.T) { 1119 | for _, test := range []struct { 1120 | Name string 1121 | Fn func() 1122 | PanicPrefix string 1123 | }{ 1124 | { 1125 | Name: "GetSubmatch & n < 1", 1126 | Fn: func() { httpmock.GetSubmatch(req, 0) }, //nolint: errcheck 1127 | PanicPrefix: "getting submatches starts at 1, not 0", 1128 | }, 1129 | { 1130 | Name: "MustGetSubmatch", 1131 | Fn: func() { httpmock.MustGetSubmatch(req, 1) }, 1132 | PanicPrefix: "GetSubmatch failed: " + httpmock.ErrSubmatchNotFound.Error(), 1133 | }, 1134 | { 1135 | Name: "MustGetSubmatchAsInt", 1136 | Fn: func() { httpmock.MustGetSubmatchAsInt(req2, 4) }, // not an int 1137 | PanicPrefix: "GetSubmatchAsInt failed: ", 1138 | }, 1139 | { 1140 | Name: "MustGetSubmatchAsUint", 1141 | Fn: func() { httpmock.MustGetSubmatchAsUint(req2, 3) }, // not a uint 1142 | PanicPrefix: "GetSubmatchAsUint failed: ", 1143 | }, 1144 | { 1145 | Name: "GetSubmatchAsFloat", 1146 | Fn: func() { httpmock.MustGetSubmatchAsFloat(req2, 1) }, // not a float 1147 | PanicPrefix: "GetSubmatchAsFloat failed: ", 1148 | }, 1149 | } { 1150 | assert.CmpPanic(test.Fn, td.HasPrefix(test.PanicPrefix), test.Name) 1151 | } 1152 | }) 1153 | 1154 | assert.RunAssertRequire("Full test", func(assert, require *td.T) { 1155 | httpmock.Activate(assert) 1156 | 1157 | var ( 1158 | id uint64 1159 | delta float64 1160 | deltaStr string 1161 | inc int64 1162 | ) 1163 | httpmock.RegisterResponder("GET", `=~^/id/(\d+)\?delta=(\d+(?:\.\d*)?)&inc=(-?\d+)\z`, 1164 | func(req *http.Request) (*http.Response, error) { 1165 | id = httpmock.MustGetSubmatchAsUint(req, 1) 1166 | delta = httpmock.MustGetSubmatchAsFloat(req, 2) 1167 | deltaStr = httpmock.MustGetSubmatch(req, 2) 1168 | inc = httpmock.MustGetSubmatchAsInt(req, 3) 1169 | 1170 | return httpmock.NewStringResponse(http.StatusOK, "OK"), nil 1171 | }) 1172 | 1173 | resp, err := http.Get("http://example.tld/id/123?delta=1.2&inc=-5") 1174 | require.CmpNoError(err) 1175 | assertBody(assert, resp, "OK") 1176 | 1177 | // Check submatches 1178 | assert.CmpLax(id, 123, "MustGetSubmatchAsUint") 1179 | assert.Cmp(delta, 1.2, "MustGetSubmatchAsFloat") 1180 | assert.Cmp(deltaStr, "1.2", "MustGetSubmatch") 1181 | assert.CmpLax(inc, -5, "MustGetSubmatchAsInt") 1182 | }) 1183 | } 1184 | 1185 | func TestCheckStackTracer(t *testing.T) { 1186 | assert, require := td.AssertRequire(t) 1187 | 1188 | // Full test using Trace() Responder 1189 | httpmock.Activate(assert) 1190 | 1191 | const url = "https://foo.bar/" 1192 | var mesg string 1193 | httpmock.RegisterResponder("GET", url, 1194 | httpmock.NewStringResponder(200, "{}"). 1195 | Trace(func(args ...interface{}) { mesg = args[0].(string) })) 1196 | 1197 | resp, err := http.Get(url) 1198 | require.CmpNoError(err) 1199 | 1200 | assertBody(assert, resp, "{}") 1201 | 1202 | // Check that first frame is the net/http.Get() call 1203 | assert.HasPrefix(mesg, "GET https://foo.bar/\nCalled from net/http.Get()\n at ") 1204 | assert.Not(mesg, td.HasSuffix("\n")) 1205 | } 1206 | 1207 | func TestCheckMethod(t *testing.T) { 1208 | mt := httpmock.NewMockTransport() 1209 | 1210 | const expected = `You probably want to use method "GET" instead of "get"? If not and so want to disable this check, set MockTransport.DontCheckMethod field to true` 1211 | 1212 | td.CmpPanic(t, 1213 | func() { 1214 | mt.RegisterResponder("get", "/pipo", httpmock.NewStringResponder(200, "")) 1215 | }, 1216 | expected) 1217 | 1218 | td.CmpPanic(t, 1219 | func() { 1220 | mt.RegisterRegexpResponder("get", regexp.MustCompile("."), httpmock.NewStringResponder(200, "")) 1221 | }, 1222 | expected) 1223 | 1224 | td.CmpPanic(t, 1225 | func() { 1226 | mt.RegisterResponderWithQuery("get", "/pipo", url.Values(nil), httpmock.NewStringResponder(200, "")) 1227 | }, 1228 | expected) 1229 | 1230 | // 1231 | // No longer panics 1232 | mt.DontCheckMethod = true 1233 | td.CmpNotPanic(t, 1234 | func() { 1235 | mt.RegisterResponder("get", "/pipo", httpmock.NewStringResponder(200, "")) 1236 | }) 1237 | 1238 | td.CmpNotPanic(t, 1239 | func() { 1240 | mt.RegisterRegexpResponder("get", regexp.MustCompile("."), httpmock.NewStringResponder(200, "")) 1241 | }) 1242 | 1243 | td.CmpNotPanic(t, 1244 | func() { 1245 | mt.RegisterResponderWithQuery("get", "/pipo", url.Values(nil), httpmock.NewStringResponder(200, "")) 1246 | }) 1247 | } 1248 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package httpmock_test 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "testing" 8 | 9 | "github.com/maxatome/go-testdeep/td" 10 | ) 11 | 12 | func assertBody(t testing.TB, resp *http.Response, expected string) bool { 13 | t.Helper() 14 | 15 | require := td.Require(t) 16 | require.NotNil(resp) 17 | 18 | defer resp.Body.Close() //nolint: errcheck 19 | 20 | data, err := io.ReadAll(resp.Body) 21 | require.CmpNoError(err) 22 | 23 | return td.CmpString(t, data, expected) 24 | } 25 | 26 | func writeFile(t testing.TB, file string, content []byte) { 27 | t.Helper() 28 | td.Require(t).CmpNoError(os.WriteFile(file, content, 0644)) 29 | } 30 | --------------------------------------------------------------------------------