Repository: gavv/httpexpect (go)

Issue:
Support infinite responses in bodyWrapper
bodyWrapper struct is a wrapper which we use for Response.Body. It's goal is to allow reading response body multiple times. At first read, it reads the whole body into memory. Then its Read method just reads body from memory. At any time we can call Rewind to start reading from the beginning (from memory), or call GetBody to get a fresh reader that also reads body from beginning (from memory).

It works well, but it does not support the case when response is an infinite stream, because it will try to read entire body into memory on the first read.

To fix this, bodyWrapper logic should be modified: until first full read, bodyWrapper should be a pass-through reader that in addition stores content in memory. After first full read it becomes a memory reader. And we also need a method to disable memory and make it pass-through reader forever.

In more detail:

* on first read, we don't try to read entire body; instead, we just make the usual read from response and return result to user; however, in addition we store everything we've read into memory

* when we encounter EOF from underlying reader, we should mark body as fully read

* after that, if user calls Rewind or GetBody, we can use body from memory

* if user calls Rewind or GetBody before we fully read body first time, we should automatically complete the full read of body and then handle the call as usual

* we should provide a method to disable storing body into the memory; if that method is called, we clear what we have in memory, we stop adding to memory in future, and we stop handling Rewind and GetBody (they will return errors)

Unit tests should be updated accordingly.

Pull request description:
Support infinite responses in bodyWrapper
Fixes #245.


