-
Notifications
You must be signed in to change notification settings - Fork 1
/
http_testcase.go
378 lines (312 loc) · 10.7 KB
/
http_testcase.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
package mt
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/jefflinse/melatonin/golden"
mtjson "github.com/jefflinse/melatonin/json"
)
// An HTTPTestCase tests a single call to an HTTP endpoint.
//
// An optional setup function can be provided to perform any necessary
// setup before the test is run, such as adding or removing objects in
// a database.
//
// All fields in the WantBody map are expected to be present in the
// response body.
type HTTPTestCase struct {
// After is an optional function that is run after the test is run.
// It can be used to perform any cleanup actions after the test,
// such as adding or removing objects in a database. Any error
// returned by After is treated as a test failure.
AfterFunc func() error
// Before is an optional function that is run before the test is run.
// It can be used to perform any prerequisites actions for the test,
// such as adding or removing objects in a database. Any error
// returned by Before is treated as a test failure.
BeforeFunc func() error
// Desc is a description of the test case.
Desc string
// Expectations is a set of values to compare the response against.
Expectations expectatons `json:"expectations"`
// GoldenFilePath is a path to a golden file defining expectations for the test case.
//
// If set, any WantStatus, WantHeaders, or WantBody values are overridden with
// values from the golden file.
GoldenFilePath string
// Path parameters to be mapped into the request path.
pathParams parameters
// Query parameters to be mapped into the request query.
queryParams parameters
// Body for the HTTP request. May contain deferred values.
requestBody any
// Configuration for the test
tctx *HTTPTestContext
// Underlying HTTP request for the test case.
request *http.Request
// Cancel function for the underlying HTTP request.
cancel context.CancelFunc
}
// expectatons represents the expected values for single HTTP response.
type expectatons struct {
// Body is the expected HTTP response body content.
Body any
// ExactHeaders indicates whether or not any unexpected response headers
// should be treated as a test failure.
WantExactHeaders bool
// ExactJSONBody indicates whether or not the expected JSON should be matched
// exactly (true) or treated as a subset of the response JSON (false).
WantExactJSONBody bool
// Headers is a map of HTTP headers that are expected to be present in
// the HTTP response.
Headers http.Header
// Status is the expected HTTP status code of the response. Default is 200.
Status int
}
var _ TestCase = &HTTPTestCase{}
// Action returns a short, uppercase verb describing the action performed by the
// test case.
func (tc *HTTPTestCase) Action() string {
return strings.ToUpper(tc.request.Method)
}
// After registers a function to be run after the test case.
func (tc *HTTPTestCase) After(after func() error) *HTTPTestCase {
tc.AfterFunc = after
return tc
}
// Before registers a function to be run before the test case.
func (tc *HTTPTestCase) Before(before func() error) *HTTPTestCase {
tc.BeforeFunc = before
return tc
}
// Describe sets a description for the test case.
func (tc *HTTPTestCase) Describe(description string) *HTTPTestCase {
tc.Desc = description
return tc
}
// Description returns a string describing the test case.
func (tc *HTTPTestCase) Description() string {
if tc.Desc != "" {
return tc.Desc
}
return fmt.Sprintf("%s %s (%d q, %d h)",
tc.Action(), tc.Target(),
len(tc.request.URL.Query()),
len(tc.request.Header),
)
}
// Execute runs the test case.
func (tc *HTTPTestCase) Execute() TestResult {
if tc.cancel != nil {
defer tc.cancel()
}
result := &HTTPTestCaseResult{
testCase: tc,
}
if tc.BeforeFunc != nil {
if err := tc.BeforeFunc(); err != nil {
return result.addFailures(err)
}
}
// apply path parameters
expandedPath, err := tc.pathParams.applyTo(tc.request.URL.Path)
if err != nil {
return result.addFailures(err)
}
tc.request.URL.Path = expandedPath
rawQuery, err := tc.queryParams.asRawQuery()
if err != nil {
return result.addFailures(err)
}
tc.request.URL.RawQuery = rawQuery
// resolve deferred values
resolvedBody, err := mtjson.ResolveDeferred(tc.requestBody)
if err != nil {
return result.addFailures(err)
}
b, err := toBytes(resolvedBody)
if err != nil {
return result.addFailures(err)
}
tc.request.Body = io.NopCloser(bytes.NewReader(b))
if tc.tctx.Handler != nil {
result.Status, result.Headers, result.Body, err = handleRequest(tc.tctx.Handler, tc.request)
if err != nil {
return result.addFailures(fmt.Errorf("failed to handle HTTP request: %w", err))
}
} else {
if tc.tctx.Client == nil {
tc.tctx.Client = http.DefaultClient
}
result.Status, result.Headers, result.Body, err = doRequest(tc.tctx.Client, tc.request)
if err != nil {
return result.addFailures(fmt.Errorf("failed to execute HTTP request: %w", err))
}
}
result.validateExpectations()
if tc.AfterFunc != nil {
if err := tc.AfterFunc(); err != nil {
result.addFailures(err)
}
}
return result
}
// Target returns a string representing the target of the action performed by the
// test case.
func (tc *HTTPTestCase) Target() string {
return tc.request.URL.Path
}
//
// Chainable qualifier methods that can be used to configure the test case.
//
// WithBody sets the request body for the test case.
func (tc *HTTPTestCase) WithBody(body any) *HTTPTestCase {
tc.requestBody = body
return tc
}
// WithHeader adds a request header to the test case.
func (tc *HTTPTestCase) WithHeader(key, value string) *HTTPTestCase {
tc.request.Header.Set(key, value)
return tc
}
// WithHeaders sets the request headers for the test case.
func (tc *HTTPTestCase) WithHeaders(headers http.Header) *HTTPTestCase {
tc.request.Header = headers
return tc
}
// WithPathParam adds a request path parameter to the test case.
func (tc *HTTPTestCase) WithPathParam(key string, value any) *HTTPTestCase {
tc.pathParams[key] = value
return tc
}
// WithPathParams sets the request path parameters for the test case.
func (tc *HTTPTestCase) WithPathParams(params map[string]any) *HTTPTestCase {
tc.pathParams = params
return tc
}
// WithQueryParam adds a request query parameter to the test case.
func (tc *HTTPTestCase) WithQueryParam(key string, value any) *HTTPTestCase {
tc.queryParams[key] = value
return tc
}
// WithQueryParams sets the request query parameters for the test case.
func (tc *HTTPTestCase) WithQueryParams(params map[string]any) *HTTPTestCase {
tc.queryParams = params
return tc
}
// WithTimeout sets a timeout for the test case.
func (tc *HTTPTestCase) WithTimeout(timeout time.Duration) *HTTPTestCase {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
tc.request = tc.request.WithContext(ctx)
tc.cancel = cancel
return tc
}
//
// Chainable expectation methods that can be used to configure the test case.
//
// ExpectBody sets the expected HTTP response body for the test case.
func (tc *HTTPTestCase) ExpectBody(body any) *HTTPTestCase {
tc.Expectations.Body = body
return tc
}
// ExpectExactBody sets the expected HTTP response body for the test case.
//
// Unlike ExpectBody, ExpectExactBody willl cause the test case to fail
// if the expected response body is a JSON object or array and contains any
// additional fields or values not present in the expected JSON content.
//
// For non-JSON values, ExpectExactBody behaves identically to ExpectBody.
func (tc *HTTPTestCase) ExpectExactBody(body any) *HTTPTestCase {
tc.Expectations.WantExactJSONBody = true
return tc.ExpectBody(body)
}
// ExpectExactHeaders sets the expected HTTP response headers for the test case.
//
// Unlike ExpectHeaders, ExpectExactHeaders willl cause the test case to fail
// if any unexpected headers are present in the response.
func (tc *HTTPTestCase) ExpectExactHeaders(headers http.Header) *HTTPTestCase {
tc.Expectations.WantExactHeaders = true
return tc.ExpectHeaders(headers)
}
// ExpectHeader adds an expected HTTP response header for the test case.
func (tc *HTTPTestCase) ExpectHeader(key, value string) *HTTPTestCase {
if tc.Expectations.Headers == nil {
tc.Expectations.Headers = http.Header{}
}
tc.Expectations.Headers.Set(key, value)
return tc
}
// ExpectHeaders sets the expected HTTP response headers for the test case.
//
// Unlike ExpectExactHeaders, ExpectHeaders only verifies that the expected
// headers are present in the response, and ignores any additional headers.
func (tc *HTTPTestCase) ExpectHeaders(headers http.Header) *HTTPTestCase {
tc.Expectations.Headers = headers
return tc
}
// ExpectGolden causes the test case to load its HTTP response expectations
// from a golden file.
func (tc *HTTPTestCase) ExpectGolden(path string) *HTTPTestCase {
tc.GoldenFilePath = path
return tc
}
// ExpectStatus sets the expected HTTP status code for the test case.
func (tc *HTTPTestCase) ExpectStatus(status int) *HTTPTestCase {
tc.Expectations.Status = status
return tc
}
// Validate ensures that the test case is valid can can be run.
func (tc *HTTPTestCase) Validate() error {
if tc.tctx.BaseURL != "" && tc.tctx.Handler != nil {
return fmt.Errorf("HTTP test context %q cannot specify both a base URL and handler", tc.tctx.BaseURL)
}
if tc.GoldenFilePath != "" {
path := tc.GoldenFilePath
if !filepath.IsAbs(path) {
path = filepath.Join(cfg.WorkingDir, path)
}
golden, err := golden.LoadFile(path)
if err != nil {
return err
}
tc.Expectations.Status = golden.WantStatus
tc.Expectations.Headers = golden.WantHeaders
tc.Expectations.Body = golden.WantBody
tc.Expectations.WantExactHeaders = golden.MatchHeadersExactly
tc.Expectations.WantExactJSONBody = golden.MatchBodyJSONExactly
}
return nil
}
type jsonTestCase struct {
Headers http.Header `json:"headers,omitempty"`
Body any `json:"body,omitempty"`
Expectations jsonTestCaseExpectations `json:"expectations,omitempty"`
}
type jsonTestCaseExpectations struct {
Status int `json:"status,omitempty"`
Headers http.Header `json:"headers,omitempty"`
Body any `json:"body,omitempty"`
WantExactHeaders bool `json:"want_exact_headers"`
WantExactJSONBody bool `json:"want_exact_json_body"`
}
// MarshalJSON customizes the JSON representaton of the test case.
func (tc HTTPTestCase) MarshalJSON() ([]byte, error) {
o := jsonTestCase{
Headers: tc.request.Header,
Body: tc.request.Body,
Expectations: jsonTestCaseExpectations{
Status: tc.Expectations.Status,
Headers: tc.Expectations.Headers,
Body: tc.Expectations.Body,
WantExactHeaders: tc.Expectations.WantExactHeaders,
WantExactJSONBody: tc.Expectations.WantExactJSONBody,
},
}
return json.Marshal(o)
}