Skip to content

Commit

Permalink
🎉 v1.0.0
Browse files Browse the repository at this point in the history
  • Loading branch information
satorunooshie committed Feb 24, 2023
1 parent ee837c3 commit fbd41ae
Show file tree
Hide file tree
Showing 16 changed files with 622 additions and 0 deletions.
24 changes: 24 additions & 0 deletions .github/workflows/actions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: actions
on: [push, pull_request]
jobs:
lint:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
check-latest: true
go-version-file: go.mod
- uses: golangci/golangci-lint-action@v3
test:
name: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
check-latest: true
go-version-file: go.mod
- name: Test
run: go test -v ./example/...
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# github.com/satorunooshie/e2e
[![Go Reference](https://pkg.go.dev/badge/github.com/satorunooshie/e2e.svg)](https://pkg.go.dev/github.com/satorunooshie/e2e)

Library for e2e and scenario testing.

## Usage

Once a golden file generated by go test with golden flag, e2e compares HTTP status code and the response with the golden file.

Need at least only two lines, new request and run test, as below.

e2e testing only needs a minimum of two lines of code; one that creates an HTTP request and the other that executes the test.

```go
t.Run(APITestName, func(t *testing.T) {
r := e2e.NewRequest(http.MethodGet, endpoint, nil)
e2e.RunTest(t, r, http.StatusOK, e2e.PrettyJSON)
})
```

For more detail, see [examples](https://github.com/satorunooshie/e2e/blob/main/example/main_test.go).
280 changes: 280 additions & 0 deletions e2e.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
package e2e

import (
"bytes"
"encoding/json"
"flag"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"os"
"path/filepath"
"strconv"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
)

var (
router http.Handler
dumpRawResponse = flag.Bool("dump", false, "dump raw response")
updateGolden = flag.Bool("golden", false, "update golden files")
)

// RegisterRouter registers router for RunTest.
func RegisterRouter(rt http.Handler) {
router = rt
}

// ResponseFilter is a function to modify HTTP response.
type ResponseFilter func(t *testing.T, r *http.Response)

// RunTest sends an HTTP request to router, then checks the status code and
// compare the response with the golden file. When `updateGolden` is true,
// update the golden file instead of comparison.
func RunTest(t *testing.T, r *http.Request, want int, filters ...ResponseFilter) {
t.Helper()

t.Logf(">>> %s %s\n", r.Method, r.URL)

w := httptest.NewRecorder()
router.ServeHTTP(w, r)

got := w.Result()
if got.StatusCode != want {
t.Errorf("HTTP StatusCode: %d, want: %d\n", got.StatusCode, want)
}

if *dumpRawResponse {
var rc io.ReadCloser
rc, got.Body = drainBody(t, got.Body)

body, err := io.ReadAll(rc)
if err != nil {
t.Fatal(err)
}
if strings.HasPrefix(got.Header.Get("Content-Type"), "application/json") {
switch got.StatusCode {
case http.StatusOK, http.StatusCreated:
body = indentJSON(t, body)
}
}

dump, err := httputil.DumpResponse(got, false)
if err != nil {
t.Fatal(err)
}

t.Logf("Raw response:\n%s%s\n", dump, body)
}

for _, f := range filters {
f(t, got)
}

dump, err := httputil.DumpResponse(got, true)
if err != nil {
t.Fatal(err)
}

if *updateGolden {
writeGolden(t, dump)
} else {
golden := readGolden(t)
if diff := cmp.Diff(golden, dump); diff != "" {
t.Errorf("HTTP Response mismatch (-want +got):\n%s", diff)
}
}

t.Logf("<<< %s\n", goldenFileName(t.Name()))
}

// This is a modified version of httputil.drainBody for this test.
func drainBody(t *testing.T, b io.ReadCloser) (dump, orig io.ReadCloser) {
t.Helper()

if b == nil || b == http.NoBody {
return http.NoBody, http.NoBody
}

var buf bytes.Buffer
if _, err := buf.ReadFrom(b); err != nil {
t.Fatal(err)
}
_ = b.Close()
return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes()))
}

func indentJSON(t *testing.T, body []byte) []byte {
t.Helper()

var tmp map[string]any
if err := json.Unmarshal(body, &tmp); err != nil {
t.Fatal(err)
}
body, err := json.MarshalIndent(&tmp, "", " ")
if err != nil {
t.Fatal(err)
}
return body
}

func goldenFileName(name string) string {
return filepath.Join("testdata", name+".golden")
}

func writeGolden(t *testing.T, data []byte) {
t.Helper()

filename := goldenFileName(t.Name())
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filename, data, 0o600); err != nil {
t.Fatal(err)
}
}

