├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── asshat ├── body.go ├── doc.go ├── header.go └── status.go ├── ci ├── fmt.mk ├── lint.mk └── test.mk ├── examples ├── helloworld │ ├── api.go │ └── api_test.go └── twitter │ └── twitter_test.go ├── go.mod ├── go.sum ├── hat.go ├── hat_test.go ├── request.go ├── request_test.go ├── response.go └── response_test.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | workflow_dispatch: 12 | 13 | jobs: 14 | fmt: 15 | runs-on: ubuntu-20.04 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: make fmt 20 | uses: ./ci/image 21 | with: 22 | args: make fmt 23 | 24 | lint: 25 | runs-on: ubuntu-20.04 26 | steps: 27 | - uses: actions/checkout@v2 28 | 29 | - name: make lint 30 | uses: ./ci/image 31 | with: 32 | args: make lint 33 | 34 | test: 35 | runs-on: ubuntu-20.04 36 | steps: 37 | - uses: actions/checkout@v2 38 | 39 | - name: make test 40 | uses: ./ci/image 41 | with: 42 | args: make test 43 | env: 44 | COVERALLS_TOKEN: ${{ secrets.github_token }} 45 | 46 | - name: Upload coverage.html 47 | uses: actions/upload-artifact@master 48 | with: 49 | name: coverage 50 | path: ci/out/coverage.html 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Coder Technologies, Inc. 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: fmt lint test 2 | 3 | .SILENT: 4 | 5 | .PHONY: * 6 | 7 | .ONESHELL: 8 | SHELL = bash 9 | .SHELLFLAGS = -ceuo pipefail 10 | 11 | include ci/fmt.mk 12 | include ci/lint.mk 13 | include ci/test.mk 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hat 2 | 3 | [![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://godoc.org/go.coder.com/hat) 4 | 5 | hat is an HTTP API testing framework for Go. 6 | 7 | It's based on composable, reusable response assertions, and request modifiers. It can dramatically **reduce API testing 8 | code**, while **improving clarity of test code and test output**. It leans on the standard `net/http` package 9 | as much as possible. 10 | 11 | Import as `go.coder.com/hat`. 12 | 13 | ## Example 14 | 15 | Let's test that twitter is working: 16 | 17 | ```go 18 | func TestTwitter(tt *testing.T) { 19 | t := hat.New(tt, "https://twitter.com") 20 | 21 | t.Get( 22 | hat.Path("/realDonaldTrump"), 23 | ).Send(t).Assert(t, 24 | asshat.StatusEqual(http.StatusOK), 25 | asshat.BodyMatches(`President`), 26 | ) 27 | } 28 | ``` 29 | 30 | 31 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* 32 | 33 | - [hat](#hat) 34 | - [Example](#example) 35 | - [Basic Concepts](#basic-concepts) 36 | - [Creating Requests](#creating-requests) 37 | - [Sending Requests](#sending-requests) 38 | - [Reading Responses](#reading-responses) 39 | - [Competitive Comparison](#competitive-comparison) 40 | - [API Symbols](#api-symbols) 41 | - [LoC](#loc) 42 | - [net/http](#nethttp) 43 | - [Chaining APIs](#chaining-apis) 44 | - [Design Patterns](#design-patterns) 45 | - [Format Agnostic](#format-agnostic) 46 | - [Minimal API](#minimal-api) 47 | - [testing.TB instead of *hat.T](#testingtb-instead-of-hatt) 48 | 49 | 50 | 51 | ## Basic Concepts 52 | 53 | ### Creating Requests 54 | hat's entrypoint is its `New` method 55 | 56 | ```go 57 | func New(t *testing.T, baseURL string) *T 58 | ``` 59 | 60 | which returns a `hat.T` that embeds a `testing.T`, and provides a bunch of methods such as 61 | `Get`, `Post`, and `Patch` to generate HTTP requests. Each request method looks like 62 | 63 | ```go 64 | func (t *T) Get(opts ...RequestOption) Request 65 | ``` 66 | 67 | RequestOption has the signature 68 | 69 | ```go 70 | type RequestOption func(t testing.TB, req *http.Request) 71 | ``` 72 | 73 | ### Sending Requests 74 | 75 | Each request modifies the request however it likes. [A few common `RequestOption`s are provided 76 | in the `hat` package.](https://godoc.org/go.coder.com/hat#RequestOption) 77 | 78 | Once the request is built, it can be sent 79 | ```go 80 | func (r Request) Send(t *T) *Response 81 | ``` 82 | 83 | or cloned 84 | 85 | ```go 86 | func (r Request) Clone(t *T, opts ...RequestOption) Request 87 | ``` 88 | _Cloning is useful when a test is making a slight modification of a complex request._ 89 | 90 | ### Reading Responses 91 | 92 | Once you've sent the request, you're given a `hat.Response`. The `Response` should be asserted. 93 | 94 | ```go 95 | func (r Response) Assert(t testing.TB, assertions ...ResponseAssertion) Response 96 | ``` 97 | 98 | `ResponseAssertion` looks like 99 | 100 | ```go 101 | type ResponseAssertion func(t testing.TB, r Response) 102 | ``` 103 | 104 | A bunch of pre-made response assertions are available in 105 | [the `asshat` package](https://godoc.org/go.coder.com/hat/asshat). 106 | 107 | 108 | ## Competitive Comparison 109 | 110 | It's difficult to say objectively which framework is the best. But, no existing 111 | framework satisfied us, and we're happy with hat. 112 | 113 | | Library | API Symbols | LoC | `net/http` | Custom Assertions/Modifiers | 114 | |------------------------------------------------------------|-------------|---------|--------------------------|-----------------------------| 115 | | hat | **24** | **410** | :heavy_check_mark: | :heavy_check_mark: | 116 | | [github.com/gavv/httpexpect](//github.com/gavv/httpexpect) | 280 | 10042 | :heavy_multiplication_x: | :warning: (Chaining API) | 117 | | [github.com/h2non/baloo](//github.com/h2non/baloo) | 91 | 2146 | :heavy_multiplication_x: | :warning: (Chaining API) | 118 | | [github.com/h2non/gock](//github.com/h2non/gock) | 122 | 2957 | :heavy_multiplication_x: | :warning: (Chaining API) | 119 | 120 | _LoC was calculated with cloc._ 121 | 122 | _Will add more columns and libraries on demand._ 123 | 124 | ### API Symbols 125 | 126 | Smaller APIs are easier to use and tend to be less opinionated. 127 | 128 | ### LoC 129 | 130 | Smaller codebases have less bugs and are easier to contribute to. 131 | 132 | ### net/http 133 | 134 | We prefer to use `net/http.Request` and `net/http.Response` so we can reuse the knowledge 135 | we already have. Also, we want to reimplement its surface area. 136 | 137 | ### Chaining APIs 138 | 139 | Chaining APIs look like 140 | 141 | ```go 142 | m.GET("/some-path"). 143 | Expect(). 144 | Status(http.StatusOK) 145 | ``` 146 | 147 | We dislike them because they make custom assertions and request modifiers a second-class citizen to 148 | the assertions and modifiers of the package. This encourages the framework's API to bloat, 149 | and discourages abstraction on part of the user. 150 | 151 | ## Design Patterns 152 | 153 | ### Format Agnostic 154 | 155 | `hat` makes no assumption about the structure of your API, request or response encoding, or 156 | the size of the requests or responses. 157 | 158 | ### Minimal API 159 | 160 | hat and asshat maintains a very small base of helpers. We think of the provided helpers as primitives 161 | for organization and application-specific helpers. 162 | 163 | ### Always Fatal 164 | 165 | While some assertions don't invalidate the test, we typically don't mind if they fail the test immediately. 166 | 167 | To avoid the API complexity of selecting 168 | between `Error`s and `Fatal`s, we fatal all the time. 169 | 170 | ### testing.TB instead of *hat.T 171 | 172 | When porting your code over to hat, it's better to accept a `testing.TB` than a `*hat.T` or a `*testing.T`. 173 | 174 | Only accept a `*hat.T` when the function is creating additional requests. This makes the code less coupled, 175 | while clarifying the scope of the helper. 176 | 177 | --- 178 | 179 | This pattern is used in hat itself. The `ResponseAssertion` type and the `Assert` function accept 180 | `testing.TB` instead of a concrete `*hat.T` or `*testing.T`. At first glance, it seems like wherever 181 | the caller is using a `ResponseAssertion` or `Assert`, they would have a `*hat.T`. 182 | 183 | In reality, this choice lets consumers hide the initialization of `hat.T` behind a helper function. E.g: 184 | 185 | ```go 186 | func TestSomething(t *testing.T) { 187 | makeRequest(t, 188 | hat.Path("/test"), 189 | ).Assert(t, 190 | asshat.StatusEqual(t, http.StatusOK), 191 | ) 192 | } 193 | ``` -------------------------------------------------------------------------------- /asshat/body.go: -------------------------------------------------------------------------------- 1 | package asshat 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "go.coder.com/hat" 10 | ) 11 | 12 | // BodyEqual checks if the response body equals expects. 13 | // Use BodyStringEqual instead of casting `expects` from a string so 14 | // the error message shows the textual difference. 15 | func BodyEqual(expects []byte) hat.ResponseAssertion { 16 | return func(t testing.TB, r hat.Response) { 17 | t.Helper() 18 | require.Equal(t, expects, r.DuplicateBody(t)) 19 | } 20 | } 21 | 22 | // BodyStringEqual checks if the body equals string expects. 23 | func BodyStringEqual(expects string) hat.ResponseAssertion { 24 | return func(t testing.TB, r hat.Response) { 25 | t.Helper() 26 | require.Equal(t, expects, string(r.DuplicateBody(t))) 27 | } 28 | } 29 | 30 | // BodyMatches ensures the response body matches the regex described by expr. 31 | func BodyMatches(expr string) hat.ResponseAssertion { 32 | // Compiling is expensive, so we do it once. 33 | rg, err := regexp.Compile(expr) 34 | 35 | return func(t testing.TB, r hat.Response) { 36 | t.Helper() 37 | require.NoError(t, err, "failed to compile regex") 38 | 39 | byt := r.DuplicateBody(t) 40 | if !rg.Match(byt) { 41 | t.Errorf("body %s does not match expr %v", byt, rg.String()) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /asshat/doc.go: -------------------------------------------------------------------------------- 1 | // Package asshat provides common ResponseAssertions. 2 | package asshat 3 | -------------------------------------------------------------------------------- /asshat/header.go: -------------------------------------------------------------------------------- 1 | package asshat 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "go.coder.com/hat" 9 | ) 10 | 11 | // HeaderEqual checks that the provided header key and value 12 | // are present in the response. 13 | func HeaderEqual(header, expected string) hat.ResponseAssertion { 14 | return func(t testing.TB, r hat.Response) { 15 | t.Helper() 16 | require.Equal(t, expected, r.Header.Get(header)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /asshat/status.go: -------------------------------------------------------------------------------- 1 | package asshat 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "go.coder.com/hat" 8 | ) 9 | 10 | // StatusEqual ensures the response status equals expected. 11 | func StatusEqual(expected int) hat.ResponseAssertion { 12 | return func(t testing.TB, r hat.Response) { 13 | t.Helper() 14 | 15 | got := r.StatusCode 16 | 17 | if expected != r.StatusCode { 18 | t.Errorf("wanted status %v (%v), got status %v (%v)", 19 | expected, http.StatusText(expected), 20 | got, http.StatusText(got), 21 | ) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ci/fmt.mk: -------------------------------------------------------------------------------- 1 | fmt: modtidy gofmt goimports prettier 2 | ifdef CI 3 | if [[ $$(git ls-files --other --modified --exclude-standard) != "" ]]; then 4 | echo "Files need generation or are formatted incorrectly:" 5 | git -c color.ui=always status | grep --color=no '\e\[31m' 6 | echo "Please run the following locally:" 7 | echo " make fmt" 8 | exit 1 9 | fi 10 | endif 11 | 12 | modtidy: gen 13 | go mod tidy 14 | 15 | gofmt: gen 16 | gofmt -w -s . 17 | 18 | goimports: gen 19 | goimports -w "-local=$$(go list -m)" . 20 | 21 | prettier: 22 | prettier --write --print-width=120 --no-semi --trailing-comma=all --loglevel=warn $$(git ls-files "*.yml") 23 | 24 | gen: 25 | go generate ./... 26 | -------------------------------------------------------------------------------- /ci/lint.mk: -------------------------------------------------------------------------------- 1 | lint: govet golint 2 | 3 | govet: 4 | go vet ./... 5 | 6 | golint: 7 | golint -set_exit_status ./... 8 | -------------------------------------------------------------------------------- /ci/test.mk: -------------------------------------------------------------------------------- 1 | test: gotest ci/out/coverage.html 2 | ifdef CI 3 | test: coveralls 4 | endif 5 | 6 | ci/out/coverage.html: gotest 7 | go tool cover -html=ci/out/coverage.prof -o=ci/out/coverage.html 8 | 9 | coveralls: gotest 10 | # https://github.com/coverallsapp/github-action/blob/master/src/run.ts 11 | echo "--- coveralls" 12 | export GIT_BRANCH="$$GITHUB_REF" 13 | export BUILD_NUMBER="$$GITHUB_SHA" 14 | if [[ $$GITHUB_EVENT_NAME == pull_request ]]; then 15 | export CI_PULL_REQUEST="$$(jq .number "$$GITHUB_EVENT_PATH")" 16 | BUILD_NUMBER="$$BUILD_NUMBER-PR-$$CI_PULL_REQUEST" 17 | fi 18 | goveralls -coverprofile=ci/out/coverage.prof -service=github 19 | 20 | gotest: 21 | go test -covermode=count -coverprofile=ci/out/coverage.prof -coverpkg=./... $${GOTESTFLAGS-} ./... 22 | sed -i '/internal\/assert/d' ci/out/coverage.prof 23 | -------------------------------------------------------------------------------- /examples/helloworld/api.go: -------------------------------------------------------------------------------- 1 | package helloworld 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | type API struct { 9 | } 10 | 11 | func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) { 12 | if len(r.URL.Path) > 9 { 13 | http.Error(w, "Path too long", http.StatusBadRequest) 14 | return 15 | } 16 | 17 | w.WriteHeader(http.StatusOK) 18 | fmt.Fprintf(w, "Hello "+r.URL.Path) 19 | } 20 | -------------------------------------------------------------------------------- /examples/helloworld/api_test.go: -------------------------------------------------------------------------------- 1 | package helloworld 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "go.coder.com/hat" 11 | "go.coder.com/hat/asshat" 12 | ) 13 | 14 | func TestAPI(tt *testing.T) { 15 | s := httptest.NewServer(&API{}) 16 | defer s.Close() 17 | 18 | t := hat.New(tt, s.URL) 19 | 20 | t.Run("Hello Echo single-parent chain", func(t *hat.T) { 21 | req := t.Get() 22 | 23 | req.Send(t).Assert( 24 | t, 25 | func(t testing.TB, r hat.Response) { 26 | byt := r.DuplicateBody(t) 27 | require.Equal(t, "Hello /", string(byt)) 28 | }, 29 | ) 30 | 31 | t.Run("underscore", func(t *hat.T) { 32 | req.Clone(t, 33 | hat.Path("/1234567890"), 34 | ).Send(t).Assert( 35 | t, 36 | func(t testing.TB, r hat.Response) { 37 | byt := r.DuplicateBody(t) 38 | require.Equal(t, "Path too long\n", string(byt)) 39 | }, 40 | asshat.StatusEqual(http.StatusBadRequest), 41 | ) 42 | }) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /examples/twitter/twitter_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "go.coder.com/hat" 5 | "go.coder.com/hat/asshat" 6 | "net/http" 7 | "testing" 8 | ) 9 | 10 | func TestTwitter(tt *testing.T) { 11 | t := hat.New(tt, "https://twitter.com") 12 | 13 | t.Get( 14 | hat.Path("/realDonaldTrump"), 15 | ).Send(t).Assert(t, 16 | asshat.StatusEqual(http.StatusOK), 17 | asshat.BodyMatches(`President`), 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.coder.com/hat 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 // indirect 7 | github.com/stretchr/testify v1.3.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 5 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 6 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 7 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 8 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 9 | -------------------------------------------------------------------------------- /hat.go: -------------------------------------------------------------------------------- 1 | package hat 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "path" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // T represents a test instance. 14 | // It intentionally does not provide any default request modifiers or 15 | // default response requireions. 16 | // Defaults should be explicitly provided to 17 | // Request and Assert. 18 | type T struct { 19 | *testing.T 20 | 21 | URL *url.URL 22 | Client *http.Client 23 | // persistentOpts are run on every request 24 | persistentOpts []RequestOption 25 | } 26 | 27 | // New creates a *T from a *testing.T. 28 | func New(t *testing.T, baseURL string) *T { 29 | client := &http.Client{ 30 | Timeout: time.Second * 5, 31 | } 32 | 33 | u, err := url.Parse(baseURL) 34 | require.NoError(t, err) 35 | 36 | return &T{ 37 | T: t, 38 | URL: u, 39 | Client: client, 40 | } 41 | } 42 | 43 | func (t *T) AddPersistentOpts(opts ...RequestOption) { 44 | t.persistentOpts = append(t.persistentOpts, opts...) 45 | } 46 | 47 | // Run creates a subtest. 48 | // The subtest inherits the settings of T. 49 | func (t *T) Run(name string, fn func(t *T)) { 50 | t.T.Run(name, func(tt *testing.T) { 51 | tt.Helper() 52 | t := *t 53 | t.T = tt 54 | u := *t.URL 55 | t.URL = &u 56 | 57 | fn(&t) 58 | }) 59 | } 60 | 61 | // RunPath creates a subtest with segment appended to the internal URL. 62 | // It uses segment as the name of the subtest. 63 | func (t *T) RunPath(elem string, fn func(t *T)) { 64 | t.Run(elem, func(t *T) { 65 | t.URL.Path = path.Join(t.URL.Path, elem) 66 | // preserve trailing slash 67 | if elem[len(elem)-1] == '/' && t.URL.Path != "/" { 68 | t.URL.Path += "/" 69 | } 70 | 71 | fn(t) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /hat_test.go: -------------------------------------------------------------------------------- 1 | package hat_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "go.coder.com/hat" 13 | "go.coder.com/hat/asshat" 14 | ) 15 | 16 | func TestT(tt *testing.T) { 17 | s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 18 | io.Copy(rw, req.Body) 19 | })) 20 | defer s.Close() 21 | 22 | t := hat.New(tt, s.URL) 23 | 24 | t.Run("Run Creates deep copy", func(dt *hat.T) { 25 | dt.URL.Path = "testing" 26 | require.NotEqual(t, dt.URL, t.URL) 27 | }) 28 | 29 | t.Run("RunURL Creates deep copy, and appends to URL", func(t *hat.T) { 30 | t.RunPath("/deeper", func(dt *hat.T) { 31 | require.Equal(t, s.URL+"/deeper", dt.URL.String()) 32 | require.NotEqual(t, dt.URL, t.URL) 33 | }) 34 | }) 35 | 36 | t.Run("PersistentOpt", func(t *hat.T) { 37 | pt := hat.New(tt, s.URL) 38 | exp := []byte("Hello World!") 39 | pt.AddPersistentOpts(hat.Body(bytes.NewBuffer(exp))) 40 | // Opt is attached by persistent opts 41 | pt.Get().Send(t).Assert(t, asshat.BodyEqual(exp)) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package hat 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "net/http" 7 | "net/url" 8 | "path" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | // RequestOption modifies a request. 15 | // Use the passed t to fail if the option cannot be set. 16 | type RequestOption func(t testing.TB, req *http.Request) 17 | 18 | // Header sets a header on the request. 19 | func Header(key, value string) RequestOption { 20 | return func(_ testing.TB, req *http.Request) { 21 | req.Header.Set(key, value) 22 | } 23 | } 24 | 25 | // URLParams sets the URL parameters of the request. 26 | func URLParams(v url.Values) RequestOption { 27 | return func(_ testing.TB, req *http.Request) { 28 | req.URL.RawQuery += v.Encode() 29 | } 30 | } 31 | 32 | // Path joins elem on to the URL. 33 | func Path(elem string) RequestOption { 34 | return func(_ testing.TB, req *http.Request) { 35 | req.URL.Path = path.Join(req.URL.Path, elem) 36 | // preserve trailing slash 37 | if elem[len(elem)-1] == '/' && req.URL.Path != "/" { 38 | req.URL.Path += "/" 39 | } 40 | } 41 | } 42 | 43 | // Body sets the body of a request. 44 | func Body(r io.Reader) RequestOption { 45 | rc, ok := r.(io.ReadCloser) 46 | if !ok { 47 | rc = ioutil.NopCloser(r) 48 | } 49 | 50 | return func(_ testing.TB, req *http.Request) { 51 | req.Body = rc 52 | } 53 | } 54 | 55 | // CombineRequestOptions returns a new RequestOption which internally 56 | // calls each member of options in the provided order. 57 | func CombineRequestOptions(opts ...RequestOption) RequestOption { 58 | return func(t testing.TB, req *http.Request) { 59 | t.Helper() 60 | for _, o := range opts { 61 | o(t, req) 62 | } 63 | } 64 | } 65 | 66 | // Request represents a pending HTTP request. 67 | type Request struct { 68 | r *http.Request 69 | 70 | // copy creates an exact copy of the request. 71 | copy func() *http.Request 72 | } 73 | 74 | func makeRequest(t testing.TB, copy func() *http.Request) Request { 75 | t.Helper() 76 | req := Request{ 77 | r: copy(), 78 | copy: copy, 79 | } 80 | return req 81 | } 82 | 83 | // Send dispatches the HTTP request. 84 | func (r Request) Send(t *T) *Response { 85 | t.Helper() 86 | t.Logf("%v %v", r.r.Method, r.r.URL) 87 | 88 | resp, err := t.Client.Do(r.r) 89 | require.NoError(t, err, "failed to send request") 90 | 91 | return &Response{ 92 | Response: resp, 93 | } 94 | } 95 | 96 | // Clone creates a duplicate HTTP request and applies opts to it. 97 | func (r Request) Clone(t *T, opts ...RequestOption) Request { 98 | t.Helper() 99 | return makeRequest(t, func() *http.Request { 100 | t.Helper() 101 | req := r.copy() 102 | for _, opt := range opts { 103 | opt(t, req) 104 | } 105 | return req 106 | }) 107 | } 108 | 109 | // Request creates an HTTP request to the endpoint. 110 | func (t T) Request(method string, opts ...RequestOption) Request { 111 | return makeRequest(t.T, 112 | func() *http.Request { 113 | req, err := http.NewRequest(method, t.URL.String(), nil) 114 | require.NoError(t, err, "failed to create request") 115 | 116 | for _, pOpt := range t.persistentOpts { 117 | pOpt(t, req) 118 | } 119 | for _, opt := range opts { 120 | opt(t, req) 121 | } 122 | 123 | return req 124 | }, 125 | ) 126 | } 127 | 128 | func (t *T) Get(opts ...RequestOption) Request { 129 | return t.Request(http.MethodGet, opts...) 130 | } 131 | 132 | func (t *T) Head(opts ...RequestOption) Request { 133 | return t.Request(http.MethodHead, opts...) 134 | } 135 | 136 | func (t *T) Post(opts ...RequestOption) Request { 137 | return t.Request(http.MethodPost, opts...) 138 | } 139 | 140 | func (t *T) Put(opts ...RequestOption) Request { 141 | return t.Request(http.MethodPut, opts...) 142 | } 143 | 144 | func (t *T) Patch(opts ...RequestOption) Request { 145 | return t.Request(http.MethodPatch, opts...) 146 | } 147 | 148 | func (t *T) Delete(opts ...RequestOption) Request { 149 | return t.Request(http.MethodDelete, opts...) 150 | } 151 | -------------------------------------------------------------------------------- /request_test.go: -------------------------------------------------------------------------------- 1 | package hat_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | 11 | "go.coder.com/hat" 12 | "go.coder.com/hat/asshat" 13 | ) 14 | 15 | func TestURLParams(t *testing.T) { 16 | t.Parallel() 17 | 18 | req, err := http.NewRequest("GET", "http://google.com", nil) 19 | require.NoError(t, err) 20 | 21 | hat.URLParams(url.Values{ 22 | "q": []string{"sean"}, 23 | })(t, req) 24 | 25 | require.Equal(t, "http://google.com?q=sean", req.URL.String()) 26 | } 27 | 28 | func TestHeader(tt *testing.T) { 29 | tt.Parallel() 30 | 31 | const hKey, hVal = "X-Custom-Header", "test-value" 32 | // Server returns status code 200 and checks the header is as expected 33 | s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 34 | require.Equal(tt, hVal, req.Header.Get(hKey)) 35 | rw.WriteHeader(http.StatusOK) 36 | })) 37 | defer s.Close() 38 | 39 | t := hat.New(tt, s.URL) 40 | t.Get(hat.Header(hKey, hVal)).Send(t).Assert(t, asshat.StatusEqual(http.StatusOK)) 41 | } 42 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package hat 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httputil" 9 | "testing" 10 | ) 11 | 12 | // ResponseAssertion requires a quality of the response. 13 | type ResponseAssertion func(t testing.TB, r Response) 14 | 15 | // CombineResponseAssertions returns a new ResponseAssertion which internally 16 | // calls each member of requires in the provided order. 17 | func CombineResponseAssertions(as ...ResponseAssertion) ResponseAssertion { 18 | return func(t testing.TB, r Response) { 19 | t.Helper() 20 | r.Assert(t, as...) 21 | } 22 | } 23 | 24 | // Response represents an HTTP response generated by hat.Request. 25 | type Response struct { 26 | *http.Response 27 | } 28 | 29 | // Assert runs each requireion against the response. 30 | // It closes the response body after all of the requireions have ran. 31 | // Assert must be called for every response as it will ensure the body is closed. 32 | // If you want to continue to reuse the connection, you must read the response body. 33 | func (r Response) Assert(t testing.TB, assertions ...ResponseAssertion) Response { 34 | t.Helper() 35 | defer r.Body.Close() 36 | 37 | for _, a := range assertions { 38 | a(t, r) 39 | if t.Failed() { 40 | header, _ := httputil.DumpResponse(r.Response, false) 41 | 42 | t.Errorf("resp:\n%s", header) 43 | 44 | body := r.DuplicateBody(t) 45 | fmted := &bytes.Buffer{} 46 | err := json.Indent(fmted, body, "", " ") 47 | if err == nil { 48 | body = fmted.Bytes() 49 | } 50 | 51 | t.Logf("body:\n%s", body) 52 | 53 | t.FailNow() 54 | } 55 | } 56 | 57 | return r 58 | } 59 | 60 | // DuplicateBody reads in the response body. 61 | // It replaces the underlying body with a duplicate. 62 | func (r Response) DuplicateBody(t testing.TB) []byte { 63 | defer r.Body.Close() 64 | 65 | byt, err := ioutil.ReadAll(r.Body) 66 | if err != nil { 67 | t.Fatalf("failed to read body: %v", err) 68 | } 69 | 70 | r.Response.Body = ioutil.NopCloser(bytes.NewReader(byt)) 71 | 72 | return byt 73 | } 74 | -------------------------------------------------------------------------------- /response_test.go: -------------------------------------------------------------------------------- 1 | package hat 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestBody(t *testing.T) { 15 | req, err := http.NewRequest("GET", "google.com", nil) 16 | require.NoError(t, err) 17 | 18 | Body(strings.NewReader("test123"))(t, req) 19 | 20 | byt, err := ioutil.ReadAll(req.Body) 21 | require.NoError(t, err) 22 | 23 | require.Equal(t, []byte("test123"), byt) 24 | } 25 | 26 | func TestResponse(tt *testing.T) { 27 | tt.Parallel() 28 | 29 | s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 30 | io.Copy(rw, req.Body) 31 | })) 32 | defer s.Close() 33 | 34 | t := New(tt, s.URL) 35 | 36 | req := t.Get(func(t testing.TB, req *http.Request) { 37 | req.Body = ioutil.NopCloser(strings.NewReader("howdy")) 38 | }) 39 | 40 | t.Run("DuplicateBody", func(t *T) { 41 | for i := 0; i < 4; i++ { 42 | require.Equal(t, "howdy", string(req.Clone(t).Send(t).DuplicateBody(t))) 43 | } 44 | }) 45 | 46 | t.Run("Again", func(t *T) { 47 | for i := 0; i < 3; i++ { 48 | t.Logf("Iteration %v", i) 49 | req.Clone(t, 50 | func(t testing.TB, req *http.Request) { 51 | // Ensure request is being copied for every Again. 52 | req.URL.Path += "/a" 53 | require.Equal(t, "/a", req.URL.Path) 54 | req.Body = ioutil.NopCloser(strings.NewReader("a")) 55 | }, 56 | ).Send(t).Assert( 57 | t, 58 | func(t testing.TB, resp Response) { 59 | require.Equal(t, []byte("a"), resp.DuplicateBody(t)) 60 | }, 61 | ) 62 | } 63 | }) 64 | } 65 | --------------------------------------------------------------------------------