From fbd41aefac9e116d7f4fa62b3c7cc0675240e623 Mon Sep 17 00:00:00 2001 From: Satoru Kitaguchi Date: Thu, 23 Feb 2023 14:45:48 +0900 Subject: [PATCH] :tada: v1.0.0 --- .github/workflows/actions.yml | 24 ++ README.md | 21 ++ e2e.go | 280 ++++++++++++++++++ example/main.go | 91 ++++++ example/main_test.go | 146 +++++++++ .../TestHealthEndpoint/v1_health_200.golden | 7 + .../TestHealthEndpoint/v2_health_200.golden | 7 + .../v1_user_500_exception.golden | 6 + .../v1_user_201_success.golden | 8 + .../v1_user_204_success.golden | 3 + .../1_UserPost_registration.golden | 8 + .../2_UserGet_after_registration.golden | 7 + .../3_UserPut_update_user_name.golden | 3 + .../4_UserGet_after_user_name_update.golden | 7 + go.mod | 2 + go.sum | 2 + 16 files changed, 622 insertions(+) create mode 100644 .github/workflows/actions.yml create mode 100644 README.md create mode 100644 example/main.go create mode 100644 example/main_test.go create mode 100644 example/testdata/TestHealthEndpoint/v1_health_200.golden create mode 100644 example/testdata/TestHealthEndpoint/v2_health_200.golden create mode 100644 example/testdata/TestUserGetEndpoint/v1_user_500_exception.golden create mode 100644 example/testdata/TestUserPostEndpoint/v1_user_201_success.golden create mode 100644 example/testdata/TestUserPutEndpoint/v1_user_204_success.golden create mode 100644 example/testdata/TestUserScenario/1_UserPost_registration.golden create mode 100644 example/testdata/TestUserScenario/2_UserGet_after_registration.golden create mode 100644 example/testdata/TestUserScenario/3_UserPut_update_user_name.golden create mode 100644 example/testdata/TestUserScenario/4_UserGet_after_user_name_update.golden create mode 100644 go.sum diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml new file mode 100644 index 0000000..973a374 --- /dev/null +++ b/.github/workflows/actions.yml @@ -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/... diff --git a/README.md b/README.md new file mode 100644 index 0000000..26d37e7 --- /dev/null +++ b/README.md @@ -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). diff --git a/e2e.go b/e2e.go index e69de29..572b8c0 100644 --- a/e2e.go +++ b/e2e.go @@ -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 +} diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..bcfde85 --- /dev/null +++ b/example/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + server := &http.Server{ + Addr: ":8080", + Handler: newRouter(), + } + go func() { + if err := server.ListenAndServe(); err != nil { + if errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server closed with error: %v\n", err) + } + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGTERM, os.Interrupt) + <-quit + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Printf("failed to gracefully shutdown: %v\n", err) + } +} + +func newRouter() http.Handler { + mux := http.NewServeMux() + + // GET: StatusOK + mux.HandleFunc("/v1/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"hoge":"fuga"}`)) + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/v2/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ping":"pong"}`)) + w.WriteHeader(http.StatusOK) + }) + + // GET: http.StatusOK + // PUT: http.StatusNoContent + mux.HandleFunc("/v1/user/1", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + switch r.URL.Query().Get("typ") { + case "exception": + http.Error(w, "Server error", http.StatusInternalServerError) + case "new": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"Giorno Giovanna"}`)) + w.WriteHeader(http.StatusOK) + default: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"JoJo"}`)) + w.WriteHeader(http.StatusOK) + } + case http.MethodPut: + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // POST: http.StatusCreated + mux.HandleFunc("/v1/user", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"id":1,"created_time":%d}`, time.Now().Unix()) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + return mux +} diff --git a/example/main_test.go b/example/main_test.go new file mode 100644 index 0000000..1df8025 --- /dev/null +++ b/example/main_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "net/http" + "strconv" + "strings" + "testing" + + "github.com/satorunooshie/e2e" +) + +func TestMain(m *testing.M) { + e2e.RegisterRouter(newRouter()) + + m.Run() +} + +// APITestName returns golden file name. +// ex) v1_health_200_success.golden +func APITestName(endpoint string, code int, description ...string) string { + return strings.Join(append([]string{strings.ReplaceAll(endpoint[1:], "/", "_"), strconv.Itoa(code)}, description...), "_") +} + +// TestHealthEndpoint shows multiple endpoints example. +func TestHealthEndpoint(t *testing.T) { + testHealthEndpoint(t, "/v1/health") + testHealthEndpoint(t, "/v2/health") +} + +func testHealthEndpoint(t *testing.T, endpoint string) { + t.Helper() + + tests := []struct { + want int + }{ + { + want: http.StatusOK, + }, + } + for _, tt := range tests { + t.Run(APITestName(endpoint, tt.want), func(t *testing.T) { + r := e2e.NewRequest(http.MethodGet, endpoint, nil) + e2e.RunTest(t, r, tt.want, e2e.PrettyJSON) + }) + } +} + +func TestUserGetEndpoint(t *testing.T) { + const endpoint = "/v1/user" + + tests := []struct { + description []string + path string + opts []e2e.RequestOption + want int + }{ + { + description: []string{"exception"}, + path: "/1", + opts: []e2e.RequestOption{e2e.WithQuery("typ", "exception")}, + want: http.StatusInternalServerError, + }, + } + for _, tt := range tests { + t.Run(APITestName(endpoint, tt.want, tt.description...), func(t *testing.T) { + endpoint := endpoint + tt.path + r := e2e.NewRequest(http.MethodGet, endpoint, nil, tt.opts...) + e2e.RunTest(t, r, tt.want) + }) + } +} + +// TestUserPostEndpoint shows ModifyJSON example. +func TestUserPostEndpoint(t *testing.T) { + const endpoint = "/v1/user" + + tests := []struct { + description []string + body map[string]any + want int + }{ + { + description: []string{"success"}, + body: map[string]any{"name": "Jonathan Joestar"}, + want: http.StatusCreated, + }, + } + for _, tt := range tests { + t.Run(APITestName(endpoint, tt.want, tt.description...), func(t *testing.T) { + r := e2e.NewRequest(http.MethodPost, endpoint, e2e.JSONBody(t, tt.body)) + e2e.RunTest(t, r, tt.want, e2e.ModifyJSON(map[string]any{"created_time": 1677136520}), e2e.PrettyJSON) + }) + } +} + +// TestUserPutEndpoint shows http.StatusNoContent example. +func TestUserPutEndpoint(t *testing.T) { + const endpoint = "/v1/user" + + tests := []struct { + description []string + path string + body map[string]any + want int + }{ + { + description: []string{"success"}, + path: "/1", + body: map[string]any{"name": "JoJo"}, + want: http.StatusNoContent, + }, + } + for _, tt := range tests { + t.Run(APITestName(endpoint, tt.want, tt.description...), func(t *testing.T) { + endpoint := endpoint + tt.path + r := e2e.NewRequest(http.MethodPut, endpoint, e2e.JSONBody(t, tt.body)) + e2e.RunTest(t, r, tt.want) + }) + } +} + +// TestUserScenario shows a scenario testing example. +func TestUserScenario(t *testing.T) { + resp := struct{ ID int }{} + // TestName: number methodName description + t.Run("1 UserPost registration", func(t *testing.T) { + const endpoint = "/v1/user" + r := e2e.NewRequest(http.MethodPost, endpoint, e2e.JSONBody(t, map[string]any{"name": "JoJo"})) + e2e.RunTest(t, r, http.StatusCreated, e2e.CaptureResponse(&resp), e2e.ModifyJSON(map[string]any{"created_time": 1677136520}), e2e.PrettyJSON) + }) + t.Run("2 UserGet after registration", func(t *testing.T) { + endpoint := "/v1/user/" + strconv.Itoa(resp.ID) + r := e2e.NewRequest(http.MethodGet, endpoint, nil) + e2e.RunTest(t, r, http.StatusOK, e2e.PrettyJSON) + }) + t.Run("3 UserPut update user name", func(t *testing.T) { + endpoint := "/v1/user/" + strconv.Itoa(resp.ID) + r := e2e.NewRequest(http.MethodPut, endpoint, e2e.JSONBody(t, map[string]any{"name": "Giorno Giovanna"})) + e2e.RunTest(t, r, http.StatusNoContent) + }) + t.Run("4 UserGet after user name update", func(t *testing.T) { + endpoint := "/v1/user/" + strconv.Itoa(resp.ID) + r := e2e.NewRequest(http.MethodGet, endpoint, nil, e2e.WithQuery("typ", "new")) + e2e.RunTest(t, r, http.StatusOK, e2e.PrettyJSON) + }) +} diff --git a/example/testdata/TestHealthEndpoint/v1_health_200.golden b/example/testdata/TestHealthEndpoint/v1_health_200.golden new file mode 100644 index 0000000..1f8c080 --- /dev/null +++ b/example/testdata/TestHealthEndpoint/v1_health_200.golden @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json + +{ + "hoge": "fuga" +} \ No newline at end of file diff --git a/example/testdata/TestHealthEndpoint/v2_health_200.golden b/example/testdata/TestHealthEndpoint/v2_health_200.golden new file mode 100644 index 0000000..3c9bbe9 --- /dev/null +++ b/example/testdata/TestHealthEndpoint/v2_health_200.golden @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json + +{ + "ping": "pong" +} \ No newline at end of file diff --git a/example/testdata/TestUserGetEndpoint/v1_user_500_exception.golden b/example/testdata/TestUserGetEndpoint/v1_user_500_exception.golden new file mode 100644 index 0000000..5d6ecc6 --- /dev/null +++ b/example/testdata/TestUserGetEndpoint/v1_user_500_exception.golden @@ -0,0 +1,6 @@ +HTTP/1.1 500 Internal Server Error +Connection: close +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff + +Server error diff --git a/example/testdata/TestUserPostEndpoint/v1_user_201_success.golden b/example/testdata/TestUserPostEndpoint/v1_user_201_success.golden new file mode 100644 index 0000000..05c41ee --- /dev/null +++ b/example/testdata/TestUserPostEndpoint/v1_user_201_success.golden @@ -0,0 +1,8 @@ +HTTP/1.1 201 Created +Connection: close +Content-Type: application/json + +{ + "created_time": 1677136520, + "id": 1 +} \ No newline at end of file diff --git a/example/testdata/TestUserPutEndpoint/v1_user_204_success.golden b/example/testdata/TestUserPutEndpoint/v1_user_204_success.golden new file mode 100644 index 0000000..ce45b87 --- /dev/null +++ b/example/testdata/TestUserPutEndpoint/v1_user_204_success.golden @@ -0,0 +1,3 @@ +HTTP/1.1 204 No Content +Connection: close + diff --git a/example/testdata/TestUserScenario/1_UserPost_registration.golden b/example/testdata/TestUserScenario/1_UserPost_registration.golden new file mode 100644 index 0000000..05c41ee --- /dev/null +++ b/example/testdata/TestUserScenario/1_UserPost_registration.golden @@ -0,0 +1,8 @@ +HTTP/1.1 201 Created +Connection: close +Content-Type: application/json + +{ + "created_time": 1677136520, + "id": 1 +} \ No newline at end of file diff --git a/example/testdata/TestUserScenario/2_UserGet_after_registration.golden b/example/testdata/TestUserScenario/2_UserGet_after_registration.golden new file mode 100644 index 0000000..fd4cca1 --- /dev/null +++ b/example/testdata/TestUserScenario/2_UserGet_after_registration.golden @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json + +{ + "name": "JoJo" +} \ No newline at end of file diff --git a/example/testdata/TestUserScenario/3_UserPut_update_user_name.golden b/example/testdata/TestUserScenario/3_UserPut_update_user_name.golden new file mode 100644 index 0000000..ce45b87 --- /dev/null +++ b/example/testdata/TestUserScenario/3_UserPut_update_user_name.golden @@ -0,0 +1,3 @@ +HTTP/1.1 204 No Content +Connection: close + diff --git a/example/testdata/TestUserScenario/4_UserGet_after_user_name_update.golden b/example/testdata/TestUserScenario/4_UserGet_after_user_name_update.golden new file mode 100644 index 0000000..62f260f --- /dev/null +++ b/example/testdata/TestUserScenario/4_UserGet_after_user_name_update.golden @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json + +{ + "name": "Giorno Giovanna" +} \ No newline at end of file diff --git a/go.mod b/go.mod index ea9587d..d65d70f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/satorunooshie/e2e go 1.20 + +require github.com/google/go-cmp v0.5.9 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..62841cd --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=