func readGolden(t *testing.T) []byte {
t.Helper()

data, err := os.ReadFile(goldenFileName(t.Name()))
if err != nil {
t.Fatal(err)
}
return data
}

func rewriteMap(t *testing.T, base, overwrite map[string]any, parents ...string) {
t.Helper()

for k, v := range overwrite {
if old, ok := base[k]; ok {
switch v := v.(type) {
case map[string]any:
sub, ok := old.(map[string]any)
if !ok {
t.Fatalf("could not rewrite map: key = %q", strings.Join(append(parents, k), "."))
}
rewriteMap(t, sub, v, append(parents, k)...)
case []map[string]any:
sub, ok := old.([]any) // body is []any.
if !ok {
t.Fatalf("could not rewrite array map: key = %q", strings.Join(append(parents, k), "."))
}
if len(sub) != len(v) {
t.Fatalf("could not rewrite array map: len(sub)=%d != len(v)=%d: key = %q",
len(sub), len(v), strings.Join(append(parents, k), "."))
}
for i, vv := range v {
kk := k + "#" + strconv.Itoa(i)
sub2, ok := sub[i].(map[string]any)
if !ok {
t.Fatalf("could not rewrite array map: key = %q", strings.Join(append(parents, kk), "."))
}
rewriteMap(t, sub2, vv, append(parents, kk)...)
}
default:
base[k] = v
}
}
}
}

// ModifyJSON overwrites the specified key in the JSON field of the response
// body if it exists. When the map value of overwrite is map[string]any,
// change only the specified fields.
func ModifyJSON(overwrite map[string]any) ResponseFilter {
return func(t *testing.T, r *http.Response) {
t.Helper()

var tmp map[string]any
if err := json.NewDecoder(r.Body).Decode(&tmp); err != nil {
t.Fatal(err)
}

rewriteMap(t, tmp, overwrite)

body := new(bytes.Buffer)
if err := json.NewEncoder(body).Encode(&tmp); err != nil {
t.Fatal(err)
}
r.Body = io.NopCloser(body)
}
}

// PrettyJSON is a ResponseFilter for formatting JSON responses. It adds
// indentation if the status code is not 204.
func PrettyJSON(t *testing.T, r *http.Response) {
t.Helper()

if r.StatusCode == http.StatusNoContent {
return
}
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
t.Fatal("Response is not JSON")
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
r.Body = io.NopCloser(bytes.NewReader(indentJSON(t, body)))
}

// CaptureResponse unmarshals JSON response.
func CaptureResponse(ptr any) ResponseFilter {
return func(t *testing.T, r *http.Response) {
t.Helper()

body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
r.Body = io.NopCloser(bytes.NewReader(body))
if err := json.Unmarshal(body, &ptr); err != nil {
t.Fatal(err)
}
}
}

type RequestOption func(*http.Request)

// WithQuery sets query parameter.
func WithQuery(key string, values ...string) RequestOption {
return func(r *http.Request) {
q := r.URL.Query()
for _, value := range values {
q.Add(key, value)
}
r.URL.RawQuery = q.Encode()
}
}

// WithHeader sets HTTP header.
func WithHeader(key, value string) RequestOption {
return func(r *http.Request) {
r.Header.Set(key, value)
}
}

// NewRequest creates a new HTTP request and applies options.
func NewRequest(method, endpoint string, body io.Reader, options ...RequestOption) *http.Request {
r := httptest.NewRequest(method, endpoint, body)
for _, opt := range options {
opt(r)
}
return r
}

// JSONBody encodes m and returns it as an io.Reader.
func JSONBody(t *testing.T, m map[string]any) io.Reader {
t.Helper()

body := new(bytes.Buffer)
if err := json.NewEncoder(body).Encode(&m); err != nil {
t.Fatal(err)
}
return body
}
Loading

0 comments on commit fbd41ae

Please sign in to comment.