Patch:
diff --git a/_examples/go.mod b/_examples/go.mod
index 4cdcc77..f419348 100644
--- a/_examples/go.mod
+++ b/_examples/go.mod
@@ -5,7 +5,7 @@ go 1.17
 require (
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/fasthttp/websocket v1.5.0
-	github.com/gavv/httpexpect/v2 v2.14.0
+	github.com/gavv/httpexpect/v2 v2.15.0
 	github.com/gin-gonic/gin v1.8.1
 	github.com/go-oauth2/oauth2/v4 v4.5.1
 	github.com/gorilla/websocket v1.5.0
@@ -27,6 +27,7 @@ require (
 	github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 // indirect
+	github.com/fatih/color v1.15.0 // indirect
 	github.com/fatih/structs v1.1.0 // indirect
 	github.com/gin-contrib/sse v0.1.0 // indirect
 	github.com/go-playground/locales v0.14.0 // indirect
@@ -53,7 +54,7 @@ require (
 	github.com/labstack/gommon v0.4.0 // indirect
 	github.com/leodido/go-urn v1.2.1 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
-	github.com/mattn/go-isatty v0.0.16 // indirect
+	github.com/mattn/go-isatty v0.0.18 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.21 // indirect
 	github.com/mitchellh/go-wordwrap v1.0.1 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
diff --git a/_examples/go.sum b/_examples/go.sum
index 6135544..539eade 100644
--- a/_examples/go.sum
+++ b/_examples/go.sum
@@ -46,6 +46,9 @@ github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod
 github.com/fasthttp/websocket v1.4.3-rc.6/go.mod h1:43W9OM2T8FeXpCWMsBd9Cb7nE2CACNqNvCqQCoty/Lc=
 github.com/fasthttp/websocket v1.5.0 h1:B4zbe3xXyvIdnqjOZrafVFklCUq5ZLo/TqCt5JA1wLE=
 github.com/fasthttp/websocket v1.5.0/go.mod h1:n0BlOQvJdPbTuBkZT0O5+jk/sp/1/VCzquR1BehI2F4=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
+github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
 github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@@ -53,8 +56,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8=
 github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
-github.com/gavv/httpexpect/v2 v2.14.0 h1:rWM60bPJpVcIZWgubYDvipTeHdJlseDM5hovR+wgFVo=
-github.com/gavv/httpexpect/v2 v2.14.0/go.mod h1:lWjY74mDnFXjTqqK41HouBDDCANFo1yrejZ5VraAE1k=
+github.com/gavv/httpexpect/v2 v2.15.0 h1:CCnFk9of4l4ijUhnMxyoEpJsIIBKcuWIFLMwwGTZxNs=
+github.com/gavv/httpexpect/v2 v2.15.0/go.mod h1:7myOP3A3VyS4+qnA4cm8DAad8zMN+7zxDB80W9f8yIc=
 github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
 github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
@@ -177,16 +180,17 @@ github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3
 github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
 github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
 github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
-github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
+github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
 github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8=
 github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
@@ -393,7 +397,6 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
diff --git a/body_wrapper.go b/body_wrapper.go
index d774cfc..5af3c44 100644
--- a/body_wrapper.go
+++ b/body_wrapper.go
@@ -3,6 +3,7 @@ package httpexpect
 import (
 	"bytes"
 	"context"
+	"errors"
 	"io"
 	"io/ioutil"
 	"runtime"
@@ -24,7 +25,11 @@ type bodyWrapper struct {
 
 	cancelFunc context.CancelFunc
 
-	isInitialized bool
+	isFullyRead bool
+	// If isRewindDisabled is true, Read will not store bytes in memory.
+	// origBytes will be empty.
+	isRewindDisabled bool
+	isReadBefore     bool
 
 	mu sync.Mutex
 }
@@ -32,6 +37,7 @@ type bodyWrapper struct {
 func newBodyWrapper(reader io.ReadCloser, cancelFunc context.CancelFunc) *bodyWrapper {
 	bw := &bodyWrapper{
 		origReader: reader,
+		currReader: reader,
 		cancelFunc: cancelFunc,
 	}
 
@@ -46,22 +52,43 @@ func (bw *bodyWrapper) Read(p []byte) (n int, err error) {
 	bw.mu.Lock()
 	defer bw.mu.Unlock()
 
+	bw.isReadBefore = true
+
 	// Preserve original reader error
 	if bw.readErr != nil {
 		return 0, bw.readErr
 	}
 
-	// Lazy initialization
-	if !bw.isInitialized {
-		if initErr := bw.initialize(); initErr != nil {
-			return 0, initErr
-		}
+	if !bw.isFullyRead && !bw.isRewindDisabled {
+		n, err = bw.readNext(p)
+	} else {
+		n, err = bw.currReader.Read(p)
 	}
 
-	if bw.currReader == nil {
-		bw.currReader = bytes.NewReader(bw.origBytes)
+	if err != nil {
+		err = bw.handleReadErr(err)
 	}
-	return bw.currReader.Read(p)
+
+	return
+}
+
+func (bw *bodyWrapper) readNext(p []byte) (n int, err error) {
+	n, err = bw.origReader.Read(p)
+	if (err == nil || err == io.EOF) && n > 0 {
+		bw.origBytes = append(bw.origBytes, p[:n]...)
+	}
+	return
+}
+
+func (bw *bodyWrapper) handleReadErr(err error) error {
+	bw.isFullyRead = true // prevent further reads
+	if err != io.EOF {
+		bw.readErr = err
+	}
+	if closeErr := bw.closeAndCancel(); closeErr != nil && err == io.EOF {
+		err = closeErr
+	}
+	return err
 }
 
 // Close body
@@ -73,10 +100,9 @@ func (bw *bodyWrapper) Close() error {
 
 	// Rewind or GetBody may be called later, so be sure to
 	// read body into memory before closing
-	if !bw.isInitialized {
-		initErr := bw.initialize()
-		if initErr != nil {
-			err = initErr
+	if !bw.isFullyRead && !bw.isRewindDisabled {
+		if readFullErr := bw.readFull(); readFullErr != nil {
+			err = readFullErr
 		}
 	}
 
@@ -94,11 +120,15 @@ func (bw *bodyWrapper) Rewind() {
 	bw.mu.Lock()
 	defer bw.mu.Unlock()
 
-	// Until first read, rewind is no-op
-	if !bw.isInitialized {
+	if bw.isRewindDisabled {
 		return
 	}
 
+	// Until first read, rewind is no-op
+	if bw.isReadBefore && !bw.isFullyRead {
+		_ = bw.readFull()
+	}
+
 	// Reset reader
 	bw.currReader = bytes.NewReader(bw.origBytes)
 }
@@ -110,33 +140,55 @@ func (bw *bodyWrapper) GetBody() (io.ReadCloser, error) {
 	bw.mu.Lock()
 	defer bw.mu.Unlock()
 
+	bw.isReadBefore = true
+
 	// Preserve original reader error
 	if bw.readErr != nil {
 		return nil, bw.readErr
 	}
 
-	// Lazy initialization
-	if !bw.isInitialized {
-		if initErr := bw.initialize(); initErr != nil {
-			return nil, initErr
+	if bw.isRewindDisabled {
+		return nil, errors.New("body caching is disabled, cannot get body contents")
+	}
+
+	if !bw.isFullyRead {
+		if err := bw.readFull(); err != nil {
+			return nil, err
 		}
 	}
 
 	return ioutil.NopCloser(bytes.NewReader(bw.origBytes)), nil
 }
 
-func (bw *bodyWrapper) initialize() error {
-	if !bw.isInitialized {
-		bw.isInitialized = true
+// Disables storing body contents in memory and clears the cache
+func (bw *bodyWrapper) DisableRewinds() error {
+	bw.mu.Lock()
+	defer bw.mu.Unlock()
 
-		if bw.origReader != nil {
-			bw.origBytes, bw.readErr = ioutil.ReadAll(bw.origReader)
+	bw.isRewindDisabled = true
+	bw.origBytes = nil
 
-			_ = bw.closeAndCancel()
-		}
-	}
+	return nil
+}
 
-	return bw.readErr
+// Reads the body fully, then cancels and closes the reader
+func (bw *bodyWrapper) readFull() error {
+	if bw.isFullyRead {
+		return errors.New("body is already fully read")
+	}
+	remainingBytes, err := ioutil.ReadAll(bw.origReader)
+	if err != nil {
+		bw.readErr = err
+		bw.isFullyRead = true // Prevent further reads
+		_ = bw.closeAndCancel()
+		return err
+	}
+	initialBytesLen := len(bw.origBytes)
+	bw.origBytes = append(bw.origBytes, remainingBytes...)
+	bw.isFullyRead = true
+	// Restore the current reader back to the same position as before.
+	bw.currReader = bytes.NewReader(bw.origBytes[initialBytesLen:])
+	return bw.closeAndCancel()
 }
 
 func (bw *bodyWrapper) closeAndCancel() error {
diff --git a/response.go b/response.go
index 59f495d..bfa92b5 100644
--- a/response.go
+++ b/response.go
@@ -604,7 +604,7 @@ func (r *Response) NoContent() *Response {
 	return r
 }
 
-// ContentType succeeds if response contains Content-Type header with given
+// HasContentType succeeds if response contains Content-Type header with given
 // media type and charset.
 //
 // If charset is omitted, and mediaType is non-empty, Content-Type header
@@ -612,8 +612,8 @@ func (r *Response) NoContent() *Response {
 //
 // If charset is omitted, and mediaType is also empty, Content-Type header
 // should contain no charset.
-func (r *Response) ContentType(mediaType string, charset ...string) *Response {
-	opChain := r.chain.enter("ContentType()")
+func (r *Response) HasContentType(mediaType string, charset ...string) *Response {
+	opChain := r.chain.enter("HasContentType()")
 	defer opChain.leave()
 
 	if opChain.failed() {
@@ -635,10 +635,10 @@ func (r *Response) ContentType(mediaType string, charset ...string) *Response {
 	return r
 }
 
-// ContentEncoding succeeds if response has exactly given Content-Encoding list.
+// HasContentEncoding succeeds if response has exactly given Content-Encoding list.
 // Common values are empty, "gzip", "compress", "deflate", "identity" and "br".
-func (r *Response) ContentEncoding(encoding ...string) *Response {
-	opChain := r.chain.enter("ContentEncoding()")
+func (r *Response) HasContentEncoding(encoding ...string) *Response {
+	opChain := r.chain.enter("HasContentEncoding()")
 	defer opChain.leave()
 
 	if opChain.failed() {
@@ -652,10 +652,10 @@ func (r *Response) ContentEncoding(encoding ...string) *Response {
 	return r
 }
 
-// TransferEncoding succeeds if response contains given Transfer-Encoding list.
+// HasTransferEncoding succeeds if response contains given Transfer-Encoding list.
 // Common values are empty, "chunked" and "identity".
-func (r *Response) TransferEncoding(encoding ...string) *Response {
-	opChain := r.chain.enter("TransferEncoding()")
+func (r *Response) HasTransferEncoding(encoding ...string) *Response {
+	opChain := r.chain.enter("HasTransferEncoding()")
 	defer opChain.leave()
 
 	if opChain.failed() {
@@ -669,6 +669,21 @@ func (r *Response) TransferEncoding(encoding ...string) *Response {
 	return r
 }
 
+// Deprecated: use HasContentType instead.
+func (r *Response) ContentType(mediaType string, charset ...string) *Response {
+	return r.HasContentType(mediaType, charset...)
+}
+
+// Deprecated: use HasContentEncoding instead.
+func (r *Response) ContentEncoding(encoding ...string) *Response {
+	return r.HasContentEncoding(encoding...)
+}
+
+// Deprecated: use HasTransferEncoding instead.
+func (r *Response) TransferEncoding(encoding ...string) *Response {
+	return r.HasTransferEncoding(encoding...)
+}
+
 // ContentOpts define parameters for matching the response content parameters.
 type ContentOpts struct {
 	// The media type Content-Type part, e.g. "application/json"


Test patch:
diff --git a/body_wrapper_test.go b/body_wrapper_test.go
index 13d173a..ba64179 100644
--- a/body_wrapper_test.go
+++ b/body_wrapper_test.go
@@ -1,7 +1,9 @@
 package httpexpect
 
 import (
+	"bytes"
 	"errors"
+	"io"
 	"io/ioutil"
 	"testing"
 
@@ -41,16 +43,54 @@ func TestBodyWrapper_Rewind(t *testing.T) {
 	assert.Equal(t, 1, cancelCount)
 }
 
+func TestBodyWrapper_RewindBeforeRead(t *testing.T) {
+	body := newMockBody("test_body")
+
+	cancelCount := 0
+	cancelFn := func() {
+		cancelCount++
+	}
+
+	wrp := newBodyWrapper(body, cancelFn)
+
+	wrp.Rewind()
+
+	b, err := ioutil.ReadAll(wrp)
+	assert.NoError(t, err)
+	assert.Equal(t, "test_body", string(b))
+
+	readCount := body.readCount
+	assert.NotEqual(t, 0, body.readCount)
+	assert.Equal(t, 1, body.closeCount)
+	assert.Equal(t, 1, cancelCount)
+
+	err = wrp.Close()
+	assert.NoError(t, err)
+
+	wrp.Rewind()
+
+	b, err = ioutil.ReadAll(wrp)
+	assert.NoError(t, err)
+	assert.Equal(t, "test_body", string(b))
+
+	assert.Equal(t, readCount, body.readCount)
+	assert.Equal(t, 1, body.closeCount)
+	assert.Equal(t, 1, cancelCount)
+}
+
 func TestBodyWrapper_GetBody(t *testing.T) {
 	body := newMockBody("test_body")
 
 	wrp := newBodyWrapper(body, nil)
 
+	assert.False(t, wrp.isReadBefore)
 	rd1, err := wrp.GetBody()
 	assert.NoError(t, err)
+	assert.True(t, wrp.isReadBefore)
 
 	rd2, err := wrp.GetBody()
 	assert.NoError(t, err)
+	assert.True(t, wrp.isReadBefore)
 
 	b, err := ioutil.ReadAll(rd1)
 	assert.NoError(t, err)
@@ -93,6 +133,14 @@ func TestBodyWrapper_OneError(t *testing.T) {
 		assert.Equal(t, 0, n)
 	}
 
+	checkReadNoErr := func(t *testing.T, wrp *bodyWrapper) {
+		b := make([]byte, 10)
+		n, err := wrp.Read(b)
+
+		assert.Nil(t, err)
+		assert.Equal(t, 9, n)
+	}
+
 	checkCloseErr := func(t *testing.T, wrp *bodyWrapper) {
 		err := wrp.Close()
 
@@ -144,7 +192,7 @@ func TestBodyWrapper_OneError(t *testing.T) {
 
 		wrp := newBodyWrapper(body, nil)
 
-		checkReadErr(t, wrp)
+		checkReadNoErr(t, wrp)
 		checkCloseErr(t, wrp)
 
 		checkReadErr(t, wrp)
@@ -265,3 +313,164 @@ func TestBodyWrapper_ErrorRewind(t *testing.T) {
 		assert.NotNil(t, err)
 	}
 }
+
+func TestBodyWrapper_InfiniteResponses(t *testing.T) {
+
+	t.Run("finite body", func(t *testing.T) {
+		body := newMockBody("test_body") // 9 characters
+		wrp := newBodyWrapper(body, nil)
+		slicedBody := []string{"test", "_bod", "y"}
+
+		b := make([]byte, 4)
+		var (
+			err error
+			n   int
+		)
+
+		for i := 0; i < 2; i++ {
+			n, err = wrp.Read(b)
+			assert.Nil(t, err)
+			assert.Equal(t, 4, n)
+			assert.Equal(t, (1+i)*4, len(wrp.origBytes))
+			assert.False(t, wrp.isFullyRead)
+			assert.Equal(t, slicedBody[i], string(b))
+		}
+		n, err = wrp.Read(b)
+		assert.Nil(t, err)
+		assert.Equal(t, 1, n)
+		assert.False(t, wrp.isFullyRead) // Won't be fully read until the next Read
+		assert.Equal(t, slicedBody[2], string(b[:n]))
+
+		n, err = wrp.Read(b)
+		assert.EqualError(t, err, io.EOF.Error())
+		assert.Equal(t, 0, n)
+		assert.True(t, wrp.isFullyRead)
+	})
+
+	t.Run("finite body with perfect fit", func(t *testing.T) {
+		body := newMockBody("testbody") // 8 characters
+		wrp := newBodyWrapper(body, nil)
+		slicedBody := []string{"test", "body"}
+
+		b := make([]byte, 4)
+		var (
+			err error
+			n   int
+		)
+
+		for i := 0; i < 2; i++ {
+			n, err = wrp.Read(b)
+			assert.Nil(t, err)
+			assert.Equal(t, 4, n)
+			assert.Equal(t, (1+i)*4, len(wrp.origBytes))
+			assert.False(t, wrp.isFullyRead)
+			assert.Equal(t, slicedBody[i], string(b))
+		}
+
+		n, err = wrp.Read(b)
+		assert.EqualError(t, err, io.EOF.Error())
+		assert.Equal(t, 0, n)
+		assert.True(t, wrp.isFullyRead)
+	})
+
+	t.Run("rewind", func(t *testing.T) {
+		body := newMockBody("test_body") // 9 characters
+		wrp := newBodyWrapper(body, nil)
+
+		b := make([]byte, 4)
+		var (
+			err error
+			n   int
+		)
+		_, _ = wrp.Read(b)
+		wrp.Rewind()
+		assert.True(t, wrp.isFullyRead)
+		assert.Equal(t, "test_body", string(wrp.origBytes))
+		n, err = wrp.Read(b)
+		assert.Nil(t, err)
+		assert.Equal(t, 4, n)
+		assert.Equal(t, "test", string(b))
+	})
+
+	t.Run("getbody", func(t *testing.T) {
+		body := newMockBody("test_body") // 9 characters
+		wrp := newBodyWrapper(body, nil)
+
+		b := make([]byte, 4)
+		var (
+			err    error
+			reader io.ReadCloser
+			n      int
+		)
+		_, _ = wrp.Read(b)
+		reader, err = wrp.GetBody()
+		assert.NoError(t, err)
+		assert.True(t, wrp.isFullyRead)
+		assert.Equal(t, "test_body", string(wrp.origBytes))
+		c, _ := ioutil.ReadAll(reader)
+		assert.Equal(t, "test_body", string(c))
+
+		n, err = wrp.Read(b)
+		assert.Nil(t, err)
+		assert.Equal(t, 4, n)
+		assert.Equal(t, "_bod", string(b))
+	})
+
+	t.Run("disable storing body into memory", func(t *testing.T) {
+		body := newMockBody("test_body") // 9 characters
+		wrp := newBodyWrapper(body, nil)
+
+		b := make([]byte, 4)
+		var (
+			err    error
+			reader io.ReadCloser
+			n      int
+		)
+		_, _ = wrp.Read(b)
+		assert.NoError(t, wrp.DisableRewinds())
+		reader, err = wrp.GetBody()
+		assert.EqualError(t, err, "body caching is disabled, cannot get body contents")
+		assert.Nil(t, reader)
+		wrp.Rewind() // Should be no-op
+		assert.False(t, wrp.isFullyRead)
+		assert.True(t, wrp.isReadBefore)
+		assert.Nil(t, wrp.origBytes)
+
+		n, err = wrp.Read(b)
+		assert.Nil(t, err)
+		assert.Equal(t, 4, n)
+		assert.Equal(t, "_bod", string(b))
+
+		n, err = wrp.Read(b)
+		assert.Nil(t, err)
+		assert.Equal(t, 1, n)
+		assert.False(t, wrp.isFullyRead) // Won't be fully read until the next Read
+		assert.Equal(t, "y", string(b[:n]))
+
+		n, err = wrp.Read(b)
+		assert.EqualError(t, err, io.EOF.Error())
+		assert.Equal(t, 0, n)
+		assert.True(t, wrp.isFullyRead)
+		assert.Nil(t, wrp.origBytes)
+	})
+
+}
+
+func TestBodyWrapperPartialRead(t *testing.T) {
+	buffer := bytes.NewBufferString("test")
+	body := &mockBody{
+		reader: buffer,
+	}
+	wrp := newBodyWrapper(body, nil)
+	b := make([]byte, 4)
+	n, err := wrp.Read(b)
+	assert.Nil(t, err)
+	assert.Equal(t, 4, n)
+
+	buffer.Write([]byte("body"))
+	n, err = wrp.Read(b)
+	assert.Nil(t, err)
+	assert.Equal(t, 4, n)
+	assert.Equal(t, "testbody", string(wrp.origBytes))
+	assert.Equal(t, "body", string(b))
+}
diff --git a/datetime_test.go b/datetime_test.go
index a573cfa..5e295f5 100644
--- a/datetime_test.go
+++ b/datetime_test.go
@@ -119,79 +119,125 @@ func TestDateTime_Getters(t *testing.T) {
 }
 
 func TestDateTime_IsEqual(t *testing.T) {
-	reporter := newMockReporter(t)
-
-	value := NewDateTime(reporter, time.Unix(0, 1234))
-
-	assert.True(t, time.Unix(0, 1234).Equal(value.Raw()))
-
-	value.IsEqual(time.Unix(0, 1234))
-	value.chain.assert(t, success)
-	value.chain.clear()
+	cases := []struct {
+		name        string
+		time        time.Time
+		value       time.Time
+		wantIsEqual chainResult
+	}{
+		{
+			name:        "equal to value",
+			time:        time.Unix(0, 1234),
+			value:       time.Unix(0, 1234),
+			wantIsEqual: success,
+		},
+		{
+			name:        "not equal to value",
+			time:        time.Unix(0, 1234),
+			value:       time.Unix(0, 4321),
+			wantIsEqual: failure,
+		},
+	}
 
-	value.IsEqual(time.Unix(0, 4321))
-	value.chain.assert(t, failure)
-	value.chain.clear()
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			reporter := newMockReporter(t)
 
-	value.NotEqual(time.Unix(0, 4321))
-	value.chain.assert(t, success)
-	value.chain.clear()
+			NewDateTime(reporter, tc.time).IsEqual(tc.value).
+				chain.assert(t, tc.wantIsEqual)
 
-	value.NotEqual(time.Unix(0, 1234))
-	value.chain.assert(t, failure)
-	value.chain.clear()
+			NewDateTime(reporter, tc.time).NotEqual(tc.value).
+				chain.assert(t, !tc.wantIsEqual)
+		})
+	}
 }
 
 func TestDateTime_IsGreater(t *testing.T) {
-	reporter := newMockReporter(t)
-
-	value := NewDateTime(reporter, time.Unix(0, 1234))
-
-	value.Gt(time.Unix(0, 1234-1))
-	value.chain.assert(t, success)
-	value.chain.clear()
-
-	value.Gt(time.Unix(0, 1234))
-	value.chain.assert(t, failure)
-	value.chain.clear()
+	cases := []struct {
+		name   string
+		time   time.Time
+		value  time.Time
+		wantGt chainResult
+		wantGe chainResult
+	}{
+		{
+			name:   "greater than value",
+			time:   time.Unix(0, 1234),
+			value:  time.Unix(0, 1234-1),
+			wantGt: success,
+			wantGe: success,
+		},
+		{
+			name:   "equal to value",
+			time:   time.Unix(0, 1234),
+			value:  time.Unix(0, 1234),
+			wantGt: failure,
+			wantGe: success,
+		},
+		{
+			name:   "less than value",
+			time:   time.Unix(0, 1234),
+			value:  time.Unix(0, 1234+1),
+			wantGt: failure,
+			wantGe: failure,
+		},
+	}
 
-	value.Ge(time.Unix(0, 1234-1))
-	value.chain.assert(t, success)
-	value.chain.clear()
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			reporter := newMockReporter(t)
 
-	value.Ge(time.Unix(0, 1234))
-	value.chain.assert(t, success)
-	value.chain.clear()
+			NewDateTime(reporter, tc.time).Gt(tc.value).
+				chain.assert(t, tc.wantGt)
 
-	value.Ge(time.Unix(0, 1234+1))
-	value.chain.assert(t, failure)
-	value.chain.clear()
+			NewDateTime(reporter, tc.time).Ge(tc.value).
+				chain.assert(t, tc.wantGe)
+		})
+	}
 }
 
 func TestDateTime_IsLesser(t *testing.T) {
-	reporter := newMockReporter(t)
-
-	value := NewDateTime(reporter, time.Unix(0, 1234))
-
-	value.Lt(time.Unix(0, 1234+1))
-	value.chain.assert(t, success)
-	value.chain.clear()
-
-	value.Lt(time.Unix(0, 1234))
-	value.chain.assert(t, failure)
-	value.chain.clear()
+	cases := []struct {
+		name   string
+		time   time.Time
+		value  time.Time
+		wantLt chainResult
+		wantLe chainResult
+	}{
+		{
+			name:   "less than value",
+			time:   time.Unix(0, 1234),
+			value:  time.Unix(0, 1234+1),
+			wantLt: success,
+			wantLe: success,
+		},
+		{
+			name:   "equal to value",
+			time:   time.Unix(0, 1234),
+			value:  time.Unix(0, 1234),
+			wantLt: failure,
+			wantLe: success,
+		},
+		{
+			name:   "greater than value",
+			time:   time.Unix(0, 1234),
+			value:  time.Unix(0, 1234-1),
+			wantLt: failure,
+			wantLe: failure,
+		},
+	}
 
-	value.Le(time.Unix(0, 1234+1))
-	value.chain.assert(t, success)
-	value.chain.clear()
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			reporter := newMockReporter(t)
 
-	value.Le(time.Unix(0, 1234))
-	value.chain.assert(t, success)
-	value.chain.clear()
+			NewDateTime(reporter, tc.time).Lt(tc.value).
+				chain.assert(t, tc.wantLt)
 
-	value.Le(time.Unix(0, 1234-1))
-	value.chain.assert(t, failure)
-	value.chain.clear()
+			NewDateTime(reporter, tc.time).Le(tc.value).
+				chain.assert(t, tc.wantLe)
+		})
+	}
 }
 
 func TestDateTime_InRange(t *testing.T) {
diff --git a/e2e/chunked_test.go b/e2e/chunked_test.go
index 3a73ccf..48ebc59 100644
--- a/e2e/chunked_test.go
+++ b/e2e/chunked_test.go
@@ -61,8 +61,8 @@ func testChunkedHandler(e *httpexpect.Expect) {
 		WithChunked(strings.NewReader("key=value")).
 		Expect().
 		Status(http.StatusOK).
-		ContentType("application/json").
-		TransferEncoding("chunked").
+		HasContentType("application/json").
+		HasTransferEncoding("chunked").
 		JSON().Array().ConsistsOf(1, 2)
 }
 
diff --git a/response_test.go b/response_test.go
index ed3e381..681a2ec 100644
--- a/response_test.go
+++ b/response_test.go
@@ -36,9 +36,9 @@ func TestResponse_FailedChain(t *testing.T) {
 		resp.StatusRange(Status2xx)
 		resp.StatusList(http.StatusOK, http.StatusBadGateway)
 		resp.NoContent()
-		resp.ContentType("", "")
-		resp.ContentEncoding("")
-		resp.TransferEncoding("")
+		resp.HasContentType("", "")
+		resp.HasContentEncoding("")
+		resp.HasTransferEncoding("")
 	}
 
 	t.Run("failed chain", func(t *testing.T) {
@@ -611,7 +611,7 @@ func TestResponse_NoContent(t *testing.T) {
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("")
+		resp.HasContentType("")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
@@ -655,7 +655,7 @@ func TestResponse_NoContent(t *testing.T) {
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("")
+		resp.HasContentType("")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
@@ -759,31 +759,31 @@ func TestResponse_ContentType(t *testing.T) {
 			Header: http.Header(headers),
 		})
 
-		resp.ContentType("text/plain")
+		resp.HasContentType("text/plain")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("text/plain", "utf-8")
+		resp.HasContentType("text/plain", "utf-8")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("text/plain", "UTF-8")
+		resp.HasContentType("text/plain", "UTF-8")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("bad")
+		resp.HasContentType("bad")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 
-		resp.ContentType("text/plain", "bad")
+		resp.HasContentType("text/plain", "bad")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 
-		resp.ContentType("")
+		resp.HasContentType("")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 
-		resp.ContentType("text/plain", "")
+		resp.HasContentType("text/plain", "")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 	})
@@ -799,11 +799,11 @@ func TestResponse_ContentType(t *testing.T) {
 			Header: http.Header(headers),
 		})
 
-		resp.ContentType("")
+		resp.HasContentType("")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 
-		resp.ContentType("", "")
+		resp.HasContentType("", "")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 	})
@@ -819,15 +819,15 @@ func TestResponse_ContentType(t *testing.T) {
 			Header: http.Header(headers),
 		})
 
-		resp.ContentType("text/plain")
+		resp.HasContentType("text/plain")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("text/plain", "")
+		resp.HasContentType("text/plain", "")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("text/plain", "utf-8")
+		resp.HasContentType("text/plain", "utf-8")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 	})
@@ -843,11 +843,11 @@ func TestResponse_ContentType(t *testing.T) {
 			Header: http.Header(headers),
 		})
 
-		resp.ContentType("")
+		resp.HasContentType("")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 
-		resp.ContentType("", "")
+		resp.HasContentType("", "")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 	})
@@ -864,19 +864,19 @@ func TestResponse_ContentEncoding(t *testing.T) {
 		Header: http.Header(headers),
 	})
 
-	resp.ContentEncoding("gzip", "deflate")
+	resp.HasContentEncoding("gzip", "deflate")
 	resp.chain.assert(t, success)
 	resp.chain.clear()
 
-	resp.ContentEncoding("deflate", "gzip")
+	resp.HasContentEncoding("deflate", "gzip")
 	resp.chain.assert(t, failure)
 	resp.chain.clear()
 
-	resp.ContentEncoding("gzip")
+	resp.HasContentEncoding("gzip")
 	resp.chain.assert(t, failure)
 	resp.chain.clear()
 
-	resp.ContentEncoding()
+	resp.HasContentEncoding()
 	resp.chain.assert(t, failure)
 	resp.chain.clear()
 }
@@ -888,15 +888,15 @@ func TestResponse_TransferEncoding(t *testing.T) {
 		TransferEncoding: []string{"foo", "bar"},
 	})
 
-	resp.TransferEncoding("foo", "bar")
+	resp.HasTransferEncoding("foo", "bar")
 	resp.chain.assert(t, success)
 	resp.chain.clear()
 
-	resp.TransferEncoding("foo")
+	resp.HasTransferEncoding("foo")
 	resp.chain.assert(t, failure)
 	resp.chain.clear()
 
-	resp.TransferEncoding()
+	resp.HasTransferEncoding()
 	resp.chain.assert(t, failure)
 	resp.chain.clear()
 }
@@ -923,15 +923,15 @@ func TestResponse_Text(t *testing.T) {
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("text/plain")
+		resp.HasContentType("text/plain")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("text/plain", "utf-8")
+		resp.HasContentType("text/plain", "utf-8")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("application/json")
+		resp.HasContentType("application/json")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 
@@ -990,15 +990,15 @@ func TestResponse_Form(t *testing.T) {
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("application/x-www-form-urlencoded")
+		resp.HasContentType("application/x-www-form-urlencoded")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("application/x-www-form-urlencoded", "")
+		resp.HasContentType("application/x-www-form-urlencoded", "")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("text/plain")
+		resp.HasContentType("text/plain")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 
@@ -1111,15 +1111,15 @@ func TestResponse_JSON(t *testing.T) {
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("application/json")
+		resp.HasContentType("application/json")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("application/json", "utf-8")
+		resp.HasContentType("application/json", "utf-8")
 		resp.chain.assert(t, success)
 		resp.chain.clear()
 
-		resp.ContentType("text/plain")
+		resp.HasContentType("text/plain")
 		resp.chain.assert(t, failure)
 		resp.chain.clear()
 
@@ -1257,15 +1257,15 @@ func TestResponse_JSONP(t *testing.T) {
 					resp.chain.assert(t, success)
 					resp.chain.clear()
 
-					resp.ContentType("application/javascript")
+					resp.HasContentType("application/javascript")
 					resp.chain.assert(t, success)
 					resp.chain.clear()
 
-					resp.ContentType("application/javascript", "utf-8")
+					resp.HasContentType("application/javascript", "utf-8")
 					resp.chain.assert(t, success)
 					resp.chain.clear()
 
-					resp.ContentType("text/plain")
+					resp.HasContentType("text/plain")
 					resp.chain.assert(t, failure)
 					resp.chain.clear()
 
@@ -1558,7 +1558,7 @@ func TestResponse_Usage(t *testing.T) {
 		resp := NewResponse(reporter, &http.Response{
 			Header: headers,
 		})
-		resp.ContentType("text/plain", "utf-8", "US-ASCII")
+		resp.HasContentType("text/plain", "utf-8", "US-ASCII")
 		resp.chain.assert(t, failure)
 	})
 
diff --git a/websocket_test.go b/websocket_test.go
index 357afe1..2ebe9b4 100644
--- a/websocket_test.go
+++ b/websocket_test.go
@@ -1047,38 +1047,40 @@ func TestWebsocket_Disconnect(t *testing.T) {
 	}
 }
 
-func TestWebsocket_PrintRead(t *testing.T) {
-	reporter := newMockReporter(t)
-	printer := &mockWebsocketPrinter{}
-	config := Config{
-		Reporter: reporter,
-		Printers: []Printer{printer},
-	}.withDefaults()
-	ws := newWebsocket(newMockChain(t), config, &mockWebsocketConn{})
-
-	ws.printRead(websocket.CloseMessage,
-		[]byte("random message"),
-		websocket.CloseNormalClosure)
-
-	if !printer.isReadFrom {
-		t.Errorf("Websocket.printRead() failed to read from printer")
-	}
-}
-
-func TestWebsocket_PrintWrite(t *testing.T) {
-	reporter := newMockReporter(t)
-	printer := &mockWebsocketPrinter{}
-	config := Config{
-		Reporter: reporter,
-		Printers: []Printer{printer},
-	}.withDefaults()
-	ws := newWebsocket(newMockChain(t), config, &mockWebsocketConn{})
-
-	ws.printWrite(websocket.CloseMessage,
-		[]byte("random message"),
-		websocket.CloseNormalClosure)
+func TestWebsocket_Printer(t *testing.T) {
+	t.Run("print read", func(t *testing.T) {
+		reporter := newMockReporter(t)
+		printer := &mockWebsocketPrinter{}
+		config := Config{
+			Reporter: reporter,
+			Printers: []Printer{printer},
+		}.withDefaults()
+		ws := newWebsocket(newMockChain(t), config, &mockWebsocketConn{})
+
+		ws.printRead(websocket.CloseMessage,
+			[]byte("random message"),
+			websocket.CloseNormalClosure)
+
+		if !printer.isReadFrom {
+			t.Errorf("Websocket.printRead() failed to read from printer")
+		}
+	})
 
-	if !printer.isWrittenTo {
-		t.Errorf("Websocket.printWrite() failed to write to printer")
-	}
+	t.Run("print write", func(t *testing.T) {
+		reporter := newMockReporter(t)
+		printer := &mockWebsocketPrinter{}
+		config := Config{
+			Reporter: reporter,
+			Printers: []Printer{printer},
+		}.withDefaults()
+		ws := newWebsocket(newMockChain(t), config, &mockWebsocketConn{})
+
+		ws.printWrite(websocket.CloseMessage,
+			[]byte("random message"),
+			websocket.CloseNormalClosure)
+
+		if !printer.isWrittenTo {
+			t.Errorf("Websocket.printWrite() failed to write to printer")
+		}
+	})
 }