diff --git a/Makefile b/Makefile index b9e9460..bc7fd87 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ COVERAGE_PATH ?= coverage.out COVERAGE_ARGS ?= -covermode=atomic -coverprofile=$(COVERAGE_PATH) TEST_ARGS ?= -race -count=1 -timeout=5s -CI_TEST_ARGS ?= -timeout=60s +CI_TEST_ARGS ?= -timeout=120s AUTOBAHN_ARGS ?= -race -count=1 -timeout=120s BENCH_COUNT ?= 10 BENCH_ARGS ?= -bench=. -benchmem -count=$(BENCH_COUNT) -run=^$$ diff --git a/internal/testing/assert/assert.go b/internal/testing/assert/assert.go index 73b0b08..22a133a 100644 --- a/internal/testing/assert/assert.go +++ b/internal/testing/assert/assert.go @@ -1,165 +1,124 @@ -// Package assert implements common assertions used in go-httbin's unit tests. +// Package assert implements a set of basic test helpers. +// +// Lightly adapted from this blog post: https://antonz.org/do-not-testify/ package assert import ( + "bytes" "errors" "fmt" - "net/http" "reflect" "strings" "testing" - "time" - - "github.com/mccutchen/websocket/internal/testing/must" ) -// Equal asserts that two values are equal. -func Equal[T comparable](t testing.TB, got, want T, msg string, arg ...any) { - t.Helper() - if got != want { - if msg == "" { - msg = "expected values to match" - } - msg = fmt.Sprintf(msg, arg...) - t.Fatalf("%s:\nwant: %v\n got: %v", msg, want, got) +// Equal asserts that got is equal to want. +func Equal[T any](tb testing.TB, got T, want T, customMsg ...any) { + tb.Helper() + if areEqual(got, want) { + return } + msg := formatMsg("expected values to be equal", customMsg) + tb.Errorf("%s:\ngot: %#v\nwant: %#v", msg, got, want) } -// DeepEqual asserts that two values are deeply equal. -func DeepEqual[T any](t testing.TB, got, want T, msg string, arg ...any) { - t.Helper() - if !reflect.DeepEqual(got, want) { - if msg == "" { - msg = "expected values to match" - } - msg = fmt.Sprintf(msg, arg...) - t.Fatalf("%s:\nwant: %#v\n got: %#v", msg, want, got) +// True asserts that got is true. +func True(tb testing.TB, got bool, customMsg ...any) { + tb.Helper() + if !got { + tb.Error(formatMsg("expected value to be true", customMsg)) } } -// NilError asserts that an error is nil. -func NilError(t testing.TB, err error) { - t.Helper() - if err != nil { - t.Fatalf("expected nil error, got %q (%T)", err, err) +// Error asserts that got matches want, which may be an error, an error type, +// an error string, or nil. If want is a string, it is considered a match if +// it is ia substring of got's string value. +func Error(tb testing.TB, got error, want any) { + tb.Helper() + + if want != nil && got == nil { + tb.Errorf("errors do not match:\ngot: \nwant: %v", want) + return } -} -// Error asserts that an error matches an expected error or any one of a list -// of expected errors. -func Error(t testing.TB, got, expected error, alternates ...error) { - t.Helper() - matched := false - wantAny := append([]error{expected}, alternates...) - for _, want := range wantAny { - if errorsMatch(t, got, want) { - matched = true - break + switch w := want.(type) { + case nil: + NilError(tb, got) + case error: + if !errors.Is(got, w) { + tb.Errorf("errors do not match:\ngot: %T(%v)\nwant: %T(%v)", got, got, w, w) } - } - if !matched { - if len(wantAny) == 1 { - t.Fatalf("expected error %q, got %q (%T vs %T)", expected, got, expected, got) - } else { - t.Fatalf("expected one of %v, got %q (%T)", wantAny, got, got) + case string: + if !strings.Contains(got.Error(), w) { + tb.Errorf("error string does not match:\ngot: %q\nwant: %q", got.Error(), w) + } + case reflect.Type: + target := reflect.New(w).Interface() + if !errors.As(got, target) { + tb.Errorf("error type does not match:\ngot: %T\nwant: %s", got, w) } - } -} - -func errorsMatch(t testing.TB, got, expected error) bool { - t.Helper() - switch { - case got == expected: - return true - case errors.Is(got, expected): - return true - case got != nil && expected != nil: - return got.Error() == expected.Error() default: - return false + tb.Errorf("unsupported want type: %T", want) } } -// StatusCode asserts that a response has a specific status code. -func StatusCode(t testing.TB, resp *http.Response, code int) { - t.Helper() - if resp.StatusCode != code { - t.Fatalf("expected status code %d, got %d", code, resp.StatusCode) - } - if resp.StatusCode >= 400 { - // Ensure our error responses are never served as HTML, so that we do - // not need to worry about XSS or other attacks in error responses. - if ct := resp.Header.Get("Content-Type"); !isSafeContentType(ct) { - t.Errorf("HTTP %s error served with dangerous content type: %s", resp.Status, ct) - } +// NilError asserts that got is nil. +func NilError(tb testing.TB, got error) { + tb.Helper() + if got != nil { + tb.Fatalf("expected nil error, got %q (%T)", got, got) } } -func isSafeContentType(ct string) bool { - return strings.HasPrefix(ct, "application/json") || strings.HasPrefix(ct, "text/plain") || strings.HasPrefix(ct, "application/octet-stream") +type equaler[T any] interface { + Equal(T) bool } -// Header asserts that a header key has a specific value in a response. -func Header(t testing.TB, resp *http.Response, key, want string) { - t.Helper() - got := resp.Header.Get(key) - if want != got { - t.Fatalf("expected header %s=%#v, got %#v", key, want, got) +func areEqual[T any](a, b T) bool { + if isNil(a) && isNil(b) { + return true } -} - -// ContentType asserts that a response has a specific Content-Type header -// value. -func ContentType(t testing.TB, resp *http.Response, contentType string) { - t.Helper() - Header(t, resp, "Content-Type", contentType) -} - -// Contains asserts that needle is found in the given string. -func Contains(t testing.TB, s string, needle string, description string) { - t.Helper() - if !strings.Contains(s, needle) { - t.Fatalf("expected string %q in %s %q", needle, description, s) + // special case types with an Equal method + if eq, ok := any(a).(equaler[T]); ok { + return eq.Equal(b) } -} - -// BodyContains asserts that a response body contains a specific substring. -func BodyContains(t testing.TB, resp *http.Response, needle string) { - t.Helper() - body := must.ReadAll(t, resp.Body) - Contains(t, body, needle, "body") -} - -// BodyEquals asserts that a response body is equal to a specific string. -func BodyEquals(t testing.TB, resp *http.Response, want string) { - t.Helper() - got := must.ReadAll(t, resp.Body) - Equal(t, got, want, "incorrect response body") -} - -// BodySize asserts that a response body is a specific size. -func BodySize(t testing.TB, resp *http.Response, want int) { - t.Helper() - got := must.ReadAll(t, resp.Body) - Equal(t, len(got), want, "incorrect response body size") -} - -// DurationRange asserts that a duration is within a specific range. -func DurationRange(t testing.TB, got, minVal, maxVal time.Duration) { - t.Helper() - if got < minVal || got > maxVal { - t.Fatalf("expected duration between %s and %s, got %s", minVal, maxVal, got) + // special case byte slices + if aBytes, ok := any(a).([]byte); ok { + bBytes := any(b).([]byte) + return bytes.Equal(aBytes, bBytes) } + return reflect.DeepEqual(a, b) } -type number interface { - ~int64 | ~float64 +func isNil(v any) bool { + if v == nil { + return true + } + // A non-nil interface can still hold a nil value, so we check the + // underlying value. + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Chan, + reflect.Func, + reflect.Interface, + reflect.Map, + reflect.Pointer, + reflect.Slice, + reflect.UnsafePointer: + return rv.IsNil() + default: + return false + } } -// RoughlyEqual asserts that a numeric value is within a certain tolerance. -func RoughlyEqual[T number](t testing.TB, got, want T, epsilon T) { - t.Helper() - if got < want-epsilon || got > want+epsilon { - t.Fatalf("expected value between %v and %v, got %v", want-epsilon, want+epsilon, got) +func formatMsg(defaultMsg string, customMsg []any) string { + msg := defaultMsg + if len(customMsg) > 0 { + tmpl, ok := customMsg[0].(string) + if !ok { + tmpl = fmt.Sprintf("%v", customMsg[0]) + } + msg = fmt.Sprintf(tmpl, customMsg[1:]...) } + return msg } diff --git a/internal/testing/must/must.go b/internal/testing/must/must.go deleted file mode 100644 index 85b3120..0000000 --- a/internal/testing/must/must.go +++ /dev/null @@ -1,50 +0,0 @@ -// Package must implements helper functions for testing to eliminate some error -// checking boilerplate. -package must - -import ( - "encoding/json" - "io" - "net/http" - "testing" - "time" -) - -// DoReq makes an HTTP request and fails the test if there is an error. -func DoReq(t testing.TB, client *http.Client, req *http.Request) *http.Response { - t.Helper() - start := time.Now() - resp, err := client.Do(req) - if err != nil { - t.Fatalf("error making HTTP request: %s %s: %s", req.Method, req.URL, err) - } - t.Logf("HTTP request: %s %s => %s (%s)", req.Method, req.URL, resp.Status, time.Since(start)) - return resp -} - -// ReadAll reads all bytes from an io.Reader and fails the test if there is an -// error. -func ReadAll(t testing.TB, r io.Reader) string { - t.Helper() - body, err := io.ReadAll(r) - if err != nil { - t.Fatalf("error reading: %s", err) - } - if rc, ok := r.(io.ReadCloser); ok { - if err := rc.Close(); err != nil { - t.Fatalf("error closing after reading: %s", err) - } - } - return string(body) -} - -// Unmarshal unmarshals JSON from an io.Reader into a value and fails the test -// if there is an error. -func Unmarshal[T any](t testing.TB, r io.Reader) T { - t.Helper() - var v T - if err := json.NewDecoder(r).Decode(&v); err != nil { - t.Fatal(err) - } - return v -} diff --git a/proto_test.go b/proto_test.go index d98d184..b302f31 100644 --- a/proto_test.go +++ b/proto_test.go @@ -2,7 +2,6 @@ package websocket_test import ( "bytes" - "errors" "fmt" "testing" @@ -25,7 +24,7 @@ func TestFrameRoundTrip(t *testing.T) { assert.NilError(t, err) // ensure client and server frame match - assert.DeepEqual(t, serverFrame, clientFrame, "server and client frame mismatch") + assert.Equal(t, serverFrame, clientFrame, "server and client frame mismatch") } func TestMaxFrameSize(t *testing.T) { @@ -148,7 +147,7 @@ func TestExampleFramesFromRFC(t *testing.T) { t.Parallel() buf := bytes.NewReader(tc.rawBytes) got := mustReadFrame(t, buf, len(tc.rawBytes)) - assert.DeepEqual(t, got, tc.wantFrame, "frames do not match") + assert.Equal(t, got, tc.wantFrame, "frames do not match") }) } } @@ -156,23 +155,23 @@ func TestExampleFramesFromRFC(t *testing.T) { func TestIncompleteFrames(t *testing.T) { testCases := map[string]struct { rawBytes []byte - wantErr error + wantErr string }{ "2-byte extended payload can't be read": { rawBytes: []byte{0x82, 0x7E}, - wantErr: errors.New("error reading 2-byte extended payload length: EOF"), + wantErr: "error reading 2-byte extended payload length: EOF", }, "8-byte extended payload can't be read": { rawBytes: []byte{0x82, 0x7F}, - wantErr: errors.New("error reading 8-byte extended payload length: EOF"), + wantErr: "error reading 8-byte extended payload length: EOF", }, "mask can't be read": { rawBytes: []byte{0x81, 0x85}, - wantErr: errors.New("error reading mask key: EOF"), + wantErr: "error reading mask key: EOF", }, "payload can't be read": { rawBytes: []byte{0x81, 0x05}, - wantErr: errors.New("error reading 5 byte payload: EOF"), + wantErr: "error reading 5 byte payload: EOF", }, } diff --git a/websocket_internal_test.go b/websocket_internal_test.go index 93f0fe7..8fccaff 100644 --- a/websocket_internal_test.go +++ b/websocket_internal_test.go @@ -44,12 +44,12 @@ func TestDefaults(t *testing.T) { assert.Equal(t, ws.mode, ServerMode, "incorrect mode value") assert.Equal(t, ws.hooks.OnCloseHandshakeStart != nil, true, "OnCloseHandshakeStart hook is nil") assert.Equal(t, ws.hooks.OnCloseHandshakeDone != nil, true, "OnCloseHandshakeDone hook is nil") - assert.Equal(t, ws.hooks.OnReadError != nil, true, "OnReadError hook is nil") - assert.Equal(t, ws.hooks.OnReadFrame != nil, true, "OnReadFrame hook is nil") - assert.Equal(t, ws.hooks.OnReadMessage != nil, true, "OnReadMessage hook is nil") - assert.Equal(t, ws.hooks.OnWriteError != nil, true, "OnWriteError hook is nil") - assert.Equal(t, ws.hooks.OnWriteFrame != nil, true, "OnWriteFrame hook is nil") - assert.Equal(t, ws.hooks.OnWriteMessage != nil, true, "OnWriteMessage hook is nil") + assert.True(t, ws.hooks.OnReadError != nil, "OnReadError hook is nil") + assert.True(t, ws.hooks.OnReadFrame != nil, "OnReadFrame hook is nil") + assert.True(t, ws.hooks.OnReadMessage != nil, "OnReadMessage hook is nil") + assert.True(t, ws.hooks.OnWriteError != nil, "OnWriteError hook is nil") + assert.True(t, ws.hooks.OnWriteFrame != nil, "OnWriteFrame hook is nil") + assert.True(t, ws.hooks.OnWriteMessage != nil, "OnWriteMessage hook is nil") t.Run("CloseTimeout defaults to ReadTimeout if set", func(t *testing.T) { var ( diff --git a/websocket_test.go b/websocket_test.go index 951a7e8..f5869b8 100644 --- a/websocket_test.go +++ b/websocket_test.go @@ -140,7 +140,7 @@ func TestHandshake(t *testing.T) { resp, err := http.DefaultClient.Do(req) assert.NilError(t, err) - assert.StatusCode(t, resp, tc.wantStatus) + assert.Equal(t, resp.StatusCode, tc.wantStatus, "incorrect status code") for k, v := range tc.wantRespHeaders { assert.Equal(t, resp.Header.Get(k), v, "incorrect value for %q response header", k) } @@ -278,13 +278,13 @@ func TestProtocolOkay(t *testing.T) { clientTest: func(t testing.TB, _ *websocket.Websocket, conn net.Conn) { mustWriteFrame(t, conn, true, wantFrame) gotFrame := mustReadFrame(t, conn, len(wantFrame.Payload)) - assert.DeepEqual(t, gotFrame, wantFrame, "frames should match") + assert.Equal(t, gotFrame, wantFrame, "frames should match") }, // server reads a single frame, ensures it matches the frame // written by the client, and then echoes it back. serverTest: func(t testing.TB, _ *websocket.Websocket, conn net.Conn) { serverFrame := mustReadFrame(t, conn, len(wantFrame.Payload)) - assert.DeepEqual(t, serverFrame, wantFrame, "frames should match") + assert.Equal(t, serverFrame, wantFrame, "frames should match") mustWriteFrame(t, conn, false, serverFrame) }, }.Run(t) @@ -315,7 +315,7 @@ func TestProtocolOkay(t *testing.T) { // correctly mustWriteFrames(t, conn, true, websocket.FrameMessage(wantMessage, maxFrameSize)) // read reply to verify round-trip - assert.DeepEqual(t, mustReadMessage(t, ws), wantMessage, "incorect message received in reply from server") + assert.Equal(t, mustReadMessage(t, ws), wantMessage, "incorect message received in reply from server") }, // server reads entire message and ensures that it is reassembled @@ -327,7 +327,7 @@ func TestProtocolOkay(t *testing.T) { }, serverTest: func(t testing.TB, ws *websocket.Websocket, _ net.Conn) { msg := mustReadMessage(t, ws) - assert.DeepEqual(t, msg, wantMessage, "incorrect messaage received from client") + assert.Equal(t, msg, wantMessage, "incorrect messaage received from client") assert.NilError(t, ws.WriteMessage(t.Context(), msg)) }, }.Run(t) @@ -344,7 +344,7 @@ func TestProtocolOkay(t *testing.T) { frame := websocket.NewFrame(websocket.OpcodeText, true, []byte("Iñtërnâtiônàlizætiøn")) mustWriteFrame(t, conn, true, frame) msg := mustReadMessage(t, ws) - assert.DeepEqual(t, msg.Payload, frame.Payload, "incorrect message payloady") + assert.Equal(t, msg.Payload, frame.Payload, "incorrect message payloady") } // valid UTF-8 fragmented on codepoint boundaries is okay @@ -355,7 +355,7 @@ func TestProtocolOkay(t *testing.T) { websocket.NewFrame(websocket.OpcodeContinuation, true, []byte("izætiøn")), }) msg := mustReadMessage(t, ws) - assert.DeepEqual(t, msg.Payload, []byte("Iñtërnâtiônàlizætiøn"), "incorrect message payloady") + assert.Equal(t, msg.Payload, []byte("Iñtërnâtiônàlizætiøn"), "incorrect message payloady") } // valid UTF-8 fragmented in the middle of a codepoint is reassembled @@ -366,7 +366,7 @@ func TestProtocolOkay(t *testing.T) { websocket.NewFrame(websocket.OpcodeContinuation, true, []byte("\xb1o")), }) msg := mustReadMessage(t, ws) - assert.DeepEqual(t, msg.Payload, []byte("jalapeño"), "payload") + assert.Equal(t, msg.Payload, []byte("jalapeño"), "payload") } assert.NilError(t, ws.Close()) @@ -389,11 +389,11 @@ func TestProtocolOkay(t *testing.T) { clientTest: func(t testing.TB, ws *websocket.Websocket, conn net.Conn) { mustWriteFrames(t, conn, true, websocket.FrameMessage(wantMessage, len(wantMessage.Payload))) msg := mustReadMessage(t, ws) - assert.DeepEqual(t, msg, wantMessage, "client received incorrect message in reply") + assert.Equal(t, msg, wantMessage, "client received incorrect message in reply") }, serverTest: func(t testing.TB, ws *websocket.Websocket, _ net.Conn) { msg := mustReadMessage(t, ws) - assert.DeepEqual(t, msg, wantMessage, "server received incorrect message") + assert.Equal(t, msg, wantMessage, "server received incorrect message") assert.NilError(t, ws.WriteMessage(t.Context(), msg)) }, }.Run(t) @@ -417,7 +417,7 @@ func TestProtocolOkay(t *testing.T) { clientFrame := websocket.NewFrame(websocket.OpcodeText, true, bytes.Repeat([]byte("*"), jumboSize)) mustWriteFrame(t, conn, true, clientFrame) respFrame := mustReadFrame(t, conn, jumboSize) - assert.DeepEqual(t, respFrame.Payload, clientFrame.Payload, "payload") + assert.Equal(t, respFrame.Payload, clientFrame.Payload, "payload") }, serverOpts: websocket.Options{ @@ -452,7 +452,7 @@ func TestProtocolOkay(t *testing.T) { // then should get the echo'd message from the two fragments msg := mustReadMessage(t, ws) - assert.DeepEqual(t, msg.Payload, []byte("01"), "incorrect messaage payload") + assert.Equal(t, msg.Payload, []byte("01"), "incorrect messaage payload") assert.NilError(t, ws.Close()) }, @@ -477,7 +477,7 @@ func TestProtocolOkay(t *testing.T) { websocket.NewFrame(websocket.OpcodeText, true, wantPayload), }) respFrame := mustReadFrame(t, conn, len(wantPayload)) - assert.DeepEqual(t, respFrame.Payload, wantPayload, "payload") + assert.Equal(t, respFrame.Payload, wantPayload, "payload") assert.NilError(t, ws.Close()) }, // server just echoes messages from the client @@ -966,7 +966,7 @@ func TestErrorHandling(t *testing.T) { // frame clientTest: func(t testing.TB, _ *websocket.Websocket, conn net.Conn) { frame := mustReadFrame(t, conn, maxFrameSize*2) - assert.DeepEqual(t, frame.Payload, payload[:maxFrameSize], "incorrect payload") + assert.Equal(t, frame.Payload, payload[:maxFrameSize], "incorrect payload") // complete closing handshake mustReadCloseFrame(t, conn, websocket.StatusNormalClosure, nil) mustWriteFrame(t, conn, true, websocket.NewCloseFrame(websocket.StatusNormalClosure, "")) @@ -1061,7 +1061,7 @@ func TestErrorHandling(t *testing.T) { msg, err := ws.ReadMessage(t.Context()) assert.NilError(t, err) - assert.DeepEqual(t, msg.Payload, wantPayload, "incorrect payload") + assert.Equal(t, msg.Payload, wantPayload, "incorrect payload") assert.NilError(t, ws.WriteMessage(t.Context(), msg)) }, }.Run(t) @@ -1081,7 +1081,7 @@ func TestErrorHandling(t *testing.T) { // client reads a message from the server clientTest: func(t testing.TB, _ *websocket.Websocket, conn net.Conn) { frame := mustReadFrame(t, conn, 128) - assert.DeepEqual(t, frame.Payload, wantPayload, "incorrect payload") + assert.Equal(t, frame.Payload, wantPayload, "incorrect payload") }, // server calls ReadMessage twice, first getting an error from the // invalid frame and then getting the expected valid payload, @@ -1131,7 +1131,7 @@ func TestServeLoop(t *testing.T) { // first frame should be echoed as expected frame := mustReadFrame(t, conn, 128) - assert.DeepEqual(t, frame.Payload, []byte("ok"), "incorrect payload") + assert.Equal(t, frame.Payload, []byte("ok"), "incorrect payload") // second frame should cause the handler to return an error, // which should cause the server to close the connection @@ -1275,7 +1275,7 @@ func mustReadCloseFrame(t testing.TB, r io.Reader, wantCode websocket.StatusCode gotReason := string(frame.Payload[2:]) assert.Equal(t, int(gotCode), int(wantCode), "incorrect close status code") if wantErr != nil { - assert.Contains(t, gotReason, wantErr.Error(), "reason") + assert.True(t, strings.Contains(gotReason, wantErr.Error()), "incorrect close reason") } } @@ -1306,7 +1306,13 @@ func assertConnClosed(t testing.TB, conn net.Conn) { } // finally check for concrete errors - assert.Error(t, err, io.EOF, net.ErrClosed) + switch { + case errors.Is(err, io.EOF): + case errors.Is(err, net.ErrClosed): + // okay, we wanted one of these errors + default: + t.Errorf("unexpected error: %s", err) + } } // clientServerTestFunc is a callback invoked by [clientServerTest.Run]to @@ -1411,7 +1417,7 @@ func (cst clientServerTest) Run(t testing.TB) { assert.NilError(t, handshakeReq.Write(clientConn)) resp, err := http.ReadResponse(br, nil) assert.NilError(t, err) - assert.StatusCode(t, resp, http.StatusSwitchingProtocols) + assert.Equal(t, resp.StatusCode, http.StatusSwitchingProtocols, "incorrect status code") clientSock := websocket.New(clientConn, cst.clientKey, websocket.ClientMode, cst.clientOpts) cst.clientTest(t, clientSock, clientConn)