From 556823dc350c23f4604ea22cb09fb829b004a4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20Soul=C3=A9?= Date: Fri, 17 Jan 2025 09:35:33 +0100 Subject: [PATCH] feat: enhance Activate and add DeactivateNonDefault functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now requires go 1.16, to be able to use testing.T.Cleanup. Signed-off-by: Maxime Soulé --- .github/workflows/ci.yml | 2 +- README.md | 15 +--- env_test.go | 1 + go.mod | 2 +- go.sum | 4 +- transport.go | 177 ++++++++++++++++++++++++++------------- transport_test.go | 65 +++++++------- 7 files changed, 160 insertions(+), 106 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07f3628..343a9b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: test: strategy: matrix: - go-version: [1.13.x, 1.14.x, 1.15.x, 1.16.x, 1.17.x, 1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x, tip] + go-version: [1.16.x, 1.17.x, 1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x, tip] full-tests: [false] include: - go-version: 1.23.x diff --git a/README.md b/README.md index 32f6415..78a5817 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Easy mocking of http responses from external resources. ## Install -Currently supports Go 1.13 to 1.23 and is regularly tested against tip. +Currently supports Go 1.16 to 1.23 and is regularly tested against tip. `v1` branch has to be used instead of `master`. @@ -23,8 +23,7 @@ populate your `go.mod` with the latest httpmock release, now ### Simple Example: ```go func TestFetchArticles(t *testing.T) { - httpmock.Activate() - t.Cleanup(httpmock.DeactivateAndReset) + httpmock.Activate(t) // Exact URL match httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles", @@ -51,8 +50,7 @@ func TestFetchArticles(t *testing.T) { ### Advanced Example: ```go func TestFetchArticles(t *testing.T) { - httpmock.Activate() - t.Cleanup(httpmock.DeactivateAndReset) + httpmock.Activate(t) // our database of articles articles := make([]map[string]interface{}, 0) @@ -138,7 +136,7 @@ type MySuite struct{} func (s *MySuite) Setup(t *td.T) error { // block all HTTP requests - httpmock.Activate() + httpmock.Activate(t) return nil } @@ -148,11 +146,6 @@ func (s *MySuite) PostTest(t *td.T, testName string) error { return nil } -func (s *MySuite) Destroy(t *td.T) error { - httpmock.DeactivateAndReset() - return nil -} - func TestMySuite(t *testing.T) { tdsuite.Run(t, &MySuite{}) } diff --git a/env_test.go b/env_test.go index c215a6c..c53a9ad 100644 --- a/env_test.go +++ b/env_test.go @@ -53,5 +53,6 @@ func TestEnv(t *testing.T) { "expected client1.Transport to not be our DefaultTransport") require.Not(client2.Transport, httpmock.DefaultTransport, "expected client2.Transport to not be our DefaultTransport") + httpmock.DeactivateNonDefault(client1) httpmock.Deactivate() } diff --git a/go.mod b/go.mod index aa97be5..50cd974 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,6 @@ module github.com/jarcoal/httpmock go 1.18 -require github.com/maxatome/go-testdeep v1.12.0 +require github.com/maxatome/go-testdeep v1.14.0 require github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 34d85ef..1979d1d 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= -github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= +github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI= +github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= diff --git a/transport.go b/transport.go index d245b20..98e0baa 100644 --- a/transport.go +++ b/transport.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "sync" + "testing" "github.com/jarcoal/httpmock/internal" ) @@ -1096,10 +1097,17 @@ var oldClientsLock sync.Mutex // To enable mocks for a test, simply activate at the beginning of a test: // // func TestFetchArticles(t *testing.T) { -// httpmock.Activate() +// httpmock.Activate(t) // // all http requests using http.DefaultTransport will now be intercepted // } // +// t is optional, when present it allows to automatically call +// [DeactivateAndReset] at the end of the test. It is strictly +// equivalent to: +// +// httpmock.Activate() +// t.Cleanup(httpmock.DeactivateAndReset) +// // If you want all of your tests in a package to be mocked, just call // [Activate] from init(): // @@ -1113,7 +1121,7 @@ var oldClientsLock sync.Mutex // httpmock.Activate() // os.Exit(m.Run()) // } -func Activate() { +func Activate(t ...testing.TB) { if Disabled() { return } @@ -1124,18 +1132,30 @@ func Activate() { InitialTransport = http.DefaultTransport } + if len(t) > 0 && t[0] != nil { + t[0].Cleanup(DeactivateAndReset) + } + http.DefaultTransport = DefaultTransport } // ActivateNonDefault starts the mock environment with a non-default -// [*http.Client]. This emulates the [Activate] function, but allows for -// custom clients that do not use [http.DefaultTransport]. +// [*http.Client]. This emulates the [Activate] function, but allows +// for custom clients that do not use [http.DefaultTransport] +// directly. To do so, it overrides the client Transport field with +// [DefaultTransport]. +// +// To enable mocks for a test using a custom client, like: // -// To enable mocks for a test using a custom client, activate at the -// beginning of a test: +// transport := http.DefaultTransport.(*http.Transport).Clone() +// transport.TLSHandshakeTimeout = 60 * time.Second +// client := &http.Client{Transport: transport} +// +// activate at the beginning of the test: // -// client := &http.Client{Transport: &http.Transport{TLSHandshakeTimeout: 60 * time.Second}} // httpmock.ActivateNonDefault(client) +// +// See also [DeactivateNonDefault], [Deactivate] and [DeactivateAndReset]. func ActivateNonDefault(client *http.Client) { if Disabled() { return @@ -1150,63 +1170,56 @@ func ActivateNonDefault(client *http.Client) { client.Transport = DefaultTransport } -// GetCallCountInfo gets the info on all the calls httpmock has caught -// since it was activated or reset. The info is returned as a map of -// the calling keys with the number of calls made to them as their -// value. The key is the method, a space, and the URL all concatenated -// together. +// DeactivateNonDefault shuts down the mock environment for +// client. Any HTTP calls made after this will use the transport used +// before [ActivateNonDefault] call. // -// As a special case, regexp responders generate 2 entries for each -// call. One for the call caught and the other for the rule that -// matched. For example: -// -// RegisterResponder("GET", `=~z\.com\z`, NewStringResponder(200, "body")) -// http.Get("http://z.com") -// -// will generate the following result: -// -// map[string]int{ -// `GET http://z.com`: 1, -// `GET =~z\.com\z`: 1, -// } -func GetCallCountInfo() map[string]int { - return DefaultTransport.GetCallCountInfo() -} +// See also [Deactivate] and [DeactivateAndReset]. +func DeactivateNonDefault(client *http.Client) { + if Disabled() { + return + } -// GetTotalCallCount gets the total number of calls httpmock has taken -// since it was activated or reset. -func GetTotalCallCount() int { - return DefaultTransport.GetTotalCallCount() + oldClientsLock.Lock() + defer oldClientsLock.Unlock() + if tr, ok := oldClients[client]; ok { + delete(oldClients, client) + client.Transport = tr + } } // Deactivate shuts down the mock environment. Any HTTP calls made // after this will use a live transport. // -// Usually you'll call it in a defer right after activating the mock -// environment: +// Usually you'll call it in a [testing.T.Cleanup] right after +// activating the mock environment: // // func TestFetchArticles(t *testing.T) { // httpmock.Activate() -// defer httpmock.Deactivate() +// t.Cleanup(httpmock.Deactivate) // // // when this test ends, the mock environment will close // } // -// Since go 1.14 you can also use [*testing.T.Cleanup] method as in: -// -// func TestFetchArticles(t *testing.T) { -// httpmock.Activate() -// t.Cleanup(httpmock.Deactivate) +// Note that registered mocks and corresponding counters are not +// removed. The next time [Activate] will be called they will be +// active again. Use [DeactivateAndReset] to also remove registered +// mocks & counters. // -// // when this test ends, the mock environment will close -// } +// It also restores all clients Transport field previously overridden +// by [ActivateNonDefault]. Unlike globally registered mocks, these +// clients won't be mocked anymore the next time [Activate] will be +// called. // -// useful in test helpers to save your callers from calling defer themselves. +// See also [Reset] and [DeactivateAndReset]. func Deactivate() { if Disabled() { return } - http.DefaultTransport = InitialTransport + + if InitialTransport != nil { + http.DefaultTransport = InitialTransport + } // reset the custom clients to use their original RoundTripper oldClientsLock.Lock() @@ -1219,24 +1232,72 @@ func Deactivate() { // Reset removes any registered mocks and returns the mock // environment to its initial state. It zeroes call counters too. +// +// See also [DeactivateAndReset]. func Reset() { DefaultTransport.Reset() } -// ZeroCallCounters zeroes call counters without touching registered responders. -func ZeroCallCounters() { - DefaultTransport.ZeroCallCounters() -} - // DeactivateAndReset is just a convenience method for calling // [Deactivate] and then [Reset]. // -// Happy deferring! +// Often called at the end of the test like in: +// +// func TestFetchArticles(t *testing.T) { +// httpmock.Activate() +// t.Cleanup(httpmock.DeactivateAndReset) +// +// // when this test ends, the mock environment will close +// } +// +// you may prefer the simpler: +// +// func TestFetchArticles(t *testing.T) { +// httpmock.Activate(t) +// +// // when this test ends, the mock environment will close +// } +// +// See [Activate]. func DeactivateAndReset() { Deactivate() Reset() } +// GetCallCountInfo gets the info on all the calls httpmock has caught +// since it was activated or reset. The info is returned as a map of +// the calling keys with the number of calls made to them as their +// value. The key is the method, a space, and the URL all concatenated +// together. +// +// As a special case, regexp responders generate 2 entries for each +// call. One for the call caught and the other for the rule that +// matched. For example: +// +// RegisterResponder("GET", `=~z\.com\z`, NewStringResponder(200, "body")) +// http.Get("http://z.com") +// +// will generate the following result: +// +// map[string]int{ +// `GET http://z.com`: 1, +// `GET =~z\.com\z`: 1, +// } +func GetCallCountInfo() map[string]int { + return DefaultTransport.GetCallCountInfo() +} + +// GetTotalCallCount gets the total number of calls httpmock has taken +// since it was first activated or last reset. +func GetTotalCallCount() int { + return DefaultTransport.GetTotalCallCount() +} + +// ZeroCallCounters zeroes call counters without touching registered responders. +func ZeroCallCounters() { + DefaultTransport.ZeroCallCounters() +} + // RegisterMatcherResponder adds a new responder, associated with a given // HTTP method, URL (or path) and [Matcher]. // @@ -1310,8 +1371,8 @@ func DeactivateAndReset() { // // If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, // OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible -// mistake. This panic can be disabled by setting m.DontCheckMethod to -// true prior to this call. +// mistake. This panic can be disabled by setting +// [DefaultTransport].DontCheckMethod to true prior to this call. // // See also [RegisterResponder] if a matcher is not needed. // @@ -1422,8 +1483,8 @@ func RegisterResponder(method, url string, responder Responder) { // // If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, // OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible -// mistake. This panic can be disabled by setting m.DontCheckMethod to -// true prior to this call. +// mistake. This panic can be disabled by setting +// [DefaultTransport].DontCheckMethod to true prior to this call. // // See [RegisterRegexpResponder] if a matcher is not needed. // @@ -1458,7 +1519,7 @@ func RegisterRegexpMatcherResponder(method string, urlRegexp *regexp.Regexp, mat // If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, // OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible // mistake. This panic can be disabled by setting -// DefaultTransport.DontCheckMethod to true prior to this call. +// [DefaultTransport].DontCheckMethod to true prior to this call. func RegisterRegexpResponder(method string, urlRegexp *regexp.Regexp, responder Responder) { DefaultTransport.RegisterRegexpResponder(method, urlRegexp, responder) } @@ -1504,8 +1565,8 @@ func RegisterRegexpResponder(method string, urlRegexp *regexp.Regexp, responder // // If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, // OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible -// mistake. This panic can be disabled by setting m.DontCheckMethod to -// true prior to this call. +// mistake. This panic can be disabled by setting +// [DefaultTransport].DontCheckMethod to true prior to this call. // // See also [RegisterResponderWithQuery] if a matcher is not needed. // @@ -1592,7 +1653,7 @@ func RegisterMatcherResponderWithQuery(method, path string, query any, matcher M // If method is a lower-cased version of CONNECT, DELETE, GET, HEAD, // OPTIONS, POST, PUT or TRACE, a panics occurs to notice the possible // mistake. This panic can be disabled by setting -// DefaultTransport.DontCheckMethod to true prior to this call. +// [DefaultTransport].DontCheckMethod to true prior to this call. func RegisterResponderWithQuery(method, path string, query any, responder Responder) { RegisterMatcherResponderWithQuery(method, path, query, Matcher{}, responder) } diff --git a/transport_test.go b/transport_test.go index b1f7149..ca668c6 100644 --- a/transport_test.go +++ b/transport_test.go @@ -23,9 +23,17 @@ import ( const testURL = "http://www.example.com/" +type errTransport struct{} + +var errTransportErr = errors.New("httpmock test error") + +// RoundTrip implements [http.RoundTripper] and always returns an error. +func (errTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errTransportErr +} + func TestMockTransport(t *testing.T) { - httpmock.Activate() - defer httpmock.Deactivate() + httpmock.Activate(t) url := "https://github.com/foo/bar" body := `["hello world"]` + "\n" @@ -95,8 +103,7 @@ func TestMockTransport(t *testing.T) { } func TestRegisterMatcherResponder(t *testing.T) { - httpmock.Activate() - defer httpmock.DeactivateAndReset() + httpmock.Activate(t) httpmock.RegisterMatcherResponder("POST", "/foo", httpmock.NewMatcher( @@ -286,8 +293,7 @@ func TestRegisterMatcherResponder(t *testing.T) { func TestMockTransportDefaultMethod(t *testing.T) { assert, require := td.AssertRequire(t) - httpmock.Activate() - defer httpmock.Deactivate() + httpmock.Activate(assert) const urlString = "https://github.com/" url, err := url.Parse(urlString) @@ -345,8 +351,7 @@ func TestMockTransportReset(t *testing.T) { } func TestMockTransportNoResponder(t *testing.T) { - httpmock.Activate() - defer httpmock.DeactivateAndReset() + httpmock.Activate(t) httpmock.Reset() @@ -384,8 +389,7 @@ func TestMockTransportNoResponder(t *testing.T) { func TestMockTransportQuerystringFallback(t *testing.T) { assert := td.Assert(t) - httpmock.Activate() - defer httpmock.DeactivateAndReset() + httpmock.Activate(assert) // register the testURL responder httpmock.RegisterResponder("GET", testURL, httpmock.NewStringResponder(200, "hello world")) @@ -405,7 +409,7 @@ func TestMockTransportQuerystringFallback(t *testing.T) { func TestMockTransportPathOnlyFallback(t *testing.T) { // Just in case a panic occurs - defer httpmock.DeactivateAndReset() + t.Cleanup(httpmock.DeactivateAndReset) for _, test := range []struct { Responder string @@ -595,19 +599,12 @@ func TestMockTransportNonDefault(t *testing.T) { // create a custom http client w/ custom Roundtripper client := &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - Dial: (&net.Dialer{ - Timeout: 60 * time.Second, - KeepAlive: 30 * time.Second, - }).Dial, - TLSHandshakeTimeout: 60 * time.Second, - }, + Transport: errTransport{}, } // activate mocks for the client httpmock.ActivateNonDefault(client) - defer httpmock.DeactivateAndReset() + t.Cleanup(httpmock.DeactivateAndReset) body := "hello world!" @@ -620,13 +617,20 @@ func TestMockTransportNonDefault(t *testing.T) { require.CmpNoError(err) assertBody(assert, resp, body) + + // Restore the initial transport + httpmock.DeactivateNonDefault(client) + _, err = client.Do(req) + td.Cmp(t, err, td.ErrorIs(errTransportErr)) + + // Can be called again, should be a no-op + td.CmpNotPanic(t, func() { httpmock.DeactivateNonDefault(client) }) } func TestMockTransportRespectsCancel(t *testing.T) { assert := td.Assert(t) - httpmock.Activate() - defer httpmock.DeactivateAndReset() + httpmock.Activate(assert) const ( cancelNone = iota @@ -721,7 +725,7 @@ func TestMockTransportRespectsTimeout(t *testing.T) { } httpmock.ActivateNonDefault(client) - defer httpmock.DeactivateAndReset() + t.Cleanup(httpmock.DeactivateAndReset) httpmock.RegisterResponder( "GET", testURL, @@ -739,8 +743,7 @@ func TestMockTransportCallCountReset(t *testing.T) { assert, require := td.AssertRequire(t) httpmock.Reset() - httpmock.Activate() - defer httpmock.Deactivate() + httpmock.Activate(assert) const ( url = "https://github.com/path?b=1&a=2" @@ -790,8 +793,7 @@ func TestMockTransportCallCountZero(t *testing.T) { assert, require := td.AssertRequire(t) httpmock.Reset() - httpmock.Activate() - defer httpmock.Deactivate() + httpmock.Activate(assert) const ( url = "https://github.com/path?b=1&a=2" @@ -991,8 +993,7 @@ func TestRegisterResponderWithQueryPanic(t *testing.T) { } func TestRegisterRegexpResponder(t *testing.T) { - httpmock.Activate() - defer httpmock.DeactivateAndReset() + httpmock.Activate(t) rx := regexp.MustCompile("ex.mple") @@ -1151,8 +1152,7 @@ func TestSubmatches(t *testing.T) { }) assert.RunAssertRequire("Full test", func(assert, require *td.T) { - httpmock.Activate() - defer httpmock.DeactivateAndReset() + httpmock.Activate(assert) var ( id uint64 @@ -1186,8 +1186,7 @@ func TestCheckStackTracer(t *testing.T) { assert, require := td.AssertRequire(t) // Full test using Trace() Responder - httpmock.Activate() - defer httpmock.Deactivate() + httpmock.Activate(assert) const url = "https://foo.bar/" var mesg string