├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── assertions.go ├── assertions_test.go ├── client.go ├── client_test.go ├── fixtures └── gopher.png ├── go.mod ├── go.sum ├── internal └── tls.go ├── request.go ├── request_helpers.go ├── request_helpers_test.go ├── request_test.go ├── server_1_8.go ├── server_1_9.go ├── server_test.go └── transport.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Golang CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-go/ for more details 4 | version: 2.1 5 | defaults: &defaults 6 | #### TEMPLATE_NOTE: go expects specific checkout path representing url 7 | #### expecting it in the form of 8 | #### /go/src/github.com/circleci/go-tool 9 | #### /go/src/bitbucket.org/circleci/go-tool 10 | working_directory: github.com/dolab/httptesting 11 | parallelism: 2 12 | default_steps: &default_steps 13 | steps: 14 | - checkout # checkout source code 15 | 16 | # specify any bash command here prefixed with `run: ` 17 | - run: git submodule update --init 18 | - run: go test -v -timeout 30s github.com/dolab/httptesting 19 | - run: go test -v -race -timeout 30s github.com/dolab/httptesting 20 | jobs: 21 | go1-24: 22 | <<: *defaults 23 | docker: 24 | # specify the version 25 | - image: cimg/go:1.24 26 | 27 | # Specify service dependencies here if necessary 28 | # CircleCI maintains a library of pre-built images 29 | # documented at https://circleci.com/docs/2.0/circleci-images/ 30 | # - image: circleci/postgres:9.4 31 | <<: *default_steps 32 | go1-19: 33 | <<: *defaults 34 | docker: 35 | # specify the version 36 | - image: cimg/go:1.19 37 | 38 | # Specify service dependencies here if necessary 39 | # CircleCI maintains a library of pre-built images 40 | # documented at https://circleci.com/docs/2.0/circleci-images/ 41 | # - image: circleci/postgres:9.4 42 | <<: *default_steps 43 | 44 | workflows: 45 | version: 2.1 46 | build_and_test: 47 | jobs: 48 | - go1-24 49 | #- go1-19 50 | 51 | -------------------------------------------------------------------------------- /.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 | *.test 24 | *.prof 25 | 26 | # IDE 27 | .idea 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httptesting 2 | 3 | [![CircleCI](https://circleci.com/gh/dolab/httptesting/tree/master.svg?style=svg)](https://circleci.com/gh/dolab/httptesting/tree/master) 4 | 5 | Golang HTTP testing client for human. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | $ go get github.com/dolab/httptesting 11 | ``` 12 | 13 | ## Getting Started 14 | 15 | ```go 16 | package httptesting 17 | 18 | import ( 19 | "net/http" 20 | "testing" 21 | 22 | "github.com/dolab/httptesting" 23 | ) 24 | 25 | // Testing with httptesting.Request 26 | func Test_Request(t *testing.T) { 27 | host := "https://example.com" 28 | client := httptesting.New(host, true) 29 | 30 | request := client.New(t) 31 | request.Get("/") 32 | 33 | // verify http response status 34 | if request.AssertOK() { 35 | // verify http response header 36 | request.AssertExistHeader("Content-Length") 37 | 38 | // verify http response body 39 | request.AssertNotEmpty() 40 | } 41 | } 42 | ``` 43 | 44 | ### Connected with `httptest.Server` 45 | 46 | ```go 47 | package httptesting 48 | 49 | import ( 50 | "net/http" 51 | "testing" 52 | 53 | "github.com/dolab/httptesting" 54 | ) 55 | 56 | type mockServer struct { 57 | method string 58 | path string 59 | assertion func(w http.ResponseWriter, r *http.Request) 60 | } 61 | 62 | func (mock *mockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 63 | mock.assertion(w, r) 64 | } 65 | 66 | func Test_Server(t *testing.T) { 67 | method := "GET" 68 | uri := "/server/https" 69 | server := &mockServer{ 70 | method: method, 71 | path: uri, 72 | assertion: func(w http.ResponseWriter, r *http.Request) { 73 | w.Header().Set("x-request-method", r.Method) 74 | 75 | w.WriteHeader(http.StatusOK) 76 | w.Write([]byte("TLS")) 77 | }, 78 | } 79 | 80 | // return default client connected with httptest.Server 81 | ts := httptesting.NewServer(server, true) 82 | defer ts.Close() 83 | 84 | request := ts.New(t) 85 | request.Get("/server/https") 86 | 87 | // verify http response status 88 | if request.AssertOK() { 89 | // verify http response header 90 | request.AssertExistHeader("Content-Length") 91 | 92 | // verify http response body 93 | request.AssertContains("TLS") 94 | } 95 | } 96 | ``` 97 | 98 | ### Advantage Usage 99 | 100 | ```go 101 | package main 102 | 103 | import ( 104 | "testing" 105 | ) 106 | 107 | func Test_Request(t *testing.T) { 108 | host := "https://example.com" 109 | client := httptesting.New(host, true) 110 | 111 | t.Run("GET /api/json", func(t *testing.T) { 112 | request := client.New(t) 113 | request.WithHeader("X-Mock-Client", "httptesting") 114 | 115 | // assume server response with following json data: 116 | // {"user":{"name":"httptesting","age":3},"addresses":[{"name":"china"},{"name":"USA"}]} 117 | request.GetJSON("/api/json", nil) 118 | 119 | // verify http response status 120 | if request.AssertOK() { 121 | // verify http response header 122 | request.AssertHeader("X-Mock-Client", "httptesting") 123 | 124 | // verify http response body with json format 125 | request.AssertContainsJSON("user.name", "httptesting") 126 | 127 | // for array 128 | request.AssertContainsJSON("addresses.1.name", "USA") 129 | request.AssertNotContainsJSON("addresses.2.name") 130 | 131 | // use regexp for custom matcher 132 | request.AssertMatch("user.*") 133 | } 134 | }) 135 | 136 | t.Run("POST /api/json", func(t *testing.T) { 137 | request := client.New(t) 138 | request.WithHeader("X-Mock-Client", "httptesting") 139 | 140 | payload := struct { 141 | Name string `json:"name"` 142 | Age int `json:"age"` 143 | }{"httptesting", 3} 144 | 145 | // assume server response with following json data: 146 | // {"data":{"name":"httptesting","age":3},"success":true} 147 | request.PostJSON("/api/json", payload) 148 | 149 | // verify http response status 150 | if request.AssertOK() { 151 | // verify http response header 152 | request.AssertHeader("X-Mock-Client", "httptesting") 153 | 154 | // verify http response body with json format 155 | request.AssertContainsJSON("data.name", "httptesting") 156 | request.AssertContainsJSON("data.age", 3) 157 | request.AssertContainsJSON("success", true) 158 | 159 | // use regexp for custom matcher 160 | request.AssertNotMatch("user.*") 161 | } 162 | }) 163 | } 164 | ``` 165 | 166 | ## Author 167 | 168 | [Spring MC](https://twitter.com/mcspring) 169 | 170 | ## LICENSE 171 | 172 | ``` 173 | The MIT License (MIT) 174 | 175 | Copyright (c) 2016 176 | 177 | Permission is hereby granted, free of charge, to any person obtaining a copy 178 | of this software and associated documentation files (the "Software"), to deal 179 | in the Software without restriction, including without limitation the rights 180 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 181 | copies of the Software, and to permit persons to whom the Software is 182 | furnished to do so, subject to the following conditions: 183 | 184 | The above copyright notice and this permission notice shall be included in all 185 | copies or substantial portions of the Software. 186 | 187 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 188 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 189 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 190 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 191 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 192 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 193 | SOFTWARE. 194 | ``` 195 | -------------------------------------------------------------------------------- /assertions.go: -------------------------------------------------------------------------------- 1 | package httptesting 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/golib/assert" 7 | ) 8 | 9 | // AssertStatus asserts that the response status code is equal to value. 10 | func (r *Request) AssertStatus(status int) bool { 11 | return assert.EqualValues(r.t, status, r.Response.StatusCode, 12 | "Expected response status code of %d, but got %d", 13 | status, 14 | r.Response.StatusCode, 15 | ) 16 | } 17 | 18 | // AssertOK asserts that the response status code is 200. 19 | func (r *Request) AssertOK() bool { 20 | return r.AssertStatus(http.StatusOK) 21 | } 22 | 23 | // AssertForbidden asserts that the response status code is 403. 24 | func (r *Request) AssertForbidden() bool { 25 | return r.AssertStatus(http.StatusForbidden) 26 | } 27 | 28 | // AssertNotFound asserts that the response status code is 404. 29 | func (r *Request) AssertNotFound() bool { 30 | return r.AssertStatus(http.StatusNotFound) 31 | } 32 | 33 | // AssertInternalError asserts that the response status code is 500. 34 | func (r *Request) AssertInternalError() bool { 35 | return r.AssertStatus(http.StatusInternalServerError) 36 | } 37 | 38 | // AssertHeader asserts that the response includes named header with value. 39 | func (r *Request) AssertHeader(name, value string) bool { 40 | actual := r.Response.Header.Get(name) 41 | 42 | return assert.EqualValues(r.t, value, actual, 43 | "Expected response header contains %s of %s, but got %s", 44 | http.CanonicalHeaderKey(name), 45 | value, 46 | actual, 47 | ) 48 | } 49 | 50 | // AssertContentType asserts that the response includes Content-Type header with value. 51 | func (r *Request) AssertContentType(contentType string) bool { 52 | return r.AssertHeader("Content-Type", contentType) 53 | } 54 | 55 | // AssertExistHeader asserts that the response includes named header. 56 | func (r *Request) AssertExistHeader(name string) bool { 57 | name = http.CanonicalHeaderKey(name) 58 | 59 | _, ok := r.Response.Header[name] 60 | if !ok { 61 | assert.Fail(r.t, "Response header: "+name+" (*required)", 62 | "Expected response header includes %s", 63 | name, 64 | ) 65 | } 66 | 67 | return ok 68 | } 69 | 70 | // AssertNotExistHeader asserts that the response does not include named header. 71 | func (r *Request) AssertNotExistHeader(name string) bool { 72 | name = http.CanonicalHeaderKey(name) 73 | 74 | _, ok := r.Response.Header[name] 75 | if ok { 76 | assert.Fail(r.t, "Response header: "+name+" (*not required)", 77 | "Expected response header does not include %s", 78 | name, 79 | ) 80 | } 81 | 82 | return !ok 83 | } 84 | 85 | // AssertEmpty asserts that the response body is empty. 86 | func (r *Request) AssertEmpty() bool { 87 | return assert.Empty(r.t, string(r.ResponseBody)) 88 | } 89 | 90 | // AssertNotEmpty asserts that the response body is not empty. 91 | func (r *Request) AssertNotEmpty() bool { 92 | return assert.NotEmpty(r.t, string(r.ResponseBody)) 93 | } 94 | 95 | // AssertContains asserts that the response body contains the string. 96 | func (r *Request) AssertContains(s string) bool { 97 | return assert.Contains(r.t, string(r.ResponseBody), s, 98 | "Expected response body contains %q", 99 | s, 100 | ) 101 | } 102 | 103 | // AssertNotContains asserts that the response body does not contain the string. 104 | func (r *Request) AssertNotContains(s string) bool { 105 | return assert.NotContains(r.t, string(r.ResponseBody), s, 106 | "Expected response body does not contain %q", 107 | s, 108 | ) 109 | } 110 | 111 | // AssertMatch asserts that the response body matches the regular expression. 112 | func (r *Request) AssertMatch(re string) bool { 113 | return assert.Match(r.t, re, r.ResponseBody, 114 | "Expected response body matches regexp %q", 115 | re, 116 | ) 117 | } 118 | 119 | // AssertNotMatch asserts that the response body does not match the regular expression. 120 | func (r *Request) AssertNotMatch(re string) bool { 121 | return assert.NotMatch(r.t, re, r.ResponseBody, 122 | "Expected response body does not match regexp %q", 123 | re, 124 | ) 125 | } 126 | 127 | // AssertContainsJSON asserts that the response body contains JSON value of the key. 128 | func (r *Request) AssertContainsJSON(key string, value interface{}) bool { 129 | return assert.ContainsJSON(r.t, string(r.ResponseBody), key, value) 130 | } 131 | 132 | // AssertNotContainsJSON asserts that the response body dose not contain JSON value of the key. 133 | func (r *Request) AssertNotContainsJSON(key string) bool { 134 | return assert.NotContainsJSON(r.t, string(r.ResponseBody), key) 135 | } 136 | -------------------------------------------------------------------------------- /assertions_test.go: -------------------------------------------------------------------------------- 1 | package httptesting 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/golib/assert" 10 | ) 11 | 12 | func TestRequest_Assertions(t *testing.T) { 13 | it := assert.New(t) 14 | method := "GET" 15 | uri := "/assertions" 16 | params := url.Values{"url-key": []string{"url-value"}} 17 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 18 | it.Equal(method, r.Method) 19 | it.Equal("text/html", r.Header.Get("Content-Type")) 20 | it.Equal("/assertions?url-key=url-value", r.RequestURI) 21 | it.Equal("url-value", r.URL.Query().Get("url-key")) 22 | 23 | w.Header().Set("x-request-method", r.Method) 24 | w.Header().Set("Content-Type", "application/json") 25 | w.WriteHeader(http.StatusOK) 26 | w.Write([]byte(`{"user":{"name":"httptesting","age":3},"addresses":[{"name":"china"},{"name":"USA"}]}`)) 27 | }) 28 | 29 | ts := httptest.NewServer(server) 30 | defer ts.Close() 31 | 32 | request := New(ts.URL, false).New(t) 33 | request.Get(uri, params) 34 | request.AssertOK() 35 | request.AssertHeader("x-request-method", method) 36 | request.AssertExistHeader("Content-Type") 37 | request.AssertNotExistHeader("x-unknown-header") 38 | request.AssertNotEmpty() 39 | request.AssertContains(`{"name":"china"}`) 40 | request.AssertContainsJSON("user.name", "httptesting") 41 | request.AssertContainsJSON("addresses.1.name", "USA") 42 | request.AssertNotContainsJSON("addresses.0.post") 43 | request.AssertNotContainsJSON("addresses.3.name") 44 | } 45 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package httptesting 2 | 3 | import ( 4 | "crypto/x509" 5 | "fmt" 6 | "net/http" 7 | "net/http/cookiejar" 8 | "net/http/httptest" 9 | "net/url" 10 | "strings" 11 | "sync" 12 | "testing" 13 | 14 | "golang.org/x/net/websocket" 15 | ) 16 | 17 | // Client defines request component of httptesting. 18 | // 19 | // NOTE: Client is not safe for concurrency, please use client.New(t) after initialized. 20 | type Client struct { 21 | mux sync.RWMutex 22 | server *httptest.Server 23 | host string 24 | certs *x509.CertPool 25 | jar *cookiejar.Jar 26 | isTLS bool 27 | } 28 | 29 | // New returns an initialized *Client ready for testing 30 | func New(host string, isTLS bool) *Client { 31 | // adjust host 32 | if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") { 33 | urlobj, err := url.Parse(host) 34 | if err == nil { 35 | isTLS = strings.HasPrefix(host, "https://") 36 | 37 | host = urlobj.Host 38 | } 39 | } 40 | 41 | jar, err := cookiejar.New(nil) 42 | if err != nil { 43 | panic(fmt.Sprintf("httptesting: New: %v", err)) 44 | } 45 | 46 | return &Client{ 47 | host: host, 48 | jar: jar, 49 | isTLS: isTLS, 50 | } 51 | } 52 | 53 | // NewWithTLS returns an initialized *Client with custom certificate. 54 | func NewWithTLS(host string, cert *x509.Certificate) *Client { 55 | // adjust host 56 | if strings.HasPrefix(host, "https://") { 57 | urlobj, err := url.Parse(host) 58 | if err == nil { 59 | host = urlobj.Host 60 | } 61 | } 62 | 63 | jar, err := cookiejar.New(nil) 64 | if err != nil { 65 | panic(fmt.Sprintf("httptesting: NewWithTLS: %v", err)) 66 | } 67 | 68 | certs := x509.NewCertPool() 69 | certs.AddCert(cert) 70 | 71 | return &Client{ 72 | host: host, 73 | certs: certs, 74 | jar: jar, 75 | isTLS: true, 76 | } 77 | } 78 | 79 | // Host returns the host and port of the server, e.g. "127.0.0.1:9090" 80 | func (c *Client) Host() string { 81 | if len(c.host) == 0 { 82 | return "" 83 | } 84 | 85 | if c.host[0] == ':' { 86 | return "127.0.0.1" + c.host 87 | } 88 | 89 | return c.host 90 | } 91 | 92 | // Url returns the abs http/isTLS URL of the resource, e.g. "http://127.0.0.1:9090/status". 93 | // The scheme is set to isTLS if http.ssl is set to true in the configuration. 94 | func (c *Client) Url(urlpath string, params ...url.Values) string { 95 | if len(params) > 0 { 96 | if !strings.Contains(urlpath, "?") { 97 | urlpath += "?" 98 | } 99 | 100 | urlpath += params[0].Encode() 101 | } 102 | 103 | scheme := "http://" 104 | if c.isTLS { 105 | scheme = "https://" 106 | } 107 | 108 | return scheme + c.Host() + urlpath 109 | } 110 | 111 | // WebsocketUrl returns the abs websocket URL of the resource, e.g. "ws://127.0.0.1:9090/status" 112 | func (c *Client) WebsocketUrl(urlpath string, params ...url.Values) string { 113 | if len(params) > 0 { 114 | if !strings.Contains(urlpath, "?") { 115 | urlpath += "?" 116 | } 117 | 118 | urlpath += params[0].Encode() 119 | } 120 | 121 | return "ws://" + c.Host() + urlpath 122 | } 123 | 124 | // Cookies returns jar related to the host 125 | func (c *Client) Cookies() ([]*http.Cookie, error) { 126 | urlobj, err := url.Parse(c.Url("/")) 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | return c.jar.Cookies(urlobj), nil 132 | } 133 | 134 | // SetCookies sets jar for the host 135 | func (c *Client) SetCookies(cookies []*http.Cookie) error { 136 | c.mux.Lock() 137 | defer c.mux.Unlock() 138 | 139 | urlobj, err := url.Parse(c.Url("/")) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | c.jar.SetCookies(urlobj, cookies) 145 | return nil 146 | } 147 | 148 | // NewClient creates a http client with cookie and tls for the Client. 149 | func (c *Client) NewClient(filters ...RequestFilter) *http.Client { 150 | client := &http.Client{ 151 | Transport: NewFilterTransport(filters, c.certs), 152 | Jar: c.jar, 153 | } 154 | 155 | return client 156 | } 157 | 158 | // NewWebsocket creates a websocket connection to the given path and returns the connection 159 | func (c *Client) NewWebsocket(t *testing.T, path string) *websocket.Conn { 160 | origin := c.WebsocketUrl("/") 161 | target := c.WebsocketUrl(path) 162 | 163 | ws, err := websocket.Dial(target, "", origin) 164 | if err != nil { 165 | t.Fatalf("httptesting: NewWebscoket: connect %s with %v\n", path, err) 166 | } 167 | 168 | return ws 169 | } 170 | 171 | // NewRequest returns a *Request which has more customization! 172 | func (c *Client) NewRequest(t *testing.T) *Request { 173 | return NewRequest(t, c) 174 | } 175 | 176 | // New is alias of NewRequest for shortcut. 177 | func (c *Client) New(t *testing.T) *Request { 178 | return c.NewRequest(t) 179 | } 180 | 181 | // Close tries to 182 | // 183 | // - close *httptest.Server created by NewServer or NewServerWithTLS 184 | func (c *Client) Close() { 185 | c.mux.Lock() 186 | defer c.mux.Unlock() 187 | 188 | if c.server != nil { 189 | c.server.Close() 190 | c.server = nil 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package httptesting 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "strconv" 8 | "sync" 9 | "testing" 10 | 11 | "github.com/golib/assert" 12 | ) 13 | 14 | type mockServer struct { 15 | method string 16 | path string 17 | it func(w http.ResponseWriter, r *http.Request) 18 | } 19 | 20 | func (mock *mockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 | mock.it(w, r) 22 | } 23 | 24 | var ( 25 | newMockServer = func(method, path string, it func(http.ResponseWriter, *http.Request)) *mockServer { 26 | return &mockServer{ 27 | method: method, 28 | path: path, 29 | it: it, 30 | } 31 | } 32 | ) 33 | 34 | func Test_New(t *testing.T) { 35 | it := assert.New(t) 36 | 37 | host := "www.example.com" 38 | absurl := "https://" + host 39 | ws := "ws://" + host 40 | 41 | client := New(host, true) 42 | it.Nil(client.server) 43 | it.NotEmpty(client.host) 44 | it.True(client.isTLS) 45 | it.Equal(host, client.Host()) 46 | it.Equal(absurl, client.Url("")) 47 | it.Equal(ws, client.WebsocketUrl("")) 48 | } 49 | 50 | func Test_NewWithRacy(t *testing.T) { 51 | method := "GET" 52 | uri := "/request/racy" 53 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 54 | w.Header().Set("x-request-method", r.Method) 55 | 56 | w.WriteHeader(http.StatusOK) 57 | w.Write([]byte(r.URL.Query().Get("routine"))) 58 | }) 59 | 60 | ts := httptest.NewServer(server) 61 | defer ts.Close() 62 | 63 | client := New(ts.URL, false) 64 | 65 | var ( 66 | wg sync.WaitGroup 67 | 68 | routines = 3 69 | ) 70 | wg.Add(routines) 71 | 72 | for i := 0; i < routines; i++ { 73 | go func(routine int) { 74 | defer wg.Done() 75 | 76 | params := url.Values{} 77 | params.Add("routine", strconv.Itoa(routine+1)) 78 | 79 | request := client.New(t) 80 | request.Get(uri, params) 81 | request.AssertOK() 82 | request.AssertContains(strconv.Itoa(routine + 1)) 83 | }(i) 84 | } 85 | 86 | wg.Wait() 87 | } 88 | 89 | func TestTesting_New(t *testing.T) { 90 | it := assert.New(t) 91 | 92 | host := "www.example.com" 93 | client := New(host, true) 94 | 95 | request := client.New(t) 96 | it.Equal(client, request.Client) 97 | it.NotNil(request.t) 98 | it.Nil(request.Response) 99 | it.Empty(request.ResponseBody) 100 | it.Nil(request.server) 101 | it.Equal(client.host, request.host) 102 | it.True(request.isTLS) 103 | } 104 | -------------------------------------------------------------------------------- /fixtures/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolab/httptesting/56e91583b4d7989a8c3bf9be5b377feb6643e5fa/fixtures/gopher.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dolab/httptesting 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/golib/assert v1.7.0 7 | golang.org/x/net v0.37.0 8 | ) 9 | 10 | require ( 11 | github.com/buger/jsonparser v1.1.1 // indirect 12 | github.com/dolab/types v1.0.0 // indirect 13 | github.com/kr/pretty v0.3.1 // indirect 14 | github.com/kr/text v0.2.0 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | github.com/rogpeppe/go-internal v1.14.1 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 2 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 3 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 4 | github.com/dolab/types v1.0.0 h1:6Mw2F+OV2O4nbtJuUkJz4SvM680BmnN98+tAKwaS5NM= 5 | github.com/dolab/types v1.0.0/go.mod h1:brm0PbBgIaJU2Bp6XOPSIJ/TeupivTbBK9HQasNfuGc= 6 | github.com/golib/assert v1.7.0 h1:rsJw1nyBS77foXXyOEnTApAn54Wp9s4AEl1cpMdcoEs= 7 | github.com/golib/assert v1.7.0/go.mod h1:NSgHxVL0GnUDmBmb39ed91474lOLh7be5qCXMuIiHow= 8 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 9 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 10 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 11 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 12 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 16 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 17 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 18 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 19 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 20 | -------------------------------------------------------------------------------- /internal/tls.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | var ( 4 | // LocalhostCert is a PEM-encoded TLS cert with SAN IPs 5 | // "127.0.0.1" and "[::1]", expiring at Jan 29 16:00:00 2084 GMT. 6 | // generated from src/crypto/tls: 7 | // go run generate_cert.go --rsa-bits 1024 --host 127.0.0.1,::1,example.com --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h 8 | LocalhostCert = []byte(`-----BEGIN CERTIFICATE----- 9 | MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zANBgkqhkiG9w0BAQsFADAS 10 | MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw 11 | MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB 12 | iQKBgQDuLnQAI3mDgey3VBzWnB2L39JUU4txjeVE6myuDqkM/uGlfjb9SjY1bIw4 13 | iA5sBBZzHi3z0h1YV8QPuxEbi4nW91IJm2gsvvZhIrCHS3l6afab4pZBl2+XsDul 14 | rKBxKKtD1rGxlG4LjncdabFn9gvLZad2bSysqz/qTAUStTvqJQIDAQABo2gwZjAO 15 | BgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUw 16 | AwEB/zAuBgNVHREEJzAlggtleGFtcGxlLmNvbYcEfwAAAYcQAAAAAAAAAAAAAAAA 17 | AAAAATANBgkqhkiG9w0BAQsFAAOBgQCEcetwO59EWk7WiJsG4x8SY+UIAA+flUI9 18 | tyC4lNhbcF2Idq9greZwbYCqTTTr2XiRNSMLCOjKyI7ukPoPjo16ocHj+P3vZGfs 19 | h1fIw3cSS2OolhloGw/XM6RWPWtPAlGykKLciQrBru5NAPvCMsb/I1DAceTiotQM 20 | fblo6RBxUQ== 21 | -----END CERTIFICATE-----`) 22 | 23 | // LocalhostKey is the private key for localhostCert. 24 | LocalhostKey = []byte(`-----BEGIN RSA PRIVATE KEY----- 25 | MIICXgIBAAKBgQDuLnQAI3mDgey3VBzWnB2L39JUU4txjeVE6myuDqkM/uGlfjb9 26 | SjY1bIw4iA5sBBZzHi3z0h1YV8QPuxEbi4nW91IJm2gsvvZhIrCHS3l6afab4pZB 27 | l2+XsDulrKBxKKtD1rGxlG4LjncdabFn9gvLZad2bSysqz/qTAUStTvqJQIDAQAB 28 | AoGAGRzwwir7XvBOAy5tM/uV6e+Zf6anZzus1s1Y1ClbjbE6HXbnWWF/wbZGOpet 29 | 3Zm4vD6MXc7jpTLryzTQIvVdfQbRc6+MUVeLKwZatTXtdZrhu+Jk7hx0nTPy8Jcb 30 | uJqFk541aEw+mMogY/xEcfbWd6IOkp+4xqjlFLBEDytgbIECQQDvH/E6nk+hgN4H 31 | qzzVtxxr397vWrjrIgPbJpQvBsafG7b0dA4AFjwVbFLmQcj2PprIMmPcQrooz8vp 32 | jy4SHEg1AkEA/v13/5M47K9vCxmb8QeD/asydfsgS5TeuNi8DoUBEmiSJwma7FXY 33 | fFUtxuvL7XvjwjN5B30pNEbc6Iuyt7y4MQJBAIt21su4b3sjXNueLKH85Q+phy2U 34 | fQtuUE9txblTu14q3N7gHRZB4ZMhFYyDy8CKrN2cPg/Fvyt0Xlp/DoCzjA0CQQDU 35 | y2ptGsuSmgUtWj3NM9xuwYPm+Z/F84K6+ARYiZ6PYj013sovGKUFfYAqVXVlxtIX 36 | qyUBnu3X9ps8ZfjLZO7BAkEAlT4R5Yl6cGhaJQYZHOde3JEMhNRcVFMO8dJDaFeo 37 | f9Oeos0UUothgiDktdQHxdNEwLjQf7lJJBzV+5OtwswCWA== 38 | -----END RSA PRIVATE KEY-----`) 39 | ) 40 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package httptesting 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "mime/multipart" 7 | "net/http" 8 | "os" 9 | "sync" 10 | "testing" 11 | ) 12 | 13 | // Request defines http client for human usage. 14 | type Request struct { 15 | *Client 16 | 17 | Response *http.Response 18 | ResponseBody []byte 19 | 20 | mux sync.Mutex 21 | t *testing.T 22 | cookies []*http.Cookie 23 | header http.Header 24 | } 25 | 26 | // NewRequest returns a new *Request with *Client 27 | func NewRequest(t *testing.T, client *Client) *Request { 28 | return &Request{ 29 | Client: client, 30 | t: t, 31 | cookies: []*http.Cookie{}, 32 | header: http.Header{}, 33 | } 34 | } 35 | 36 | // WithHeader sets http header by replace for the request 37 | func (r *Request) WithHeader(key, value string) *Request { 38 | r.mux.Lock() 39 | defer r.mux.Unlock() 40 | 41 | r.header.Set(key, value) 42 | 43 | return r 44 | } 45 | 46 | // WithHttpHeader adds http header for the request 47 | func (r *Request) WithHttpHeader(header http.Header) *Request { 48 | r.mux.Lock() 49 | defer r.mux.Unlock() 50 | 51 | for key, values := range header { 52 | for _, value := range values { 53 | r.header.Add(key, value) 54 | } 55 | } 56 | 57 | return r 58 | } 59 | 60 | // WithCookies sets jar for client by replace for the request 61 | func (r *Request) WithCookies(cookies []*http.Cookie) *Request { 62 | r.mux.Lock() 63 | defer r.mux.Unlock() 64 | 65 | r.cookies = append(r.cookies, cookies...) 66 | 67 | return r 68 | } 69 | 70 | // NewRequest issues any request and read the response. 71 | // If successful, the caller may examine the Response and ResponseBody properties. 72 | // NOTE: You have to manage session / cookie data manually. 73 | func (r *Request) NewRequest(request *http.Request, filters ...RequestFilter) { 74 | r.mux.Lock() 75 | defer r.mux.Unlock() 76 | 77 | var err error 78 | 79 | r.Response, err = r.NewClient(filters...).Do(request) 80 | if err != nil { 81 | r.t.Fatalf("httptesting: %v\n", err) 82 | } 83 | defer r.Response.Body.Close() 84 | 85 | // Read response body if not empty 86 | r.ResponseBody = []byte{} 87 | 88 | switch r.Response.StatusCode { 89 | case http.StatusNoContent: 90 | // ignore 91 | 92 | default: 93 | r.ResponseBody, err = io.ReadAll(r.Response.Body) 94 | if err != nil { 95 | if err != io.EOF { 96 | r.t.Fatalf("httptesting: NewRequest:%s %s: %v\n", request.Method, request.URL.RequestURI(), err) 97 | } 98 | 99 | r.t.Logf("httptesting: NewRequest:%s %s: Unexptected response body with io.EOF\n", request.Method, request.URL.RequestURI()) 100 | } 101 | } 102 | } 103 | 104 | // NewSessionRequest issues any request with session / cookie and read the response. 105 | // If successful, the caller may examine the Response and ResponseBody properties. 106 | // NOTE: Session data will be added to the request jar for requested host. 107 | func (r *Request) NewSessionRequest(request *http.Request, filters ...RequestFilter) { 108 | if cookies, err := r.Cookies(); err == nil { 109 | for _, cookie := range cookies { 110 | request.AddCookie(cookie) 111 | } 112 | } 113 | 114 | for _, cookie := range r.cookies { 115 | request.AddCookie(cookie) 116 | } 117 | 118 | r.NewRequest(request, filters...) 119 | } 120 | 121 | // NewMultipartRequest issues a multipart request for the method & fields given and read the response. 122 | // If successful, the caller may examine the Response and ResponseBody properties. 123 | func (r *Request) NewMultipartRequest(method, path, filename string, file interface{}, fields ...map[string]string) { 124 | var buf bytes.Buffer 125 | 126 | mw := multipart.NewWriter(&buf) 127 | 128 | fw, ferr := mw.CreateFormFile("filename", filename) 129 | if ferr != nil { 130 | r.t.Fatalf("httptesting: NewMultipartRequest:%s %s: %v\n", method, path, ferr) 131 | } 132 | 133 | // apply file 134 | var ( 135 | reader io.Reader 136 | err error 137 | ) 138 | switch f := file.(type) { 139 | case io.Reader: 140 | reader = f 141 | 142 | case string: 143 | reader, err = os.Open(f) 144 | if err != nil { 145 | r.t.Fatalf("httptesting: NewMultipartRequest:os.Open(%s): %v\n", f, err) 146 | } 147 | 148 | default: 149 | r.t.Fatalf("httptesting: NewMultipartRequest:%T<%v>: Unsupported file type\n", file, file) 150 | } 151 | 152 | if _, err := io.Copy(fw, reader); err != nil { 153 | r.t.Fatalf("httptesting: NewMultipartRequest:io.Copy(%T, %T): %v\n", fw, file, err) 154 | } 155 | 156 | // apply fields 157 | if len(fields) > 0 { 158 | for key, value := range fields[0] { 159 | mw.WriteField(key, value) 160 | } 161 | } 162 | 163 | // adds the terminating boundary 164 | mw.Close() 165 | 166 | request, err := http.NewRequest(method, r.Url(path), &buf) 167 | if err != nil { 168 | r.t.Fatalf("httptesting: NewMultipartRequest:%s %s: %v\n", method, path, err) 169 | } 170 | request.Header.Set("Content-Type", mw.FormDataContentType()) 171 | 172 | r.NewRequest(request) 173 | } 174 | -------------------------------------------------------------------------------- /request_helpers.go: -------------------------------------------------------------------------------- 1 | package httptesting 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "encoding/xml" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | ) 12 | 13 | // Get issues a GET request to the given path with Content-Type: text/html header, and 14 | // stores the result in Response and ResponseBody if success. 15 | func (r *Request) Get(path string, params ...url.Values) { 16 | contentType := "text/html" 17 | 18 | if len(params) == 0 { 19 | r.Send("GET", path, contentType) 20 | } else { 21 | r.Send("GET", path, contentType, params[0]) 22 | } 23 | } 24 | 25 | // GetJSON issues a GET request to the given path with Content-Type: application/json header, and 26 | // stores the result in Response and ResponseBody if success. 27 | func (r *Request) GetJSON(path string, params ...url.Values) { 28 | contentType := "application/json" 29 | 30 | if len(params) == 0 { 31 | r.Send("GET", path, contentType) 32 | } else { 33 | r.Send("GET", path, contentType, params[0]) 34 | } 35 | } 36 | 37 | // GetXML issues a GET request to the given path with Content-Type: text/xml header, and 38 | // stores the result in Response and ResponseBody if success. 39 | func (r *Request) GetXML(path string, params ...url.Values) { 40 | contentType := "text/xml" 41 | 42 | if len(params) == 0 { 43 | r.Send("GET", path, contentType) 44 | } else { 45 | r.Send("GET", path, contentType, params[0]) 46 | } 47 | } 48 | 49 | // Head issues a HEAD request to the given path with Content-Type: text/html header, and 50 | // stores the result in Response if success. 51 | func (r *Request) Head(path string, params ...url.Values) { 52 | contentType := "text/html" 53 | 54 | if len(params) == 0 { 55 | r.Send("HEAD", path, contentType) 56 | } else { 57 | r.Send("HEAD", path, contentType, params[0]) 58 | } 59 | } 60 | 61 | // Options issues an OPTIONS request to the given path Content-Type: text/html header, and 62 | // stores the result in Response if success. 63 | func (r *Request) Options(path string, params ...url.Values) { 64 | contentType := "text/html" 65 | 66 | if len(params) == 0 { 67 | r.Send("OPTIONS", path, contentType) 68 | } else { 69 | r.Send("OPTIONS", path, contentType, params[0]) 70 | } 71 | } 72 | 73 | // Put issues a PUT request to the given path with specified Content-Type header, and 74 | // stores the result in Response and ResponseBody if success. 75 | func (r *Request) Put(path, contentType string, data ...interface{}) { 76 | r.Send("PUT", path, contentType, data...) 77 | } 78 | 79 | // PutForm issues a PUT request to the given path with Content-Type: application/x-www-form-urlencoded header, and 80 | // stores the result in Response and ResponseBody if success. 81 | func (r *Request) PutForm(path string, data interface{}) { 82 | r.Put(path, "application/x-www-form-urlencoded", data) 83 | } 84 | 85 | // PutJSON issues a PUT request to the given path with Content-Type: application/json header, and 86 | // stores the result in Response and ResponseBody if success. 87 | // NOTE: It will encode data by json.Marshal before making request. 88 | func (r *Request) PutJSON(path string, data interface{}) { 89 | b, err := json.Marshal(data) 90 | if err != nil { 91 | r.t.Fatalf("httptesting: PutJSON:json.Marshal(%T): %v", data, err) 92 | } 93 | 94 | r.Put(path, "application/json", b) 95 | } 96 | 97 | // PutXML issues a PUT request to the given path with Content-Type: text/xml header, and 98 | // stores the result in Response and ResponseBody if success. 99 | // NOTE: It will encode data by xml.Marshal before making request. 100 | func (r *Request) PutXML(path string, data interface{}) { 101 | b, err := xml.Marshal(data) 102 | if err != nil { 103 | r.t.Fatalf("httptesting: PutXML:xml.Marshal(%T): %v", data, err) 104 | } 105 | 106 | r.Put(path, "text/xml", b) 107 | } 108 | 109 | // Post issues a POST request to the given path with specified Content-Type header, and 110 | // stores the result in Response and ResponseBody if success. 111 | func (r *Request) Post(path, contentType string, data ...interface{}) { 112 | r.Send("POST", path, contentType, data...) 113 | } 114 | 115 | // PostForm issues a POST request to the given path with Content-Type: application/x-www-form-urlencoded header, and 116 | // stores the result in Response and ResponseBody if success. 117 | func (r *Request) PostForm(path string, data interface{}) { 118 | r.Post(path, "application/x-www-form-urlencoded", data) 119 | } 120 | 121 | // PostJSON issues a POST request to the given path with Content-Type: application/json header, and 122 | // stores the result in Response and ResponseBody if success. 123 | // NOTE: It will encode data by json.Marshal before making request. 124 | func (r *Request) PostJSON(path string, data interface{}) { 125 | b, err := json.Marshal(data) 126 | if err != nil { 127 | r.t.Fatalf("httptesting: PostJSON:json.Marshal(%T): %v", data, err) 128 | } 129 | 130 | r.Post(path, "application/json", b) 131 | } 132 | 133 | // PostXML issues a POST request to the given path with Content-Type: text/xml header, and 134 | // stores the result in Response and ResponseBody if success. 135 | // NOTE: It will encode data by xml.Marshal before making request. 136 | func (r *Request) PostXML(path string, data interface{}) { 137 | b, err := xml.Marshal(data) 138 | if err != nil { 139 | r.t.Fatalf("httptesting: PostXML:xml.Marshal(%T): %v", data, err) 140 | } 141 | 142 | r.Post(path, "text/xml", b) 143 | } 144 | 145 | // Patch issues a PATCH request to the given path with specified Content-Type header, and 146 | // stores the result in Response and ResponseBody if success. 147 | func (r *Request) Patch(path, contentType string, data ...interface{}) { 148 | r.Send("PATCH", path, contentType, data...) 149 | } 150 | 151 | // PatchForm issues a PATCH request to the given path with Content-Type: application/x-www-form-urlencoded header, and 152 | // stores the result in Response and ResponseBody if success. 153 | func (r *Request) PatchForm(path string, data interface{}) { 154 | r.Patch(path, "application/x-www-form-urlencoded", data) 155 | } 156 | 157 | // PatchJSON issues a PATCH request to the given path with with Content-Type: application/json header, and 158 | // stores the result in Response and ResponseBody if success. 159 | // It will encode data by json.Marshal before making request. 160 | func (r *Request) PatchJSON(path string, data interface{}) { 161 | b, err := json.Marshal(data) 162 | if err != nil { 163 | r.t.Fatalf("httptesting: PatchJSON:json.Marshal(%T): %v", data, err) 164 | } 165 | 166 | r.Patch(path, "application/json", b) 167 | } 168 | 169 | // PatchXML issues a PATCH request to the given path with Content-Type: text/xml header, and 170 | // stores the result in Response and ResponseBody if success. 171 | // NOTE: It will encode data by xml.Marshal before making request. 172 | func (r *Request) PatchXML(path string, data interface{}) { 173 | b, err := xml.Marshal(data) 174 | if err != nil { 175 | r.t.Fatalf("httptesting: PatchXML:xml.Marshal(%T): %v", data, err) 176 | } 177 | 178 | r.Patch(path, "text/xml", b) 179 | } 180 | 181 | // Delete issues a DELETE request to the given path, sending request with specified Content-Type header, and 182 | // stores the result in Response and ResponseBody if success. 183 | func (r *Request) Delete(path, contentType string, data ...interface{}) { 184 | r.Send("DELETE", path, contentType, data...) 185 | } 186 | 187 | // DeleteForm issues a DELETE request to the given path with Content-Type: application/x-www-form-urlencoded header, and 188 | // stores the result in Response and ResponseBody if success. 189 | func (r *Request) DeleteForm(path string, data interface{}) { 190 | r.Delete(path, "application/x-www-form-urlencoded", data) 191 | } 192 | 193 | // DeleteJSON issues a DELETE request to the given path with Content-Type: application/json header, and 194 | // stores the result in Response and ResponseBody if success. 195 | // NOTE: It will encode data by json.Marshal before making request. 196 | func (r *Request) DeleteJSON(path string, data interface{}) { 197 | b, err := json.Marshal(data) 198 | if err != nil { 199 | r.t.Fatalf("httptesting: DeleteJSON:json.Marshal(%T): %v", data, err) 200 | } 201 | 202 | r.Delete(path, "application/json", b) 203 | } 204 | 205 | // DeleteXML issues a DELETE request to the given path with Content-Type: text/xml header, and 206 | // stores the result in Response and ResponseBody if success. 207 | // NOTE: It will encode data by xml.Marshal before making request. 208 | func (r *Request) DeleteXML(path string, data interface{}) { 209 | b, err := xml.Marshal(data) 210 | if err != nil { 211 | r.t.Fatalf("httptesting: DeleteXML:xml.Marshal(%T): %v", data, err) 212 | } 213 | 214 | r.Delete(path, "text/xml", b) 215 | } 216 | 217 | // Send issues a HTTP request to the given path with specified method and content type header, and 218 | // stores the result in Response and ResponseBody if success. 219 | // NOTE: It will encode data with json.Marshal for unsupported types and reset content type to application/json for the request. 220 | func (r *Request) Send(method, path, contentType string, data ...interface{}) { 221 | request, err := r.Build(method, path, contentType, data...) 222 | if err != nil { 223 | r.t.Fatalf("httptesting: Send:%s %s: %v\n", method, path, err) 224 | } 225 | 226 | // adjust custom headers 227 | for key, values := range r.header { 228 | // ignore Content-Type and Content-Length headers 229 | switch http.CanonicalHeaderKey(key) { 230 | case "Content-Type", "Content-Length": 231 | // ignore 232 | 233 | default: 234 | for _, value := range values { 235 | request.Header.Add(key, value) 236 | } 237 | } 238 | } 239 | 240 | r.NewSessionRequest(request) 241 | } 242 | 243 | func (r *Request) Build(method, urlpath, contentType string, data ...interface{}) (request *http.Request, err error) { 244 | absurl := r.Url(urlpath) 245 | 246 | var ( 247 | buf *bytes.Buffer 248 | ) 249 | 250 | if len(data) == 0 { 251 | buf = bytes.NewBuffer(nil) 252 | 253 | request, err = http.NewRequest(method, absurl, nil) 254 | } else { 255 | body := data[0] 256 | 257 | switch typo := body.(type) { 258 | case io.Reader: 259 | buf = bytes.NewBuffer(nil) 260 | io.Copy(buf, typo) 261 | 262 | case string: 263 | buf = bytes.NewBufferString(typo) 264 | 265 | case *string: 266 | buf = bytes.NewBufferString(*typo) 267 | 268 | case []byte: 269 | buf = bytes.NewBuffer(typo) 270 | 271 | case *[]byte: 272 | buf = bytes.NewBuffer(*typo) 273 | 274 | case url.Values: 275 | buf = bytes.NewBufferString(typo.Encode()) 276 | 277 | default: 278 | b, _ := json.Marshal(body) 279 | 280 | buf = bytes.NewBuffer(b) 281 | contentType = "application/json" 282 | } 283 | 284 | switch method { 285 | case "GET", "HEAD", "OPTIONS": // apply request params to url 286 | if buf.Len() > 0 { 287 | absurl += "?" + buf.String() 288 | 289 | // clean 290 | buf.Reset() 291 | } 292 | 293 | request, err = http.NewRequest(method, absurl, nil) 294 | 295 | default: 296 | request, err = http.NewRequest(method, absurl, buf) 297 | } 298 | } 299 | 300 | if err != nil { 301 | return 302 | } 303 | 304 | // hijack request headers 305 | request.Header.Set("Content-Type", contentType) 306 | request.Header.Set("Content-Length", strconv.FormatInt(int64(buf.Len()), 10)) 307 | request.ContentLength = int64(buf.Len()) 308 | return 309 | } 310 | -------------------------------------------------------------------------------- /request_helpers_test.go: -------------------------------------------------------------------------------- 1 | package httptesting 2 | 3 | import ( 4 | "encoding/xml" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | 11 | "github.com/golib/assert" 12 | ) 13 | 14 | func TestRequest_Get(t *testing.T) { 15 | it := assert.New(t) 16 | method := "GET" 17 | uri := "/get" 18 | params := url.Values{"url-key": []string{"url-value"}} 19 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 20 | it.Equal(method, r.Method) 21 | it.Equal("text/html", r.Header.Get("Content-Type")) 22 | it.Equal("/get?url-key=url-value", r.RequestURI) 23 | it.Equal("url-value", r.URL.Query().Get("url-key")) 24 | 25 | w.Header().Set("x-request-method", r.Method) 26 | w.WriteHeader(http.StatusOK) 27 | w.Write([]byte(method + " " + uri + " OK!")) 28 | }) 29 | 30 | ts := httptest.NewServer(server) 31 | defer ts.Close() 32 | 33 | request := New(ts.URL, false).New(t) 34 | request.Get(uri, params) 35 | request.AssertOK() 36 | request.AssertHeader("x-request-method", method) 37 | request.AssertContains("GET /get OK!") 38 | } 39 | 40 | func TestRequest_GetJSON(t *testing.T) { 41 | it := assert.New(t) 42 | method := "GET" 43 | uri := "/get" 44 | params := url.Values{"url-key": []string{"url-value"}} 45 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 46 | it.Equal(method, r.Method) 47 | it.Equal("application/json", r.Header.Get("Content-Type")) 48 | it.Equal("/get?url-key=url-value", r.RequestURI) 49 | it.Equal("url-value", r.URL.Query().Get("url-key")) 50 | 51 | w.Header().Set("x-request-method", r.Method) 52 | w.WriteHeader(http.StatusOK) 53 | w.Write([]byte(method + " " + uri + " OK!")) 54 | }) 55 | 56 | ts := httptest.NewServer(server) 57 | defer ts.Close() 58 | 59 | request := New(ts.URL, false).New(t) 60 | request.GetJSON(uri, params) 61 | request.AssertOK() 62 | request.AssertHeader("x-request-method", method) 63 | request.AssertContains("GET /get OK!") 64 | } 65 | 66 | func TestRequest_GetXML(t *testing.T) { 67 | it := assert.New(t) 68 | method := "GET" 69 | uri := "/get" 70 | params := url.Values{"url-key": []string{"url-value"}} 71 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 72 | it.Equal(method, r.Method) 73 | it.Equal("text/xml", r.Header.Get("Content-Type")) 74 | it.Equal("/get?url-key=url-value", r.RequestURI) 75 | it.Equal("url-value", r.URL.Query().Get("url-key")) 76 | 77 | w.Header().Set("x-request-method", r.Method) 78 | w.WriteHeader(http.StatusOK) 79 | w.Write([]byte(method + " " + uri + " OK!")) 80 | }) 81 | 82 | ts := httptest.NewServer(server) 83 | defer ts.Close() 84 | 85 | request := New(ts.URL, false).New(t) 86 | request.GetXML(uri, params) 87 | request.AssertOK() 88 | request.AssertHeader("x-request-method", method) 89 | request.AssertContains("GET /get OK!") 90 | } 91 | 92 | func TestRequest_Head(t *testing.T) { 93 | it := assert.New(t) 94 | method := "HEAD" 95 | uri := "/head?key" 96 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 97 | it.Equal(method, r.Method) 98 | it.Equal(uri, r.RequestURI) 99 | 100 | w.Header().Set("x-request-method", r.Method) 101 | w.WriteHeader(http.StatusOK) 102 | w.Write([]byte(method + " " + uri + " OK!")) 103 | }) 104 | 105 | ts := httptest.NewServer(server) 106 | defer ts.Close() 107 | 108 | request := New(ts.URL, false).New(t) 109 | request.Head(uri) 110 | request.AssertOK() 111 | request.AssertHeader("x-request-method", method) 112 | request.AssertEmpty() 113 | } 114 | 115 | func TestRequest_Options(t *testing.T) { 116 | it := assert.New(t) 117 | method := "OPTIONS" 118 | uri := "/options?key" 119 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 120 | it.Equal(method, r.Method) 121 | it.Equal(uri, r.RequestURI) 122 | 123 | w.Header().Set("x-request-method", r.Method) 124 | w.WriteHeader(http.StatusOK) 125 | w.Write([]byte(method + " " + uri + " OK!")) 126 | }) 127 | 128 | ts := httptest.NewServer(server) 129 | defer ts.Close() 130 | 131 | request := New(ts.URL, false).New(t) 132 | request.Options(uri) 133 | request.AssertOK() 134 | request.AssertHeader("x-request-method", method) 135 | request.AssertContains("OPTIONS /options?key OK!") 136 | } 137 | 138 | func TestRequest_PutForm(t *testing.T) { 139 | it := assert.New(t) 140 | method := "PUT" 141 | uri := "/put?key" 142 | params := url.Values{"form-key": []string{"form-value"}} 143 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 144 | it.Equal(method, r.Method) 145 | it.Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type")) 146 | it.Equal(uri, r.RequestURI) 147 | it.Equal("form-value", r.FormValue("form-key")) 148 | 149 | _, ok := r.URL.Query()["key"] 150 | it.True(ok) 151 | it.Empty(r.FormValue("key")) 152 | 153 | w.Header().Set("x-request-method", r.Method) 154 | w.WriteHeader(http.StatusOK) 155 | w.Write([]byte(method + " " + uri + " OK!")) 156 | }) 157 | 158 | ts := httptest.NewServer(server) 159 | defer ts.Close() 160 | 161 | request := New(ts.URL, false).New(t) 162 | request.PutForm(uri, params) 163 | request.AssertOK() 164 | request.AssertHeader("x-request-method", method) 165 | request.AssertContains("PUT /put?key OK!") 166 | } 167 | 168 | func TestRequest_PutJSON(t *testing.T) { 169 | it := assert.New(t) 170 | method := "PUT" 171 | uri := "/put?key" 172 | params := struct { 173 | Name string `json:"name"` 174 | Age int `json:"age"` 175 | Married bool `json:"married"` 176 | }{"testing", 1, false} 177 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 178 | it.Equal(method, r.Method) 179 | it.Equal("application/json", r.Header.Get("Content-Type")) 180 | it.Equal(uri, r.RequestURI) 181 | 182 | _, ok := r.URL.Query()["key"] 183 | it.True(ok) 184 | it.Empty(r.FormValue("key")) 185 | 186 | b, err := io.ReadAll(r.Body) 187 | r.Body.Close() 188 | it.Nil(err) 189 | it.Equal(`{"name":"testing","age":1,"married":false}`, string(b)) 190 | 191 | w.Header().Set("x-request-method", r.Method) 192 | w.WriteHeader(http.StatusOK) 193 | w.Write([]byte(method + " " + uri + " OK!")) 194 | }) 195 | 196 | ts := httptest.NewServer(server) 197 | defer ts.Close() 198 | 199 | request := New(ts.URL, false).New(t) 200 | request.PutJSON(uri, params) 201 | request.AssertOK() 202 | request.AssertHeader("x-request-method", method) 203 | request.AssertContains("PUT /put?key OK!") 204 | } 205 | 206 | func TestRequest_PutXML(t *testing.T) { 207 | type xmlData struct { 208 | XMLName xml.Name `xml:"Person"` 209 | Name string `xml:"Name"` 210 | Age int `xml:"Age"` 211 | Married bool `xml:"Married"` 212 | } 213 | 214 | it := assert.New(t) 215 | method := "PUT" 216 | uri := "/put?key" 217 | params := xmlData{ 218 | Name: "testing", 219 | Age: 1, 220 | Married: false, 221 | } 222 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 223 | it.Equal(method, r.Method) 224 | it.Equal("text/xml", r.Header.Get("Content-Type")) 225 | it.Equal(uri, r.RequestURI) 226 | 227 | _, ok := r.URL.Query()["key"] 228 | it.True(ok) 229 | it.Empty(r.FormValue("key")) 230 | 231 | b, err := io.ReadAll(r.Body) 232 | r.Body.Close() 233 | it.Nil(err) 234 | it.Equal(`testing1false`, string(b)) 235 | 236 | w.Header().Set("x-request-method", r.Method) 237 | w.WriteHeader(http.StatusOK) 238 | w.Write([]byte(method + " " + uri + " OK!")) 239 | }) 240 | 241 | ts := httptest.NewServer(server) 242 | defer ts.Close() 243 | 244 | request := New(ts.URL, false).New(t) 245 | request.PutXML(uri, params) 246 | request.AssertOK() 247 | request.AssertHeader("x-request-method", method) 248 | request.AssertContains("PUT /put?key OK!") 249 | } 250 | 251 | func TestRequest_PostForm(t *testing.T) { 252 | it := assert.New(t) 253 | method := "POST" 254 | uri := "/post?key" 255 | params := url.Values{"form-key": []string{"form-value"}} 256 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 257 | it.Equal(method, r.Method) 258 | it.Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type")) 259 | it.Equal(uri, r.RequestURI) 260 | it.Equal("form-value", r.FormValue("form-key")) 261 | 262 | _, ok := r.URL.Query()["key"] 263 | it.True(ok) 264 | it.Empty(r.FormValue("key")) 265 | 266 | w.Header().Set("x-request-method", r.Method) 267 | w.WriteHeader(http.StatusOK) 268 | w.Write([]byte(method + " " + uri + " OK!")) 269 | }) 270 | 271 | ts := httptest.NewServer(server) 272 | defer ts.Close() 273 | 274 | request := New(ts.URL, false).New(t) 275 | request.PostForm(uri, params) 276 | request.AssertOK() 277 | request.AssertHeader("x-request-method", method) 278 | request.AssertContains("POST /post?key OK!") 279 | } 280 | 281 | func TestRequest_PostJSON(t *testing.T) { 282 | it := assert.New(t) 283 | method := "POST" 284 | uri := "/post?key" 285 | params := struct { 286 | Name string `json:"name"` 287 | Age int `json:"age"` 288 | Married bool `json:"married"` 289 | }{"testing", 1, false} 290 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 291 | it.Equal(method, r.Method) 292 | it.Equal("application/json", r.Header.Get("Content-Type")) 293 | it.Equal(uri, r.RequestURI) 294 | 295 | _, ok := r.URL.Query()["key"] 296 | it.True(ok) 297 | it.Empty(r.FormValue("key")) 298 | 299 | b, err := io.ReadAll(r.Body) 300 | r.Body.Close() 301 | it.Nil(err) 302 | it.Equal(`{"name":"testing","age":1,"married":false}`, string(b)) 303 | 304 | w.Header().Set("x-request-method", r.Method) 305 | w.WriteHeader(http.StatusOK) 306 | w.Write([]byte(method + " " + uri + " OK!")) 307 | }) 308 | 309 | ts := httptest.NewServer(server) 310 | defer ts.Close() 311 | 312 | request := New(ts.URL, false).New(t) 313 | request.PostJSON(uri, params) 314 | request.AssertOK() 315 | request.AssertHeader("x-request-method", method) 316 | request.AssertContains("POST /post?key OK!") 317 | } 318 | 319 | func TestRequest_PostXML(t *testing.T) { 320 | type xmlData struct { 321 | XMLName xml.Name `xml:"Person"` 322 | Name string `xml:"Name"` 323 | Age int `xml:"Age"` 324 | Married bool `xml:"Married"` 325 | } 326 | 327 | it := assert.New(t) 328 | method := "POST" 329 | uri := "/post?key" 330 | params := xmlData{ 331 | Name: "testing", 332 | Age: 1, 333 | Married: false, 334 | } 335 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 336 | it.Equal(method, r.Method) 337 | it.Equal("text/xml", r.Header.Get("Content-Type")) 338 | it.Equal(uri, r.RequestURI) 339 | 340 | _, ok := r.URL.Query()["key"] 341 | it.True(ok) 342 | it.Empty(r.FormValue("key")) 343 | 344 | b, err := io.ReadAll(r.Body) 345 | r.Body.Close() 346 | it.Nil(err) 347 | it.Equal(`testing1false`, string(b)) 348 | 349 | w.Header().Set("x-request-method", r.Method) 350 | w.WriteHeader(http.StatusOK) 351 | w.Write([]byte(method + " " + uri + " OK!")) 352 | }) 353 | 354 | ts := httptest.NewServer(server) 355 | defer ts.Close() 356 | 357 | request := New(ts.URL, false).New(t) 358 | request.PostXML(uri, params) 359 | request.AssertOK() 360 | request.AssertHeader("x-request-method", method) 361 | request.AssertContains("POST /post?key OK!") 362 | } 363 | 364 | func TestRequest_PatchForm(t *testing.T) { 365 | it := assert.New(t) 366 | method := "PATCH" 367 | uri := "/patch?key" 368 | params := url.Values{"form-key": []string{"form-value"}} 369 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 370 | it.Equal(method, r.Method) 371 | it.Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type")) 372 | it.Equal(uri, r.RequestURI) 373 | it.Equal("form-value", r.FormValue("form-key")) 374 | 375 | _, ok := r.URL.Query()["key"] 376 | it.True(ok) 377 | it.Empty(r.FormValue("key")) 378 | 379 | w.Header().Set("x-request-method", r.Method) 380 | w.WriteHeader(http.StatusOK) 381 | w.Write([]byte(method + " " + uri + " OK!")) 382 | }) 383 | 384 | ts := httptest.NewServer(server) 385 | defer ts.Close() 386 | 387 | request := New(ts.URL, false).New(t) 388 | request.PatchForm(uri, params) 389 | request.AssertOK() 390 | request.AssertHeader("x-request-method", method) 391 | request.AssertContains("PATCH /patch?key OK!") 392 | } 393 | 394 | func TestRequest_PatchJSON(t *testing.T) { 395 | it := assert.New(t) 396 | method := "PATCH" 397 | uri := "/patch?key" 398 | params := struct { 399 | Name string `json:"name"` 400 | Age int `json:"age"` 401 | Married bool `json:"married"` 402 | }{"testing", 1, false} 403 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 404 | it.Equal(method, r.Method) 405 | it.Equal("application/json", r.Header.Get("Content-Type")) 406 | it.Equal(uri, r.RequestURI) 407 | 408 | _, ok := r.URL.Query()["key"] 409 | it.True(ok) 410 | it.Empty(r.FormValue("key")) 411 | 412 | b, err := io.ReadAll(r.Body) 413 | r.Body.Close() 414 | it.Nil(err) 415 | it.Equal(`{"name":"testing","age":1,"married":false}`, string(b)) 416 | 417 | w.Header().Set("x-request-method", r.Method) 418 | w.WriteHeader(http.StatusOK) 419 | w.Write([]byte(method + " " + uri + " OK!")) 420 | }) 421 | 422 | ts := httptest.NewServer(server) 423 | defer ts.Close() 424 | 425 | request := New(ts.URL, false).New(t) 426 | request.PatchJSON(uri, params) 427 | request.AssertOK() 428 | request.AssertHeader("x-request-method", method) 429 | request.AssertContains("PATCH /patch?key OK!") 430 | } 431 | 432 | func TestRequest_PatchXML(t *testing.T) { 433 | type xmlData struct { 434 | XMLName xml.Name `xml:"Person"` 435 | Name string `xml:"Name"` 436 | Age int `xml:"Age"` 437 | Married bool `xml:"Married"` 438 | } 439 | 440 | it := assert.New(t) 441 | method := "PATCH" 442 | uri := "/patch?key" 443 | params := xmlData{ 444 | Name: "testing", 445 | Age: 1, 446 | Married: false, 447 | } 448 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 449 | it.Equal(method, r.Method) 450 | it.Equal("text/xml", r.Header.Get("Content-Type")) 451 | it.Equal(uri, r.RequestURI) 452 | 453 | _, ok := r.URL.Query()["key"] 454 | it.True(ok) 455 | it.Empty(r.FormValue("key")) 456 | 457 | b, err := io.ReadAll(r.Body) 458 | r.Body.Close() 459 | it.Nil(err) 460 | it.Equal(`testing1false`, string(b)) 461 | 462 | w.Header().Set("x-request-method", r.Method) 463 | w.WriteHeader(http.StatusOK) 464 | w.Write([]byte(method + " " + uri + " OK!")) 465 | }) 466 | 467 | ts := httptest.NewServer(server) 468 | defer ts.Close() 469 | 470 | request := New(ts.URL, false).New(t) 471 | request.PatchXML(uri, params) 472 | request.AssertOK() 473 | request.AssertHeader("x-request-method", method) 474 | request.AssertContains("PATCH /patch?key OK!") 475 | } 476 | 477 | func TestRequest_DeleteForm(t *testing.T) { 478 | it := assert.New(t) 479 | method := "DELETE" 480 | uri := "/delete?key" 481 | params := url.Values{"form-key": []string{"form-value"}} 482 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 483 | it.Equal(method, r.Method) 484 | it.Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type")) 485 | it.Equal(uri, r.RequestURI) 486 | it.Empty(r.FormValue("form-key")) 487 | 488 | _, ok := r.URL.Query()["key"] 489 | it.True(ok) 490 | it.Empty(r.FormValue("key")) 491 | 492 | b, err := io.ReadAll(r.Body) 493 | r.Body.Close() 494 | it.Nil(err) 495 | it.Equal(`form-key=form-value`, string(b)) 496 | 497 | w.Header().Set("x-request-method", r.Method) 498 | w.WriteHeader(http.StatusOK) 499 | w.Write([]byte(method + " " + uri + " OK!")) 500 | }) 501 | 502 | ts := httptest.NewServer(server) 503 | defer ts.Close() 504 | 505 | request := New(ts.URL, false).New(t) 506 | request.DeleteForm(uri, params) 507 | request.AssertOK() 508 | request.AssertHeader("x-request-method", method) 509 | request.AssertContains("DELETE /delete?key OK!") 510 | } 511 | 512 | func TestRequest_DeleteJSON(t *testing.T) { 513 | it := assert.New(t) 514 | method := "DELETE" 515 | uri := "/delete?key" 516 | params := struct { 517 | Name string `json:"name"` 518 | Age int `json:"age"` 519 | Married bool `json:"married"` 520 | }{"testing", 1, false} 521 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 522 | it.Equal(method, r.Method) 523 | it.Equal("application/json", r.Header.Get("Content-Type")) 524 | it.Equal(uri, r.RequestURI) 525 | 526 | _, ok := r.URL.Query()["key"] 527 | it.True(ok) 528 | it.Empty(r.FormValue("key")) 529 | 530 | b, err := io.ReadAll(r.Body) 531 | r.Body.Close() 532 | it.Nil(err) 533 | it.Equal(`{"name":"testing","age":1,"married":false}`, string(b)) 534 | 535 | w.Header().Set("x-request-method", r.Method) 536 | w.WriteHeader(http.StatusOK) 537 | w.Write([]byte(method + " " + uri + " OK!")) 538 | }) 539 | 540 | ts := httptest.NewServer(server) 541 | defer ts.Close() 542 | 543 | request := New(ts.URL, false).New(t) 544 | request.DeleteJSON(uri, params) 545 | request.AssertOK() 546 | request.AssertHeader("x-request-method", method) 547 | request.AssertContains("DELETE /delete?key OK!") 548 | } 549 | 550 | func TestRequest_DeleteXML(t *testing.T) { 551 | type xmlData struct { 552 | XMLName xml.Name `xml:"Person"` 553 | Name string `xml:"Name"` 554 | Age int `xml:"Age"` 555 | Married bool `xml:"Married"` 556 | } 557 | 558 | it := assert.New(t) 559 | method := "DELETE" 560 | uri := "/delete?key" 561 | params := xmlData{ 562 | Name: "testing", 563 | Age: 1, 564 | Married: false, 565 | } 566 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 567 | it.Equal(method, r.Method) 568 | it.Equal("text/xml", r.Header.Get("Content-Type")) 569 | it.Equal(uri, r.RequestURI) 570 | 571 | _, ok := r.URL.Query()["key"] 572 | it.True(ok) 573 | it.Empty(r.FormValue("key")) 574 | 575 | b, err := io.ReadAll(r.Body) 576 | r.Body.Close() 577 | it.Nil(err) 578 | it.Equal(`testing1false`, string(b)) 579 | 580 | w.Header().Set("x-request-method", r.Method) 581 | w.WriteHeader(http.StatusOK) 582 | w.Write([]byte(method + " " + uri + " OK!")) 583 | }) 584 | 585 | ts := httptest.NewServer(server) 586 | defer ts.Close() 587 | 588 | request := New(ts.URL, false).New(t) 589 | request.DeleteXML(uri, params) 590 | request.AssertOK() 591 | request.AssertHeader("x-request-method", method) 592 | request.AssertContains("DELETE /delete?key OK!") 593 | } 594 | -------------------------------------------------------------------------------- /request_test.go: -------------------------------------------------------------------------------- 1 | package httptesting 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/golib/assert" 11 | ) 12 | 13 | func TestRequest(t *testing.T) { 14 | it := assert.New(t) 15 | 16 | method := "GET" 17 | uri := "/request/client" 18 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 19 | it.Equal(method, r.Method) 20 | it.Equal("/request/client", r.RequestURI) 21 | 22 | w.WriteHeader(http.StatusOK) 23 | w.Write([]byte(r.Header.Get("X-Mock-Client"))) 24 | }) 25 | 26 | ts := httptest.NewServer(server) 27 | defer ts.Close() 28 | 29 | client := New(ts.URL, false) 30 | 31 | request := client.New(t) 32 | request.WithHeader("X-Mock-Client", "httptesting") 33 | 34 | request.Get("/request/client", nil) 35 | request.AssertOK() 36 | request.AssertContains("httptesting") 37 | } 38 | 39 | func TestRequestWithConcurrency(t *testing.T) { 40 | it := assert.New(t) 41 | 42 | method := "GET" 43 | uri := "/request/client" 44 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 45 | it.Equal(method, r.Method) 46 | it.Equal("/request/client", r.RequestURI) 47 | 48 | time.Sleep(10 * time.Millisecond) 49 | 50 | w.WriteHeader(http.StatusOK) 51 | w.Write([]byte(r.Header.Get("X-Mock-Client"))) 52 | }) 53 | 54 | ts := httptest.NewServer(server) 55 | defer ts.Close() 56 | 57 | client := New(ts.URL, false) 58 | 59 | var ( 60 | wg sync.WaitGroup 61 | 62 | concurrency = 3 63 | ) 64 | 65 | issuedAt := time.Now() 66 | 67 | wg.Add(concurrency) 68 | for i := 0; i < concurrency; i++ { 69 | go func() { 70 | defer wg.Done() 71 | 72 | request := client.New(t) 73 | request.WithHeader("X-Mock-Client", "httptesting") 74 | 75 | request.Get("/request/client", nil) 76 | request.AssertOK() 77 | request.AssertContains("httptesting") 78 | }() 79 | } 80 | wg.Wait() 81 | 82 | delta := time.Since(issuedAt) 83 | it.True(delta < 20*time.Millisecond) 84 | } 85 | -------------------------------------------------------------------------------- /server_1_8.go: -------------------------------------------------------------------------------- 1 | //go:build !go1.9 2 | 3 | package httptesting 4 | 5 | import ( 6 | "crypto/tls" 7 | "crypto/x509" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "net/http/cookiejar" 12 | "net/http/httptest" 13 | "net/url" 14 | 15 | "github.com/dolab/httptesting/internal" 16 | ) 17 | 18 | // NewServer returns an initialized *Client along with mocked server for testing 19 | // NOTE: You MUST call client.Close() for cleanup after testing. 20 | func NewServer(handler http.Handler, isTLS bool) *Client { 21 | var ( 22 | ts *httptest.Server 23 | certs *x509.CertPool 24 | ) 25 | if isTLS { 26 | cert, err := tls.X509KeyPair(internal.LocalhostCert, internal.LocalhostKey) 27 | if err != nil { 28 | panic(fmt.Sprintf("httptesting: NewTLSServer: %v", err)) 29 | } 30 | 31 | ts = httptest.NewTLSServer(handler) 32 | 33 | x509cert, err := x509.ParseCertificate(cert.Certificate[0]) 34 | if err != nil { 35 | panic(fmt.Sprintf("httptesting: NewTLSServer: %v", err)) 36 | } 37 | 38 | certs = x509.NewCertPool() 39 | certs.AddCert(x509cert) 40 | } else { 41 | ts = httptest.NewServer(handler) 42 | } 43 | 44 | jar, err := cookiejar.New(nil) 45 | if err != nil { 46 | panic(err.Error()) 47 | } 48 | 49 | urlobj, err := url.Parse(ts.URL) 50 | if err != nil { 51 | panic(err.Error()) 52 | } 53 | 54 | return &Client{ 55 | server: ts, 56 | host: urlobj.Host, 57 | certs: certs, 58 | jar: jar, 59 | isTLS: isTLS, 60 | } 61 | } 62 | 63 | // NewServerWithTLS returns an initialized *Client along with mocked server for testing 64 | // NOTE: You MUST call client.Close() for cleanup after testing. 65 | func NewServerWithTLS(handler http.Handler, cert tls.Certificate) *Client { 66 | x509cert, err := x509.ParseCertificate(cert.Certificate[0]) 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | 71 | ts := httptest.NewUnstartedServer(handler) 72 | ts.TLS = &tls.Config{ 73 | Certificates: []tls.Certificate{cert}, 74 | } 75 | ts.StartTLS() 76 | 77 | certs := x509.NewCertPool() 78 | certs.AddCert(x509cert) 79 | 80 | jar, err := cookiejar.New(nil) 81 | if err != nil { 82 | panic(err.Error()) 83 | } 84 | 85 | urlobj, err := url.Parse(ts.URL) 86 | if err != nil { 87 | panic(err.Error()) 88 | } 89 | 90 | return &Client{ 91 | server: ts, 92 | host: urlobj.Host, 93 | certs: certs, 94 | jar: jar, 95 | isTLS: true, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /server_1_9.go: -------------------------------------------------------------------------------- 1 | //go:build go1.9 2 | 3 | package httptesting 4 | 5 | import ( 6 | "crypto/tls" 7 | "crypto/x509" 8 | "net/http" 9 | "net/http/cookiejar" 10 | "net/http/httptest" 11 | "net/url" 12 | ) 13 | 14 | // NewServer returns an initialized *Client along with mocked server for testing 15 | // NOTE: You MUST call client.Close() for cleanup after testing. 16 | func NewServer(handler http.Handler, isTLS bool) *Client { 17 | var ( 18 | ts *httptest.Server 19 | certs *x509.CertPool 20 | ) 21 | if isTLS { 22 | ts = httptest.NewTLSServer(handler) 23 | 24 | if transport, ok := ts.Client().Transport.(*http.Transport); ok { 25 | certs = transport.TLSClientConfig.RootCAs 26 | } 27 | } else { 28 | ts = httptest.NewServer(handler) 29 | } 30 | 31 | jar, err := cookiejar.New(nil) 32 | if err != nil { 33 | panic(err.Error()) 34 | } 35 | 36 | urlobj, err := url.Parse(ts.URL) 37 | if err != nil { 38 | panic(err.Error()) 39 | } 40 | 41 | return &Client{ 42 | server: ts, 43 | host: urlobj.Host, 44 | certs: certs, 45 | jar: jar, 46 | isTLS: isTLS, 47 | } 48 | } 49 | 50 | // NewServerWithTLS returns an initialized *Client along with mocked server for testing 51 | // NOTE: You MUST call client.Close() for cleanup after testing. 52 | func NewServerWithTLS(handler http.Handler, cert tls.Certificate) *Client { 53 | ts := httptest.NewUnstartedServer(handler) 54 | ts.TLS = &tls.Config{ 55 | Certificates: []tls.Certificate{cert}, 56 | } 57 | ts.StartTLS() 58 | 59 | jar, err := cookiejar.New(nil) 60 | if err != nil { 61 | panic(err.Error()) 62 | } 63 | 64 | urlobj, err := url.Parse(ts.URL) 65 | if err != nil { 66 | panic(err.Error()) 67 | } 68 | 69 | var certs *x509.CertPool 70 | if transport, ok := ts.Client().Transport.(*http.Transport); ok { 71 | certs = transport.TLSClientConfig.RootCAs 72 | } 73 | 74 | return &Client{ 75 | server: ts, 76 | host: urlobj.Host, 77 | certs: certs, 78 | jar: jar, 79 | isTLS: true, 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package httptesting 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/dolab/httptesting/internal" 10 | "github.com/golib/assert" 11 | ) 12 | 13 | func Test_NewServer(t *testing.T) { 14 | it := assert.New(t) 15 | 16 | method := "GET" 17 | uri := "/server" 18 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 19 | w.Header().Set("x-request-method", r.Method) 20 | 21 | w.WriteHeader(http.StatusOK) 22 | w.Write([]byte("TLS")) 23 | }) 24 | 25 | ts := NewServer(server, true) 26 | defer ts.Close() 27 | 28 | it.NotNil(ts.server) 29 | it.NotEmpty(ts.host) 30 | it.True(ts.isTLS) 31 | 32 | // it should work with internal client 33 | request := ts.New(t) 34 | request.Get("/server/tls", nil) 35 | request.AssertOK() 36 | request.AssertContains("TLS") 37 | 38 | // it should work with custom TLS client 39 | cert, err := tls.X509KeyPair(internal.LocalhostCert, internal.LocalhostKey) 40 | if it.Nil(err) { 41 | x509cert, err := x509.ParseCertificate(cert.Certificate[0]) 42 | if it.Nil(err) { 43 | client := NewWithTLS(ts.Url(""), x509cert) 44 | 45 | request = client.New(t) 46 | request.Get("/server/tls", nil) 47 | request.AssertOK() 48 | request.AssertContains("TLS") 49 | } 50 | } 51 | } 52 | 53 | func Test_NewServerWithTLS(t *testing.T) { 54 | it := assert.New(t) 55 | 56 | method := "GET" 57 | uri := "/server/tls" 58 | server := newMockServer(method, uri, func(w http.ResponseWriter, r *http.Request) { 59 | w.Header().Set("x-request-method", r.Method) 60 | 61 | w.WriteHeader(http.StatusOK) 62 | w.Write([]byte("TLS")) 63 | }) 64 | 65 | cert, err := tls.X509KeyPair(internal.LocalhostCert, internal.LocalhostKey) 66 | if it.Nil(err) { 67 | ts := NewServerWithTLS(server, cert) 68 | defer ts.Close() 69 | 70 | it.NotNil(ts.server) 71 | it.NotEmpty(ts.host) 72 | it.True(ts.isTLS) 73 | 74 | // it should work with internal client 75 | request := ts.New(t) 76 | request.Get("/server/tls", nil) 77 | request.AssertOK() 78 | request.AssertContains("TLS") 79 | 80 | // it should work with custom TLS client 81 | x509cert, err := x509.ParseCertificate(cert.Certificate[0]) 82 | if it.Nil(err) { 83 | client := NewWithTLS(ts.Url(""), x509cert) 84 | 85 | request = client.New(t) 86 | request.Get("/server/tls", nil) 87 | request.AssertOK() 88 | request.AssertContains("TLS") 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /transport.go: -------------------------------------------------------------------------------- 1 | package httptesting 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "net" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | // RequestFilter is a callback for http request injection. 13 | type RequestFilter func(r *http.Request) error 14 | 15 | // FilterTransport defines a custom http.Transport with filters and certs. 16 | type FilterTransport struct { 17 | filters []RequestFilter 18 | certs []*x509.CertPool 19 | } 20 | 21 | func NewFilterTransport(filters []RequestFilter, certs ...*x509.CertPool) *FilterTransport { 22 | return &FilterTransport{ 23 | filters: filters, 24 | certs: certs, 25 | } 26 | } 27 | 28 | func (transport *FilterTransport) RoundTrip(r *http.Request) (*http.Response, error) { 29 | tr := &http.Transport{ 30 | Proxy: http.ProxyFromEnvironment, 31 | DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { 32 | // invoke filters 33 | for _, filter := range transport.filters { 34 | err := filter(r) 35 | if err != nil { 36 | return nil, err 37 | } 38 | } 39 | 40 | dialer := &net.Dialer{ 41 | Timeout: 10 * time.Second, 42 | KeepAlive: 30 * time.Second, 43 | } 44 | 45 | conn, err := dialer.Dial(network, address) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | return conn, nil 51 | }, 52 | ResponseHeaderTimeout: 3 * time.Second, 53 | } 54 | 55 | if len(transport.certs) > 0 { 56 | tr.TLSClientConfig = &tls.Config{ 57 | RootCAs: transport.certs[0], 58 | InsecureSkipVerify: true, 59 | } 60 | tr.TLSHandshakeTimeout = 5 * time.Second 61 | } 62 | 63 | return tr.RoundTrip(r) 64 | } 65 | --------------------------------------------------------------------------------