Repository: google/go-safeweb
Pull request #283 (2021-04-20 19:12:52)

Add representation of a bancheck config.
First step in implementing a full linter (#270). Adds representation of configuration file contents with banned APIs.


Patch:
diff --git a/cmd/bancheck/config/config.go b/cmd/bancheck/config/config.go
new file mode 100644
index 0000000..10a3a95
--- /dev/null
+++ b/cmd/bancheck/config/config.go
@@ -0,0 +1,89 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 	https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package config
+
+import (
+	"encoding/json"
+	"errors"
+	"os"
+)
+
+// Config represents a configuration passed to the linter.
+type Config struct {
+	Imports   []BannedAPI `json:"imports"`
+	Functions []BannedAPI `json:"functions"`
+}
+
+// BannedAPI represents an identifier (e.g. import, function call) that should not be used.
+type BannedAPI struct {
+	Name       string      `json:"name"` // fully qualified identifier name
+	Msg        string      `json:"msg"`  // additional information e.g. rationale for banning
+	Exemptions []Exemption `json:"exemptions"`
+}
+
+// Exemption represents a location that should be exempted from checking for banned APIs.
+type Exemption struct {
+	Justification string `json:"justification"`
+	AllowedDir    string `json:"allowedDir"`
+}
+
+// ReadConfigs reads banned APIs from all files.
+func ReadConfigs(files []string) (*Config, error) {
+	var imports []BannedAPI
+	var fns []BannedAPI
+
+	for _, file := range files {
+		config, err := readCfg(file)
+		if err != nil {
+			return nil, err
+		}
+
+		imports = append(imports, config.Imports...)
+		fns = append(fns, config.Functions...)
+	}
+
+	return &Config{Imports: imports, Functions: fns}, nil
+}
+
+func readCfg(filename string) (*Config, error) {
+	f, err := openFile(filename)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	return decodeCfg(f)
+}
+
+func openFile(filename string) (*os.File, error) {
+	info, err := os.Stat(filename)
+	if os.IsNotExist(err) {
+		return nil, errors.New("file does not exist")
+	}
+	if info.IsDir() {
+		return nil, errors.New("file is a directory")
+	}
+
+	return os.Open(filename)
+}
+
+func decodeCfg(f *os.File) (*Config, error) {
+	var cfg Config
+	err := json.NewDecoder(f).Decode(&cfg)
+	if err != nil {
+		return nil, err
+	}
+
+	return &cfg, nil
+}
diff --git a/go.mod b/go.mod
index d40bacd..e72a147 100644
--- a/go.mod
+++ b/go.mod
@@ -6,4 +6,5 @@ require (
 	github.com/google/go-cmp v0.5.0
 	github.com/google/safehtml v0.0.2
 	golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1
+	golang.org/x/tools v0.1.0
 )
diff --git a/go.sum b/go.sum
index 1101f97..b7882fa 100644
--- a/go.sum
+++ b/go.sum
@@ -2,15 +2,39 @@ github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/safehtml v0.0.2 h1:ZOt2VXg4x24bW0m2jtzAOkhoXV0iM8vNKc0paByCZqM=
 github.com/google/safehtml v0.0.2/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc=
 golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44 h1:Bli41pIlzTzf3KEY06n+xnzK/BESIg2ze4Pgfh/aI8c=
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
+golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/safehttp/flight.go b/safehttp/flight.go
index ab6658b..081a1d7 100644
--- a/safehttp/flight.go
+++ b/safehttp/flight.go
@@ -32,28 +32,6 @@ type flight struct {
 	written bool
 }
 
-// DeprecatedNewResponseWriter creates a ResponseWriter implementation that has
-// been historically used for testing. DO NOT USE this function, it's going
-// to be removed soon.
-//
-// The main problem with this is the implementation of the returned
-// ResponseWriter is incomplete, i.e. lacks the Handler and Interceptors fields
-// of the handlerConfig needed by the flight. For the existing tests that use it,
-// it's fine, but they should be migrated.
-//
-// TODO(kele): remove this once we have a better option to provide a
-// ResponseWriter implementation for interceptor testing.
-func DeprecatedNewResponseWriter(rw http.ResponseWriter, dispatcher Dispatcher) ResponseWriter {
-	if dispatcher == nil {
-		dispatcher = DefaultDispatcher{}
-	}
-	return &flight{
-		cfg:    handlerConfig{Dispatcher: dispatcher},
-		rw:     rw,
-		header: newHeader(rw.Header()),
-	}
-}
-
 // handlerConfig is the safe HTTP handler configuration, including the
 // dispatcher and interceptors.
 type handlerConfig struct {
@@ -66,7 +44,7 @@ func processRequest(cfg handlerConfig, rw http.ResponseWriter, req *http.Request
 	f := &flight{
 		cfg:    cfg,
 		rw:     rw,
-		header: newHeader(rw.Header()),
+		header: NewHeader(rw.Header()),
 		req:    NewIncomingRequest(req),
 	}
 
diff --git a/safehttp/header.go b/safehttp/header.go
index d0d14ae..83204a9 100644
--- a/safehttp/header.go
+++ b/safehttp/header.go
@@ -28,7 +28,11 @@ type Header struct {
 	claimed map[string]bool
 }
 
-func newHeader(h http.Header) Header {
+// NewHeader creates a new Header.
+func NewHeader(h http.Header) Header {
+	if h == nil {
+		h = http.Header{}
+	}
 	return Header{
 		wrapped: h,
 		claimed: map[string]bool{},
diff --git a/safehttp/incoming_request.go b/safehttp/incoming_request.go
index 9da62bb..af0ab11 100644
--- a/safehttp/incoming_request.go
+++ b/safehttp/incoming_request.go
@@ -49,7 +49,7 @@ func NewIncomingRequest(req *http.Request) *IncomingRequest {
 	}
 	return &IncomingRequest{
 		req:    req,
-		Header: newHeader(req.Header),
+		Header: NewHeader(req.Header),
 		TLS:    req.TLS,
 		URL:    &URL{url: req.URL},
 	}


Test patch:
diff --git a/cmd/bancheck/config/config_test.go b/cmd/bancheck/config/config_test.go
new file mode 100644
index 0000000..5141ba6
--- /dev/null
+++ b/cmd/bancheck/config/config_test.go
@@ -0,0 +1,333 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 	https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package config
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"golang.org/x/tools/go/analysis/analysistest"
+)
+
+func TestReadConfigs(t *testing.T) {
+	tests := []struct {
+		desc  string
+		files map[string]string
+		want  *Config
+	}{
+		{
+			desc: "file with empty definitions",
+			files: map[string]string{
+				"file.json": `
+				{}
+				`,
+			},
+			want: &Config{},
+		},
+		{
+			desc: "file with unknown field",
+			files: map[string]string{
+				"file.json": `
+				{
+					"unknown": 1
+				}
+				`,
+			},
+			want: &Config{},
+		},
+		{
+			desc: "file with banned import",
+			files: map[string]string{
+				"file.json": `
+				{
+					"imports": [{
+						"name": "legacyconversions",
+						"msg": "Sample message",
+						"exemptions": [{
+							"justification": "My justification",
+							"allowedDir": "subdirs/vetted/..."
+						}]
+					}]
+				}
+				`,
+			},
+			want: &Config{Imports: []BannedAPI{
+				{
+					Name: "legacyconversions",
+					Msg:  "Sample message",
+					Exemptions: []Exemption{
+						{
+							Justification: "My justification",
+							AllowedDir:    "subdirs/vetted/...",
+						},
+					},
+				}},
+			},
+		},
+		{
+			desc: "multiple files with imports",
+			files: map[string]string{
+				"file1.json": `
+				{
+					"imports": [{
+						"name": "import1",
+						"msg": "msg1" 
+					}]
+				}
+				`,
+				"file2.json": `
+				{
+					"imports": [{
+						"name": "import2",
+						"msg": "msg2" 
+					}]
+				}
+				`,
+			},
+			want: &Config{Imports: []BannedAPI{
+				{Name: "import1", Msg: "msg1"}, {Name: "import2", Msg: "msg2"},
+			}},
+		},
+		{
+			desc: "file with banned function",
+			files: map[string]string{
+				"file.json": `
+				{
+					"functions": [{
+						"name": "safehttp.NewServeMuxConfig",
+						"msg": "Sample message",
+						"exemptions": [{
+							"justification": "My justification",
+							"allowedDir": "subdirs/vetted/..."
+						}]
+					}]
+				}
+				`,
+			},
+			want: &Config{Functions: []BannedAPI{{
+				Name: "safehttp.NewServeMuxConfig",
+				Msg:  "Sample message",
+				Exemptions: []Exemption{
+					{
+						Justification: "My justification",
+						AllowedDir:    "subdirs/vetted/...",
+					},
+				},
+			}}},
+		},
+		{
+			desc: "file with banned imports and functions",
+			files: map[string]string{
+				"file.json": `
+				{
+					"imports": [{
+						"name": "legacyconversions",
+						"msg": "Sample message",
+						"exemptions": [{
+							"justification": "My justification",
+							"allowedDir": "subdirs/vetted/..."
+						}]
+					}],
+					"functions": [{
+						"name": "safehttp.NewServeMuxConfig",
+						"msg": "Sample message",
+						"exemptions": [{
+							"justification": "My justification",
+							"allowedDir": "subdirs/vetted/..."
+						}]
+					}]
+				}
+				`,
+			},
+			want: &Config{
+				Imports: []BannedAPI{
+					{
+						Name: "legacyconversions",
+						Msg:  "Sample message",
+						Exemptions: []Exemption{
+							{
+								Justification: "My justification",
+								AllowedDir:    "subdirs/vetted/...",
+							},
+						},
+					}},
+				Functions: []BannedAPI{{
+					Name: "safehttp.NewServeMuxConfig",
+					Msg:  "Sample message",
+					Exemptions: []Exemption{
+						{
+							Justification: "My justification",
+							AllowedDir:    "subdirs/vetted/...",
+						},
+					},
+				}},
+			},
+		},
+		{
+			desc: "multiple files with functions",
+			files: map[string]string{
+				"file1.json": `
+				{
+					"functions": [{
+						"name": "function1",
+						"msg": "msg1" 
+					}]
+				}
+				`,
+				"file2.json": `
+				{
+					"functions": [{
+						"name": "function2",
+						"msg": "msg2" 
+					}]
+				}
+				`,
+			},
+			want: &Config{Functions: []BannedAPI{
+				{Name: "function1", Msg: "msg1"}, {Name: "function2", Msg: "msg2"},
+			}},
+		},
+		{
+			desc: "duplicate definitions",
+			files: map[string]string{
+				"file1.json": `
+				{
+					"functions": [{
+						"name": "function",
+						"msg": "Banned by team x",
+						"exemptions": [{
+							"justification": "My justification",
+							"allowedDir": "subdirs/vetted/..."
+						}]
+					}]
+				}
+				`,
+				"file2.json": `
+				{
+					"functions": [{
+						"name": "function",
+						"msg": "Banned by team y",
+						"exemptions": [{
+							"justification": "#yolo",
+							"allowedDir": "otherdir/legacy/..."
+						}]
+					}]
+				}
+				`,
+			},
+			want: &Config{
+				Functions: []BannedAPI{
+					{
+						Name: "function",
+						Msg:  "Banned by team x",
+						Exemptions: []Exemption{
+							{
+								Justification: "My justification",
+								AllowedDir:    "subdirs/vetted/...",
+							},
+						},
+					},
+					{
+						Name: "function",
+						Msg:  "Banned by team y",
+						Exemptions: []Exemption{
+							{
+								Justification: "#yolo",
+								AllowedDir:    "otherdir/legacy/...",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			dir, cleanup, err := analysistest.WriteFiles(test.files)
+			if err != nil {
+				t.Fatalf("WriteFiles() returned err: %v", err)
+			}
+			defer cleanup()
+			var files []string
+			for f := range test.files {
+				path := filepath.Join(dir, "src", f)
+				files = append(files, path)
+			}
+
+			cfg, err := ReadConfigs(files)
+
+			if err != nil {
+				t.Errorf("ReadConfigs() got err: %v want: nil", err)
+			}
+			if diff := cmp.Diff(cfg, test.want, cmpopts.SortSlices(BannedAPICmp)); diff != "" {
+				t.Errorf("config mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+var BannedAPICmp = func(b1, b2 BannedAPI) bool { return b1.Msg < b2.Msg }
+
+func TestConfigErrors(t *testing.T) {
+	tests := []struct {
+		desc     string
+		files    map[string]string
+		fileName string
+	}{
+		{
+			desc:     "file does not exist",
+			files:    map[string]string{},
+			fileName: "nonexistent",
+		},
+		{
+			desc: "file is a directory",
+			files: map[string]string{
+				"dir/file.json": ``,
+			},
+			fileName: "dir",
+		},
+		{
+			desc: "file has invalid contents",
+			files: map[string]string{
+				"file.json": `
+				{"imports":"this should be an object"}
+				`,
+			},
+			fileName: "file.json",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			dir, cleanup, err := analysistest.WriteFiles(test.files)
+			if err != nil {
+				t.Fatalf("WriteFiles() got err: %v", err)
+			}
+			defer cleanup()
+
+			file := filepath.Join(dir, "src", test.fileName)
+			cfg, err := ReadConfigs([]string{file})
+
+			if cfg != nil {
+				t.Errorf("ReadConfigs() got %v, wanted nil", cfg)
+			}
+			if err == nil {
+				t.Errorf("ReadConfigs() got %v, wanted error", cfg)
+			}
+		})
+	}
+}
diff --git a/safehttp/header_test.go b/safehttp/header_test.go
index b19034d..6734386 100644
--- a/safehttp/header_test.go
+++ b/safehttp/header_test.go
@@ -22,7 +22,7 @@ import (
 )
 
 func TestSet(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Set("Foo-Key", "Bar-Value")
 	if got, want := h.Get("Foo-Key"), "Bar-Value"; got != want {
 		t.Errorf(`h.Get("Foo-Key") got: %q want %q`, got, want)
@@ -35,7 +35,7 @@ func TestSet(t *testing.T) {
 // Note that the casing of the header name is different
 // when accessing and modifying the same header.
 func TestSetCanonicalization(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Set("fOo-KeY", "Bar-Value")
 	if got, want := h.Get("FoO-kEy"), "Bar-Value"; got != want {
 		t.Errorf(`h.Get("FoO-kEy") got: %q want %q`, got, want)
@@ -43,7 +43,7 @@ func TestSetCanonicalization(t *testing.T) {
 }
 
 func TestSetEmptySetCookie(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	defer func() {
 		if r := recover(); r != nil {
 			return
@@ -57,7 +57,7 @@ func TestSetEmptySetCookie(t *testing.T) {
 }
 
 func TestSetClaimed(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Set("Foo-Key", "Pizza-Value")
 	h.Claim("Foo-Key")
 	defer func() {
@@ -73,7 +73,7 @@ func TestSetClaimed(t *testing.T) {
 }
 
 func TestSetEmptyClaimed(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Claim("Foo-Key")
 	defer func() {
 		if r := recover(); r != nil {
@@ -88,7 +88,7 @@ func TestSetEmptyClaimed(t *testing.T) {
 }
 
 func TestAdd(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Add("Foo-Key", "Bar-Value")
 	h.Add("Foo-Key", "Pizza-Value")
 	if diff := cmp.Diff([]string{"Bar-Value", "Pizza-Value"}, h.Values("Foo-Key")); diff != "" {
@@ -102,7 +102,7 @@ func TestAdd(t *testing.T) {
 // Note that the casing of the header name is different
 // when accessing and modifying the same header.
 func TestAddCanonicalization(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Add("fOo-KeY", "Bar-Value")
 	h.Add("FoO-kEy", "Pizza-Value")
 	if diff := cmp.Diff([]string{"Bar-Value", "Pizza-Value"}, h.Values("fOO-KEY")); diff != "" {
@@ -111,7 +111,7 @@ func TestAddCanonicalization(t *testing.T) {
 }
 
 func TestAddEmptySetCookie(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	defer func() {
 		if r := recover(); r != nil {
 			return
@@ -125,7 +125,7 @@ func TestAddEmptySetCookie(t *testing.T) {
 }
 
 func TestAddClaimed(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Add("Foo-Key", "Bar-Value")
 	h.Claim("Foo-Key")
 	defer func() {
@@ -141,7 +141,7 @@ func TestAddClaimed(t *testing.T) {
 }
 
 func TestAddEmptyClaimed(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Claim("Foo-Key")
 	defer func() {
 		if r := recover(); r != nil {
@@ -156,7 +156,7 @@ func TestAddEmptyClaimed(t *testing.T) {
 }
 
 func TestDel(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Set("Foo-Key", "Bar-Value")
 	h.Del("Foo-Key")
 	if diff := cmp.Diff([]string{}, h.Values("Foo-Key")); diff != "" {
@@ -170,7 +170,7 @@ func TestDel(t *testing.T) {
 // Note that the casing of the header name is different
 // when accessing and modifying the same header.
 func TestDelCanonicalization(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Set("fOo-KeY", "Bar-Value")
 	h.Del("FoO-kEy")
 	if diff := cmp.Diff([]string{}, h.Values("FOO-kEY")); diff != "" {
@@ -179,7 +179,7 @@ func TestDelCanonicalization(t *testing.T) {
 }
 
 func TestDelEmptySetCookie(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	defer func() {
 		if r := recover(); r != nil {
 			return
@@ -193,7 +193,7 @@ func TestDelEmptySetCookie(t *testing.T) {
 }
 
 func TestDelClaimed(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Set("Foo-Key", "Bar-Value")
 	h.Claim("Foo-Key")
 	defer func() {
@@ -209,7 +209,7 @@ func TestDelClaimed(t *testing.T) {
 }
 
 func TestDelEmptyClaimed(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Claim("Foo-Key")
 	defer func() {
 		if r := recover(); r != nil {
@@ -228,7 +228,7 @@ func TestDelEmptyClaimed(t *testing.T) {
 // slice. The test ensures that Values() returns a copy
 // of the underlying slice.
 func TestValuesModifyClaimed(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Set("Foo-Key", "Bar-Value")
 	h.Claim("Foo-Key")
 	v := h.Values("Foo-Key")
@@ -261,7 +261,7 @@ func TestValuesOrdering(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			h := newHeader(http.Header{})
+			h := NewHeader(http.Header{})
 			h.Add("Foo-Key", tt.values[0])
 			h.Add("Foo-Key", tt.values[1])
 			if diff := cmp.Diff(tt.values, h.Values("Foo-Key")); diff != "" {
@@ -272,7 +272,7 @@ func TestValuesOrdering(t *testing.T) {
 }
 
 func TestManyEqualKeyValuePairs(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Add("Foo-Key", "Bar-Value")
 	h.Add("Foo-Key", "Bar-Value")
 	if diff := cmp.Diff([]string{"Bar-Value", "Bar-Value"}, h.Values("Foo-Key")); diff != "" {
@@ -284,7 +284,7 @@ func TestManyEqualKeyValuePairs(t *testing.T) {
 // correctly remove the previously added header before setting
 // the new one.
 func TestAddSet(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Add("Foo-Key", "Bar-Value")
 	h.Set("Foo-Key", "Pizza-Value")
 	if diff := cmp.Diff([]string{"Pizza-Value"}, h.Values("Foo-Key")); diff != "" {
@@ -293,14 +293,14 @@ func TestAddSet(t *testing.T) {
 }
 
 func TestValuesEmptyHeader(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	if diff := cmp.Diff([]string{}, h.Values("Foo-Key")); diff != "" {
 		t.Errorf("h.Values(\"Foo-Key\") mismatch (-want +got):\n%s", diff)
 	}
 }
 
 func TestClaim(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	set := h.Claim("Foo-Key")
 	set([]string{"Bar-Value", "Pizza-Value"})
 	if diff := cmp.Diff([]string{"Bar-Value", "Pizza-Value"}, h.Values("Foo-Key")); diff != "" {
@@ -314,7 +314,7 @@ func TestClaim(t *testing.T) {
 // Note that the casing of the header name is different
 // when accessing and modifying the same header.
 func TestClaimCanonicalization(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	set := h.Claim("fOO-kEY")
 	set([]string{"Bar-Value", "Pizza-Value"})
 	if diff := cmp.Diff([]string{"Bar-Value", "Pizza-Value"}, h.Values("fOo-kEy")); diff != "" {
@@ -323,7 +323,7 @@ func TestClaimCanonicalization(t *testing.T) {
 }
 
 func TestClaimSetCookie(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	defer func() {
 		if r := recover(); r != nil {
 			return
@@ -334,7 +334,7 @@ func TestClaimSetCookie(t *testing.T) {
 }
 
 func TestClaimClaimed(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Claim("Foo-Key")
 	defer func() {
 		if r := recover(); r != nil {
@@ -346,7 +346,7 @@ func TestClaimClaimed(t *testing.T) {
 }
 
 func TestHeaderIsClaimed(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Claim("Foo-Key")
 	if got := h.IsClaimed("Foo-Key"); got != true {
 		t.Errorf(`h.IsClaimed("Foo-Key") got: %v want: true`, got)
@@ -354,7 +354,7 @@ func TestHeaderIsClaimed(t *testing.T) {
 }
 
 func TestHeaderIsClaimedCanonicalization(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	h.Claim("fOo-KEY")
 	if got := h.IsClaimed("foo-keY"); got != true {
 		t.Errorf(`h.IsClaimed("foo-keY") got: %v want: true`, got)
@@ -362,14 +362,14 @@ func TestHeaderIsClaimedCanonicalization(t *testing.T) {
 }
 
 func TestHeaderIsClaimedNotClaimed(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	if got := h.IsClaimed("Foo-Key"); got != false {
 		t.Errorf(`h.IsClaimed("Foo-Key") got: %v want: true`, got)
 	}
 }
 
 func TestHeaderIsClaimedSetCookie(t *testing.T) {
-	h := newHeader(http.Header{})
+	h := NewHeader(http.Header{})
 	if got := h.IsClaimed("Set-Cookie"); got != true {
 		t.Errorf(`h.IsClaimed("Set-Cookie") got: %v want: true`, got)
 	}
diff --git a/safehttp/plugins/collector/collector_test.go b/safehttp/plugins/collector/collector_test.go
index 85b5fd1..3a70a7f 100644
--- a/safehttp/plugins/collector/collector_test.go
+++ b/safehttp/plugins/collector/collector_test.go
@@ -125,7 +125,7 @@ func TestValidReport(t *testing.T) {
 					"sourceFile": "stuff.js",
 					"lineNumber": 10,
 					"columnNumber": 17
-				}	
+				}
 			}]`,
 			want: []collector.Report{
 				collector.Report{
@@ -164,20 +164,20 @@ func TestValidReport(t *testing.T) {
 			req := safehttptest.NewRequest(safehttp.MethodPost, "/collector", strings.NewReader(tt.report))
 			req.Header.Set("Content-Type", "application/reports+json")
 
-			rr := safehttptest.NewResponseRecorder()
-			h.ServeHTTP(rr.ResponseWriter, req)
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
+			h.ServeHTTP(fakeRW, req)
 
 			if diff := cmp.Diff(tt.want, gotReports); diff != "" {
 				t.Errorf("reports gotten mismatch (-want +got):\n%s", diff)
 			}
 
-			if got, want := rr.Status(), safehttp.StatusNoContent; got != want {
-				t.Errorf("rr.Status() got: %v want: %v", got, want)
+			if got, want := rr.Code, int(safehttp.StatusNoContent); got != want {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
 			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
 				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if got, want := rr.Body(), ""; got != want {
+			if got, want := rr.Body.String(), ""; got != want {
 				t.Errorf("rr.Body() got: %q want: %q", got, want)
 			}
 		})
@@ -302,16 +302,16 @@ func TestValidDeprecatedCSPReport(t *testing.T) {
 			req := safehttptest.NewRequest(safehttp.MethodPost, "/collector", strings.NewReader(tt.report))
 			req.Header.Set("Content-Type", "application/csp-report")
 
-			rr := safehttptest.NewResponseRecorder()
-			h.ServeHTTP(rr.ResponseWriter, req)
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
+			h.ServeHTTP(fakeRW, req)
 
-			if got, want := rr.Status(), safehttp.StatusNoContent; got != want {
-				t.Errorf("rr.Status() got: %v want: %v", got, want)
+			if got, want := rr.Code, int(safehttp.StatusNoContent); got != want {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
 			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
 				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if got, want := rr.Body(), ""; got != want {
+			if got, want := rr.Body.String(), ""; got != want {
 				t.Errorf("rr.Body() got: %q want: %q", got, want)
 			}
 		})
@@ -444,18 +444,15 @@ func TestInvalidRequest(t *testing.T) {
 				t.Errorf("expected collector not to be called")
 			})
 
-			rr := safehttptest.NewResponseRecorder()
-			h.ServeHTTP(rr.ResponseWriter, tt.req)
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
+			h.ServeHTTP(fakeRW, tt.req)
 
-			if got := rr.Status(); got != tt.wantStatus {
-				t.Errorf("rr.Status() got: %v want: %v", got, tt.wantStatus)
+			if got, want := rr.Code, int(tt.wantStatus); got != want {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
-			if diff := cmp.Diff(tt.wantHeaders, map[string][]string(rr.Header())); diff != "" {
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
 				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if got := rr.Body(); got != tt.wantBody {
-				t.Errorf("rr.Body() got: %q want: %q", got, tt.wantBody)
-			}
 		})
 	}
 }
diff --git a/safehttp/plugins/coop/coop_test.go b/safehttp/plugins/coop/coop_test.go
index d5a79d6..d32d9d2 100644
--- a/safehttp/plugins/coop/coop_test.go
+++ b/safehttp/plugins/coop/coop_test.go
@@ -15,6 +15,7 @@
 package coop
 
 import (
+	"net/http/httptest"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
@@ -89,7 +90,7 @@ func TestBefore(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			check := func(rr *safehttptest.ResponseRecorder, w want) {
+			check := func(rr *httptest.ResponseRecorder, w want) {
 				t.Helper()
 				h := rr.Header()
 				enf, rep := h.Values("Cross-Origin-Opener-Policy"), h.Values("Cross-Origin-Opener-Policy-Report-Only")
@@ -99,25 +100,25 @@ func TestBefore(t *testing.T) {
 				if diff := cmp.Diff(w.rep, rep); diff != "" {
 					t.Errorf("Report Only COOP -want +got:\n%s", diff)
 				}
-				if rr.Status() != safehttp.StatusOK {
-					t.Errorf("Status: got %v want: %v", rr.Status(), safehttp.StatusOK)
+				if rr.Code != int(safehttp.StatusOK) {
+					t.Errorf("Status: got %v want: %v", rr.Code, safehttp.StatusOK)
 				}
-				if rr.Body() != "" {
-					t.Errorf("Got body: %q, didn't want one", rr.Body())
+				if rr.Body.String() != "" {
+					t.Errorf("Got body: %q, didn't want one", rr.Body.String())
 				}
 			}
 			// Non overridden
 			{
-				rr := safehttptest.NewResponseRecorder()
+				fakeRW, rr := safehttptest.NewFakeResponseWriter()
 				req := safehttptest.NewRequest(safehttp.MethodGet, "/", nil)
-				tt.interceptor.Before(rr.ResponseWriter, req, nil)
+				tt.interceptor.Before(fakeRW, req, nil)
 				check(rr, tt.want)
 			}
 			// Overridden
 			{
-				rr := safehttptest.NewResponseRecorder()
+				fakeRW, rr := safehttptest.NewFakeResponseWriter()
 				req := safehttptest.NewRequest(safehttp.MethodGet, "/", nil)
-				tt.interceptor.Before(rr.ResponseWriter, req, tt.overrider)
+				tt.interceptor.Before(fakeRW, req, tt.overrider)
 				check(rr, tt.wantOverridden)
 			}
 		})
diff --git a/safehttp/plugins/cors/cors_test.go b/safehttp/plugins/cors/cors_test.go
index e331d8f..56a9c0f 100644
--- a/safehttp/plugins/cors/cors_test.go
+++ b/safehttp/plugins/cors/cors_test.go
@@ -151,18 +151,18 @@ func TestRequest(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			rr := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			it := cors.Default("https://foo.com")
 			it.AllowCredentials = tt.allowCredentials
 			it.ExposedHeaders = tt.exposedHeaders
-			it.Before(rr.ResponseWriter, tt.req, nil)
+			it.Before(fakeRW, tt.req, nil)
 
 			if diff := cmp.Diff(tt.want, map[string][]string(rr.Header())); diff != "" {
 				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if got := rr.Body(); got != "" {
-				t.Errorf(`rr.Body() got: %q want: ""`, got)
+			if got := rr.Body.String(); got != "" {
+				t.Errorf(`rr.Body.String() got: %q want: ""`, got)
 			}
 		})
 	}
@@ -174,11 +174,11 @@ func TestVaryHeaderAppending(t *testing.T) {
 	req.Header.Set("X-Cors", "1")
 	req.Header.Set("Content-Type", "application/json")
 
-	rr := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 	rr.Header().Set("Vary", "a")
 
 	it := cors.Default("https://foo.com")
-	it.Before(rr.ResponseWriter, req, nil)
+	it.Before(fakeRW, req, nil)
 
 	wantHeaders := map[string][]string{
 		"Access-Control-Allow-Origin": {"https://foo.com"},
@@ -187,8 +187,8 @@ func TestVaryHeaderAppending(t *testing.T) {
 	if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
 		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 	}
-	if got := rr.Body(); got != "" {
-		t.Errorf(`rr.Body() got: %q want: ""`, got)
+	if got := rr.Body.String(); got != "" {
+		t.Errorf(`rr.Body.String() got: %q want: ""`, got)
 	}
 }
 
@@ -198,24 +198,18 @@ func TestHeadRequest(t *testing.T) {
 	req.Header.Set("X-Cors", "1")
 	req.Header.Set("Content-Type", "application/json")
 
-	rr := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 	it := cors.Default("https://foo.com")
-	it.Before(rr.ResponseWriter, req, nil)
+	it.Before(fakeRW, req, nil)
 
-	if got, want := rr.Status(), safehttp.StatusMethodNotAllowed; got != want {
-		t.Errorf("rr.Status() got: %v want: %v", got, want)
-	}
-	wantHeaders := map[string][]string{
-		"Content-Type":           {"text/plain; charset=utf-8"},
-		"X-Content-Type-Options": {"nosniff"},
+	if got, want := rr.Code, int(safehttp.StatusMethodNotAllowed); got != want {
+		t.Errorf("rr.Code got: %v want: %v", got, want)
 	}
+	wantHeaders := map[string][]string{}
 	if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
 		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 	}
-	if got, want := rr.Body(), "Method Not Allowed\n"; got != want {
-		t.Errorf(`rr.Body() got: %q want: %q`, got, want)
-	}
 }
 
 func TestInvalidRequest(t *testing.T) {
@@ -244,24 +238,18 @@ func TestInvalidRequest(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			rr := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			it := cors.Default("https://foo.com")
-			it.Before(rr.ResponseWriter, tt.req, nil)
+			it.Before(fakeRW, tt.req, nil)
 
-			if want := safehttp.StatusPreconditionFailed; rr.Status() != want {
-				t.Errorf("rr.Status() got: %v want: %v", rr.Status(), want)
-			}
-			wantHeaders := map[string][]string{
-				"Content-Type":           {"text/plain; charset=utf-8"},
-				"X-Content-Type-Options": {"nosniff"},
+			if want := safehttp.StatusPreconditionFailed; rr.Code != int(want) {
+				t.Errorf("rr.Code got: %v want: %v", rr.Code, want)
 			}
+			wantHeaders := map[string][]string{}
 			if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
 				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if got, want := rr.Body(), "Precondition Failed\n"; got != want {
-				t.Errorf(`rr.Body() got: %q want: %q`, got, want)
-			}
 		})
 	}
 }
@@ -283,24 +271,18 @@ func TestRequestDisallowedContentTypes(t *testing.T) {
 				req.Header.Set("Content-Type", ct)
 			}
 
-			rr := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			it := cors.Default("https://foo.com")
-			it.Before(rr.ResponseWriter, req, nil)
+			it.Before(fakeRW, req, nil)
 
-			if want := safehttp.StatusUnsupportedMediaType; rr.Status() != want {
-				t.Errorf("rr.Status() got: %v want: %v", rr.Status(), want)
-			}
-			wantHeaders := map[string][]string{
-				"Content-Type":           {"text/plain; charset=utf-8"},
-				"X-Content-Type-Options": {"nosniff"},
+			if want := safehttp.StatusUnsupportedMediaType; rr.Code != int(want) {
+				t.Errorf("rr.Code got: %v want: %v", rr.Code, want)
 			}
+			wantHeaders := map[string][]string{}
 			if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
 				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if got, want := rr.Body(), "Unsupported Media Type\n"; got != want {
-				t.Errorf(`rr.Body() got: %q want: %q`, got, want)
-			}
 		})
 	}
 }
@@ -309,24 +291,18 @@ func TestDisallowedOrigin(t *testing.T) {
 	req := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com/asdf", nil)
 	req.Header.Set("Origin", "https://pizza.com")
 
-	rr := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 	it := cors.Default("https://foo.com")
-	it.Before(rr.ResponseWriter, req, nil)
+	it.Before(fakeRW, req, nil)
 
-	if want := safehttp.StatusForbidden; rr.Status() != want {
-		t.Errorf("rr.Status() got: %v want: %v", rr.Status(), want)
-	}
-	wantHeaders := map[string][]string{
-		"Content-Type":           {"text/plain; charset=utf-8"},
-		"X-Content-Type-Options": {"nosniff"},
+	if want := safehttp.StatusForbidden; rr.Code != int(want) {
+		t.Errorf("rr.Code got: %v want: %v", rr.Code, want)
 	}
+	wantHeaders := map[string][]string{}
 	if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
 		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 	}
-	if got, want := rr.Body(), "Forbidden\n"; got != want {
-		t.Errorf(`rr.Body() got: %q want: %q`, got, want)
-	}
 }
 
 func TestPreflight(t *testing.T) {
@@ -443,21 +419,21 @@ func TestPreflight(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			rr := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			it := cors.Default("https://foo.com")
 			it.MaxAge = tt.maxAge
 			it.SetAllowedHeaders(tt.allowedHeaders...)
-			it.Before(rr.ResponseWriter, tt.req, nil)
+			it.Before(fakeRW, tt.req, nil)
 
-			if rr.Status() != safehttp.StatusNoContent {
-				t.Errorf("rr.Status() got: %v want: %v", rr.Status(), safehttp.StatusNoContent)
+			if rr.Code != int(safehttp.StatusNoContent) {
+				t.Errorf("rr.Code got: %v want: %v", rr.Code, safehttp.StatusNoContent)
 			}
 			if diff := cmp.Diff(tt.wantHeaders, map[string][]string(rr.Header())); diff != "" {
 				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if got := rr.Body(); got != "" {
-				t.Errorf(`rr.Body() got: %q want: ""`, got)
+			if got := rr.Body.String(); got != "" {
+				t.Errorf(`rr.Body.String() got: %q want: ""`, got)
 			}
 		})
 	}
@@ -490,24 +466,18 @@ func TestInvalidAccessControlRequestHeaders(t *testing.T) {
 			rh.Set("Access-Control-Request-Method", safehttp.MethodPut)
 			rh.Set("Access-Control-Request-Headers", tt.headers)
 
-			rr := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			it := cors.Default("https://foo.com")
-			it.Before(rr.ResponseWriter, req, nil)
+			it.Before(fakeRW, req, nil)
 
-			if want := safehttp.StatusForbidden; rr.Status() != want {
-				t.Errorf("rr.Status() got: %v want: %v", rr.Status(), want)
-			}
-			wantHeaders := map[string][]string{
-				"Content-Type":           {"text/plain; charset=utf-8"},
-				"X-Content-Type-Options": {"nosniff"},
+			if want := safehttp.StatusForbidden; rr.Code != int(want) {
+				t.Errorf("rr.Code got: %v want: %v", rr.Code, want)
 			}
+			wantHeaders := map[string][]string{}
 			if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
 				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if got, want := rr.Body(), "Forbidden\n"; got != want {
-				t.Errorf("rr.Body() got: %q want: %q", got, want)
-			}
 		})
 	}
 }
@@ -517,24 +487,18 @@ func TestEmptyAccessControlRequestMethod(t *testing.T) {
 	rh := req.Header
 	rh.Set("Origin", "https://foo.com")
 
-	rr := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 	it := cors.Default("https://foo.com")
-	it.Before(rr.ResponseWriter, req, nil)
+	it.Before(fakeRW, req, nil)
 
-	if want := safehttp.StatusForbidden; rr.Status() != want {
-		t.Errorf("rr.Status() got: %v want: %v", rr.Status(), want)
-	}
-	wantHeaders := map[string][]string{
-		"Content-Type":           {"text/plain; charset=utf-8"},
-		"X-Content-Type-Options": {"nosniff"},
+	if want := safehttp.StatusForbidden; rr.Code != int(want) {
+		t.Errorf("rr.Code got: %v want: %v", rr.Code, want)
 	}
+	wantHeaders := map[string][]string{}
 	if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
 		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 	}
-	if got, want := rr.Body(), "Forbidden\n"; got != want {
-		t.Errorf("rr.Body() got: %q want: %q", got, want)
-	}
 }
 
 func TestAccessControlRequestMethodHead(t *testing.T) {
@@ -542,46 +506,34 @@ func TestAccessControlRequestMethodHead(t *testing.T) {
 	req.Header.Set("Origin", "https://foo.com")
 	req.Header.Set("Access-Control-Request-Method", safehttp.MethodHead)
 
-	rr := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 	it := cors.Default("https://foo.com")
-	it.Before(rr.ResponseWriter, req, nil)
+	it.Before(fakeRW, req, nil)
 
-	if want := safehttp.StatusForbidden; rr.Status() != want {
-		t.Errorf("rr.Status() got: %v want: %v", rr.Status(), want)
-	}
-	wantHeaders := map[string][]string{
-		"Content-Type":           {"text/plain; charset=utf-8"},
-		"X-Content-Type-Options": {"nosniff"},
+	if want := safehttp.StatusForbidden; rr.Code != int(want) {
+		t.Errorf("rr.Code got: %v want: %v", rr.Code, want)
 	}
+	wantHeaders := map[string][]string{}
 	if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
 		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 	}
-	if got, want := rr.Body(), "Forbidden\n"; got != want {
-		t.Errorf("rr.Body() got: %q want: %q", got, want)
-	}
 }
 
 func TestPreflightEmptyOrigin(t *testing.T) {
 	req := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
 	req.Header.Set("Access-Control-Request-Method", safehttp.MethodHead)
 
-	rr := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 	it := cors.Default("https://foo.com")
-	it.Before(rr.ResponseWriter, req, nil)
+	it.Before(fakeRW, req, nil)
 
-	if want := safehttp.StatusForbidden; rr.Status() != want {
-		t.Errorf("rr.Status() got: %v want: %v", rr.Status(), want)
-	}
-	wantHeaders := map[string][]string{
-		"Content-Type":           {"text/plain; charset=utf-8"},
-		"X-Content-Type-Options": {"nosniff"},
+	if want := safehttp.StatusForbidden; rr.Code != int(want) {
+		t.Errorf("rr.Code got: %v want: %v", rr.Code, want)
 	}
+	wantHeaders := map[string][]string{}
 	if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
 		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 	}
-	if got, want := rr.Body(), "Forbidden\n"; got != want {
-		t.Errorf("rr.Body() got: %q want: %q", got, want)
-	}
 }
diff --git a/safehttp/plugins/csp/csp_test.go b/safehttp/plugins/csp/csp_test.go
index 1eefd20..5984a75 100644
--- a/safehttp/plugins/csp/csp_test.go
+++ b/safehttp/plugins/csp/csp_test.go
@@ -193,10 +193,10 @@ func TestBefore(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			rr := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 			req := safehttptest.NewRequest(safehttp.MethodGet, "/", nil)
 
-			tt.interceptor.Before(rr.ResponseWriter, req, nil)
+			tt.interceptor.Before(fakeRW, req, nil)
 
 			h := rr.Header()
 			if diff := cmp.Diff(tt.wantEnforcePolicy, h.Values("Content-Security-Policy"), cmpopts.EquateEmpty()); diff != "" {
@@ -264,13 +264,13 @@ func TestNonceEmptyContext(t *testing.T) {
 }
 
 func TestCommitNonce(t *testing.T) {
-	rec := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 	req := safehttptest.NewRequest(safehttp.MethodGet, "https://foo.com/pizza", nil)
 	req.SetContext(context.WithValue(req.Context(), ctxKey{}, "pizza"))
 
 	it := Interceptor{}
 	tr := &safehttp.TemplateResponse{}
-	it.Commit(rec.ResponseWriter, req, tr, nil)
+	it.Commit(fakeRW, req, tr, nil)
 
 	nonce, ok := tr.FuncMap["CSPNonce"]
 	if !ok {
@@ -285,21 +285,21 @@ func TestCommitNonce(t *testing.T) {
 		t.Errorf(`tr.FuncMap["CSPNonce"](): got %q, want %q`, got, want)
 	}
 
-	if got, want := rec.Status(), safehttp.StatusOK; want != got {
-		t.Errorf("rec.Status(): got %v, want %v", got, want)
+	if got, want := rr.Code, int(safehttp.StatusOK); got != want {
+		t.Errorf("rr.Code: got %v, want %v", got, want)
 	}
 
-	if diff := cmp.Diff(map[string][]string{}, map[string][]string(rec.Header())); diff != "" {
-		t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+	if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
+		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 	}
 
-	if got, want := "", rec.Body(); got != want {
-		t.Errorf("rec.Body(): got %q want %q", got, want)
+	if got, want := "", rr.Body.String(); got != want {
+		t.Errorf("rr.Body.String(): got %q want %q", got, want)
 	}
 }
 
 func TestCommitMissingNonce(t *testing.T) {
-	rec := safehttptest.NewResponseRecorder()
+	fakeRW, _ := safehttptest.NewFakeResponseWriter()
 	req := safehttptest.NewRequest(safehttp.MethodGet, "https://foo.com/pizza", nil)
 	req.SetContext(context.Background())
 
@@ -311,26 +311,26 @@ func TestCommitMissingNonce(t *testing.T) {
 			t.Fatal("expected panic")
 		}
 	}()
-	it.Commit(rec.ResponseWriter, req, tr, nil)
+	it.Commit(fakeRW, req, tr, nil)
 }
 
 func TestCommitNotTemplateResponse(t *testing.T) {
-	rec := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 	req := safehttptest.NewRequest(safehttp.MethodGet, "https://foo.com/pizza", nil)
 
 	it := Interceptor{}
-	it.Commit(rec.ResponseWriter, req, safehttp.NoContentResponse{}, nil)
+	it.Commit(fakeRW, req, safehttp.NoContentResponse{}, nil)
 
-	if got, want := rec.Status(), safehttp.StatusOK; want != got {
-		t.Errorf("rec.Status(): got %v, want %v", got, want)
+	if got, want := rr.Code, int(safehttp.StatusOK); got != want {
+		t.Errorf("rr.Code: got %v, want %v", got, want)
 	}
 
-	if diff := cmp.Diff(map[string][]string{}, map[string][]string(rec.Header())); diff != "" {
-		t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+	if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
+		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 	}
 
-	if got, want := rec.Body(), ""; got != want {
-		t.Errorf("rec.Body(): got %q want %q", got, want)
+	if got, want := rr.Body.String(), ""; got != want {
+		t.Errorf("rr.Body.String(): got %q want %q", got, want)
 	}
 
 }
diff --git a/safehttp/plugins/fetchmetadata/fetchmetadata_test.go b/safehttp/plugins/fetchmetadata/fetchmetadata_test.go
index c7b7f3c..2c91f0b 100644
--- a/safehttp/plugins/fetchmetadata/fetchmetadata_test.go
+++ b/safehttp/plugins/fetchmetadata/fetchmetadata_test.go
@@ -166,19 +166,19 @@ func TestAllowedResourceIsolationEnforceMode(t *testing.T) {
 			req.Header.Add("Sec-Fetch-Site", test.site)
 			req.Header.Add("Sec-Fetch-Mode", test.mode)
 			req.Header.Add("Sec-Fetch-Dest", test.dest)
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			p := fetchmetadata.NewInterceptor()
-			p.Before(rec.ResponseWriter, req, nil)
+			p.Before(fakeRW, req, nil)
 
-			if want, got := safehttp.StatusOK, safehttp.StatusCode(rec.Status()); got != want {
-				t.Errorf("rec.Status() got: %v want: %v", got, want)
+			if want, got := int(safehttp.StatusOK), rr.Code; got != want {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
-			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
+				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if want, got := "", rec.Body(); got != want {
-				t.Errorf("rec.Body() got: %q want: %q", got, want)
+			if want, got := "", rr.Body.String(); got != want {
+				t.Errorf("rr.Body.String() got: %q want: %q", got, want)
 			}
 		})
 	}
@@ -192,23 +192,13 @@ func TestRejectedResourceIsolationEnforceMode(t *testing.T) {
 			req.Header.Add("Sec-Fetch-Site", test.site)
 			req.Header.Add("Sec-Fetch-Mode", test.mode)
 			req.Header.Add("Sec-Fetch-Dest", test.dest)
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			p := fetchmetadata.NewInterceptor()
-			p.Before(rec.ResponseWriter, req, nil)
+			p.Before(fakeRW, req, nil)
 
-			if want, got := safehttp.StatusForbidden, safehttp.StatusCode(rec.Status()); want != got {
-				t.Errorf("rec.Status() got: %v want: %v", got, want)
-			}
-			wantHeaders := map[string][]string{
-				"Content-Type":           {"text/plain; charset=utf-8"},
-				"X-Content-Type-Options": {"nosniff"},
-			}
-			if diff := cmp.Diff(wantHeaders, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
-			}
-			if want, got := "Forbidden\n", rec.Body(); got != want {
-				t.Errorf("rec.Body() got: %q want: %q", got, want)
+			if want, got := safehttp.StatusForbidden, safehttp.StatusCode(rr.Code); want != got {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
 		})
 	}
@@ -244,25 +234,15 @@ func TestRejectedResourceIsolationEnforceModeWithLogger(t *testing.T) {
 			req.Header.Add("Sec-Fetch-Site", test.site)
 			req.Header.Add("Sec-Fetch-Mode", test.mode)
 			req.Header.Add("Sec-Fetch-Dest", test.dest)
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			p := fetchmetadata.NewInterceptor()
 			logger := &methodLogger{}
 			p.Logger = logger
-			p.Before(rec.ResponseWriter, req, nil)
+			p.Before(fakeRW, req, nil)
 
-			if want, got := safehttp.StatusForbidden, safehttp.StatusCode(rec.Status()); want != got {
-				t.Errorf("rec.Status() got: %v want: %v", got, want)
-			}
-			wantHeaders := map[string][]string{
-				"Content-Type":           {"text/plain; charset=utf-8"},
-				"X-Content-Type-Options": {"nosniff"},
-			}
-			if diff := cmp.Diff(wantHeaders, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
-			}
-			if want, got := "Forbidden\n", rec.Body(); got != want {
-				t.Errorf("rec.Body() got: %q want: %q", got, want)
+			if want, got := safehttp.StatusForbidden, safehttp.StatusCode(rr.Code); want != got {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
 			if test.report != logger.report {
 				t.Errorf("logger.report: got %s, want %s", logger.report, test.report)
@@ -299,22 +279,22 @@ func TestResourceIsolationReportMode(t *testing.T) {
 			req.Header.Add("Sec-Fetch-Site", test.site)
 			req.Header.Add("Sec-Fetch-Mode", test.mode)
 			req.Header.Add("Sec-Fetch-Dest", test.dest)
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			p := fetchmetadata.NewInterceptor()
 			logger := &methodLogger{}
 			p.Logger = logger
 			p.SetReportOnly()
-			p.Before(rec.ResponseWriter, req, nil)
+			p.Before(fakeRW, req, nil)
 
-			if want, got := safehttp.StatusOK, safehttp.StatusCode(rec.Status()); got != want {
-				t.Errorf("rec.Status() got: %v want: %v", got, want)
+			if want, got := safehttp.StatusOK, safehttp.StatusCode(rr.Code); got != want {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
-			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
+				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if want, got := "", rec.Body(); got != want {
-				t.Errorf("rec.Body() got: %q want: %q", got, want)
+			if want, got := "", rr.Body.String(); got != want {
+				t.Errorf("rr.Body.String() got: %q want: %q", got, want)
 			}
 			if logger.report != test.report {
 				t.Errorf("logger.report: got %s, want %s", logger.report, test.report)
@@ -342,24 +322,14 @@ func TestNavIsolationEnforceMode(t *testing.T) {
 			req.Header.Add("Sec-Fetch-Site", test.site)
 			req.Header.Add("Sec-Fetch-Mode", test.mode)
 			req.Header.Add("Sec-Fetch-Dest", test.dest)
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			p := fetchmetadata.NewInterceptor()
 			p.NavIsolation = true
-			p.Before(rec.ResponseWriter, req, nil)
+			p.Before(fakeRW, req, nil)
 
-			if want, got := safehttp.StatusForbidden, safehttp.StatusCode(rec.Status()); want != got {
-				t.Errorf("rec.Status() got: %v want: %v", got, want)
-			}
-			wantHeaders := map[string][]string{
-				"Content-Type":           {"text/plain; charset=utf-8"},
-				"X-Content-Type-Options": {"nosniff"},
-			}
-			if diff := cmp.Diff(wantHeaders, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
-			}
-			if want, got := "Forbidden\n", rec.Body(); got != want {
-				t.Errorf("rec.Body() got: %q want: %q", got, want)
+			if want, got := safehttp.StatusForbidden, safehttp.StatusCode(rr.Code); want != got {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
 		})
 	}
@@ -396,23 +366,23 @@ func TestNavIsolationReportMode(t *testing.T) {
 			req.Header.Add("Sec-Fetch-Site", test.site)
 			req.Header.Add("Sec-Fetch-Mode", test.mode)
 			req.Header.Add("Sec-Fetch-Dest", test.dest)
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			p := fetchmetadata.NewInterceptor()
 			logger := &methodLogger{}
 			p.Logger = logger
 			p.NavIsolation = true
 			p.SetReportOnly()
-			p.Before(rec.ResponseWriter, req, nil)
+			p.Before(fakeRW, req, nil)
 
-			if want, got := safehttp.StatusOK, safehttp.StatusCode(rec.Status()); want != got {
-				t.Errorf("rec.Status() got: %v want: %v", got, want)
+			if want, got := safehttp.StatusOK, safehttp.StatusCode(rr.Code); want != got {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
-			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
+				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if want, got := "", rec.Body(); got != want {
-				t.Errorf("rec.Body() got: %q want: %q", got, want)
+			if want, got := "", rr.Body.String(); got != want {
+				t.Errorf("rr.Body.String() got: %q want: %q", got, want)
 			}
 			if logger.report != test.report {
 				t.Errorf("logger.report: got %s, want %s", logger.report, test.report)
@@ -429,19 +399,19 @@ func TestCORSEndpoint(t *testing.T) {
 			req.Header.Add("Sec-Fetch-Site", test.site)
 			req.Header.Add("Sec-Fetch-Mode", test.mode)
 			req.Header.Add("Sec-Fetch-Dest", test.dest)
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			p := fetchmetadata.NewInterceptor("/carbonara")
-			p.Before(rec.ResponseWriter, req, nil)
+			p.Before(fakeRW, req, nil)
 
-			if want, got := safehttp.StatusOK, safehttp.StatusCode(rec.Status()); got != want {
-				t.Errorf("rec.Status() got: %v want: %v", got, want)
+			if want, got := safehttp.StatusOK, safehttp.StatusCode(rr.Code); got != want {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
-			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
+				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-			if want, got := "", rec.Body(); got != want {
-				t.Errorf("rec.Body() got: %q want: %q", got, want)
+			if want, got := "", rr.Body.String(); got != want {
+				t.Errorf("rr.Body.String() got: %q want: %q", got, want)
 			}
 		})
 	}
@@ -450,8 +420,6 @@ func TestCORSEndpoint(t *testing.T) {
 func TestCORSAfterRedirect(t *testing.T) {
 	tests := []struct {
 		name, method, site, mode, dest string
-		wantHeaders                    map[string][]string
-		wantBody                       string
 	}{
 		{
 			name:   "cross origin POST",
@@ -459,10 +427,6 @@ func TestCORSAfterRedirect(t *testing.T) {
 			site:   "cross-site",
 			mode:   "navigate",
 			dest:   "document",
-			wantHeaders: map[string][]string{
-				"Location": {"https://spaghetti.com/carbonara"},
-			},
-			wantBody: "",
 		},
 		{
 			name:   "cross origin GET from object",
@@ -470,11 +434,6 @@ func TestCORSAfterRedirect(t *testing.T) {
 			site:   "cross-site",
 			mode:   "navigate",
 			dest:   "object",
-			wantHeaders: map[string][]string{
-				"Content-Type": {"text/html; charset=utf-8"},
-				"Location":     {"https://spaghetti.com/carbonara"},
-			},
-			wantBody: "<a href=\"https://spaghetti.com/carbonara\">Moved Permanently</a>.\n\n",
 		},
 		{
 			name:   "cross origin HEAD from embed",
@@ -482,11 +441,6 @@ func TestCORSAfterRedirect(t *testing.T) {
 			site:   "cross-site",
 			mode:   "navigate",
 			dest:   "embed",
-			wantHeaders: map[string][]string{
-				"Content-Type": {"text/html; charset=utf-8"},
-				"Location":     {"https://spaghetti.com/carbonara"},
-			},
-			wantBody: "",
 		},
 	}
 	for _, test := range tests {
@@ -495,21 +449,18 @@ func TestCORSAfterRedirect(t *testing.T) {
 			req.Header.Add("Sec-Fetch-Site", test.site)
 			req.Header.Add("Sec-Fetch-Mode", test.mode)
 			req.Header.Add("Sec-Fetch-Dest", test.dest)
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			p := fetchmetadata.NewInterceptor("/carbonara")
 			p.NavIsolation = true
 			p.RedirectURL, _ = safehttp.ParseURL("https://spaghetti.com/carbonara")
-			p.Before(rec.ResponseWriter, req, nil)
+			p.Before(fakeRW, req, nil)
 
-			if want, got := safehttp.StatusMovedPermanently, safehttp.StatusCode(rec.Status()); got != want {
-				t.Errorf("rec.Status() got: %v want: %v", got, want)
-			}
-			if diff := cmp.Diff(test.wantHeaders, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			if want, got := safehttp.StatusMovedPermanently, safehttp.StatusCode(rr.Code); got != want {
+				t.Errorf("rr.Code got: %v want: %v", got, want)
 			}
-			if gotBody := rec.Body(); test.wantBody != gotBody {
-				t.Errorf("rec.Body() got: %q want: %q", gotBody, test.wantBody)
+			if got, want := fakeRW.RedirectURL, "https://spaghetti.com/carbonara"; got != want {
+				t.Errorf("RedirectURL got %q, want %q", got, want)
 			}
 		})
 	}
diff --git a/safehttp/plugins/hsts/hsts_test.go b/safehttp/plugins/hsts/hsts_test.go
index 10656dc..f30570e 100644
--- a/safehttp/plugins/hsts/hsts_test.go
+++ b/safehttp/plugins/hsts/hsts_test.go
@@ -24,120 +24,122 @@ import (
 	"github.com/google/go-safeweb/safehttp/safehttptest"
 )
 
-func TestHSTS(t *testing.T) {
+func TestHSTSReject(t *testing.T) {
 	var test = []struct {
-		name        string
-		interceptor hsts.Interceptor
-		req         *safehttp.IncomingRequest
-		wantStatus  safehttp.StatusCode
-		wantHeaders map[string][]string
-		wantBody    string
+		name         string
+		interceptor  hsts.Interceptor
+		req          *safehttp.IncomingRequest
+		wantStatus   safehttp.StatusCode
+		wantBody     string
+		wantRedirect string
 	}{
 		{
-			name:        "HTTPS",
-			interceptor: hsts.Default(),
+			name:         "HTTP",
+			interceptor:  hsts.Default(),
+			req:          safehttptest.NewRequest(safehttp.MethodGet, "http://localhost/", nil),
+			wantStatus:   safehttp.StatusMovedPermanently,
+			wantRedirect: "https://localhost/",
+		},
+		{
+			name:        "Negative MaxAge",
+			interceptor: hsts.Interceptor{MaxAge: -1 * time.Second},
 			req:         safehttptest.NewRequest(safehttp.MethodGet, "https://localhost/", nil),
-			wantStatus:  safehttp.StatusOK,
-			wantHeaders: map[string][]string{
-				"Strict-Transport-Security": {"max-age=63072000; includeSubDomains"}, // 63072000 seconds is two years
-			},
-			wantBody: "",
+			wantStatus:  safehttp.StatusInternalServerError,
 		},
+	}
+
+	for _, tt := range test {
+		t.Run(tt.name, func(t *testing.T) {
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
+
+			tt.interceptor.Before(fakeRW, tt.req, nil)
+
+			if gotStatus := rr.Code; gotStatus != int(tt.wantStatus) {
+				t.Errorf("rr.Code got: %v want: %v", gotStatus, tt.wantStatus)
+			}
+
+			if got, want := fakeRW.RedirectURL, tt.wantRedirect; got != want {
+				t.Errorf("RedirectURL got %q, want %q", got, want)
+			}
+		})
+	}
+}
+
+func TestHSTSOK(t *testing.T) {
+	var test = []struct {
+		name         string
+		interceptor  hsts.Interceptor
+		req          *safehttp.IncomingRequest
+		wantHeaders  map[string][]string
+		wantRedirect string
+	}{
 		{
-			name:        "HTTP",
+			name:        "HTTPS",
 			interceptor: hsts.Default(),
-			req:         safehttptest.NewRequest(safehttp.MethodGet, "http://localhost/", nil),
-			wantStatus:  safehttp.StatusMovedPermanently,
+			req:         safehttptest.NewRequest(safehttp.MethodGet, "https://localhost/", nil),
 			wantHeaders: map[string][]string{
-				"Content-Type": {"text/html; charset=utf-8"},
-				"Location":     {"https://localhost/"},
+				"Strict-Transport-Security": {"max-age=63072000; includeSubDomains"}, // 63072000 seconds is two years
 			},
-			wantBody: "<a href=\"https://localhost/\">Moved Permanently</a>.\n\n",
 		},
 		{
 			name:        "HTTP behind proxy",
 			interceptor: hsts.Interceptor{BehindProxy: true},
 			req:         safehttptest.NewRequest(safehttp.MethodGet, "http://localhost/", nil),
-			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				// max-age=0 tells the browser to expire the HSTS protection.
 				"Strict-Transport-Security": {"max-age=0; includeSubDomains"},
 			},
-			wantBody: "",
 		},
 		{
 			name:        "Preload",
 			interceptor: hsts.Interceptor{Preload: true, DisableIncludeSubDomains: true},
 			req:         safehttptest.NewRequest(safehttp.MethodGet, "https://localhost/", nil),
-			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				// max-age=0 tells the browser to expire the HSTS protection.
 				"Strict-Transport-Security": {"max-age=0; preload"},
 			},
-			wantBody: "",
 		},
 		{
 			name:        "Preload and IncludeSubDomains",
 			interceptor: hsts.Interceptor{Preload: true},
 			req:         safehttptest.NewRequest(safehttp.MethodGet, "https://localhost/", nil),
-			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				// max-age=0 tells the browser to expire the HSTS protection.
 				"Strict-Transport-Security": {"max-age=0; includeSubDomains; preload"},
 			},
-			wantBody: "",
 		},
 		{
 			name:        "No preload and no includeSubDomains",
 			interceptor: hsts.Interceptor{DisableIncludeSubDomains: true},
 			req:         safehttptest.NewRequest(safehttp.MethodGet, "https://localhost/", nil),
-			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				// max-age=0 tells the browser to expire the HSTS protection.
 				"Strict-Transport-Security": {"max-age=0"},
 			},
-			wantBody: "",
 		},
 		{
-			name:        "Custom maxage",
+			name:        "Custom MaxAge",
 			interceptor: hsts.Interceptor{MaxAge: 3600 * time.Second},
 			req:         safehttptest.NewRequest(safehttp.MethodGet, "https://localhost/", nil),
-			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				"Strict-Transport-Security": {"max-age=3600; includeSubDomains"}, // 3600 seconds is 1 hour
 			},
-			wantBody: "",
-		},
-		{
-			name:        "Negative maxage",
-			interceptor: hsts.Interceptor{MaxAge: -1 * time.Second},
-			req:         safehttptest.NewRequest(safehttp.MethodGet, "https://localhost/", nil),
-			wantStatus:  safehttp.StatusInternalServerError,
-			wantHeaders: map[string][]string{
-				"Content-Type":           {"text/plain; charset=utf-8"},
-				"X-Content-Type-Options": {"nosniff"},
-			},
-			wantBody: "Internal Server Error\n",
 		},
 	}
 
 	for _, tt := range test {
 		t.Run(tt.name, func(t *testing.T) {
-			rr := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
-			tt.interceptor.Before(rr.ResponseWriter, tt.req, nil)
+			tt.interceptor.Before(fakeRW, tt.req, nil)
 
-			if gotStatus := rr.Status(); gotStatus != tt.wantStatus {
-				t.Errorf("rr.Status() got: %v want: %v", gotStatus, tt.wantStatus)
+			if gotStatus := rr.Code; gotStatus != int(safehttp.StatusOK) {
+				t.Errorf("rr.Code got: %v want: %v", gotStatus, safehttp.StatusOK)
 			}
 
 			if diff := cmp.Diff(tt.wantHeaders, map[string][]string(rr.Header())); diff != "" {
 				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
 			}
-
-			if got := rr.Body(); got != tt.wantBody {
-				t.Errorf("rr.Body() got: %q want: %q", got, tt.wantBody)
-			}
 		})
 	}
 }
diff --git a/safehttp/plugins/staticheaders/staticheaders_test.go b/safehttp/plugins/staticheaders/staticheaders_test.go
index e54e706..d066d35 100644
--- a/safehttp/plugins/staticheaders/staticheaders_test.go
+++ b/safehttp/plugins/staticheaders/staticheaders_test.go
@@ -17,7 +17,6 @@ package staticheaders_test
 import (
 	"testing"
 
-	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-safeweb/safehttp"
 	"github.com/google/go-safeweb/safehttp/plugins/staticheaders"
 	"github.com/google/go-safeweb/safehttp/safehttptest"
@@ -25,24 +24,12 @@ import (
 
 func TestPlugin(t *testing.T) {
 	req := safehttptest.NewRequest(safehttp.MethodGet, "/", nil)
-	rr := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 	p := staticheaders.Interceptor{}
-	p.Before(rr.ResponseWriter, req, nil)
+	p.Before(fakeRW, req, nil)
 
-	if got, want := rr.Status(), safehttp.StatusOK; got != want {
-		t.Errorf("rr.Status() got: %v want: %v", got, want)
-	}
-
-	wantHeaders := map[string][]string{
-		"X-Content-Type-Options": {"nosniff"},
-		"X-Xss-Protection":       {"0"},
-	}
-	if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
-		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
-	}
-
-	if got, want := rr.Body(), ""; got != want {
-		t.Errorf("rr.Body() got: %q want: %q", got, want)
+	if got, want := rr.Code, safehttp.StatusOK; got != int(want) {
+		t.Errorf("rr.Code got: %v want: %v", got, want)
 	}
 }
diff --git a/safehttp/plugins/xsrf/xsrfangular/xsrf_test.go b/safehttp/plugins/xsrf/xsrfangular/xsrf_test.go
index 8d4cae1..fc8d207 100644
--- a/safehttp/plugins/xsrf/xsrfangular/xsrf_test.go
+++ b/safehttp/plugins/xsrf/xsrfangular/xsrf_test.go
@@ -18,7 +18,6 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-safeweb/safehttp"
 	"github.com/google/go-safeweb/safehttp/safehttptest"
 )
@@ -51,33 +50,23 @@ func TestAddCookie(t *testing.T) {
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
 			req := safehttptest.NewRequest(safehttp.MethodGet, "/", nil)
-			rec := safehttptest.NewResponseRecorder()
-			test.it.Commit(rec.ResponseWriter, req, nil, nil)
+			fakeRW, _ := safehttptest.NewFakeResponseWriter()
+			test.it.Commit(fakeRW, req, nil, nil)
 
-			if got, want := rec.Status(), safehttp.StatusOK; got != want {
-				t.Errorf("rec.Status(): got %v, want %v", got, want)
+			if len(fakeRW.Cookies) != 1 {
+				t.Errorf("len(Cookies) = %v, want 1", len(fakeRW.Cookies))
 			}
 
-			wantCookie := "Path=/; Max-Age=86400; Secure; SameSite=Strict"
-			got := map[string][]string(rec.Header())["Set-Cookie"][0]
-			if !strings.Contains(got, test.cookie) {
-				t.Errorf("Set-Cookie header: %s not present", test.cookie)
+			if got, want := fakeRW.Cookies[0].String(), "Path=/; Max-Age=86400; Secure; SameSite=Strict"; !strings.Contains(got, want) {
+				t.Errorf("XSRF cookie got %q, want to contain %q", got, want)
 			}
-			if !strings.Contains(got, wantCookie) {
-				t.Errorf("Set-Cookie header: got %q, want defaults %q", got, wantCookie)
-			}
-
-			if got, want := rec.Body(), ""; got != want {
-				t.Errorf("rec.Body(): got %q want %q", got, want)
-			}
-
 		})
 	}
 }
 
 func TestAddCookieFail(t *testing.T) {
 	req := safehttptest.NewRequest(safehttp.MethodGet, "/", nil)
-	rec := safehttptest.NewResponseRecorder()
+	fakeRW, _ := safehttptest.NewFakeResponseWriter()
 	it := &Interceptor{}
 
 	defer func() {
@@ -85,7 +74,7 @@ func TestAddCookieFail(t *testing.T) {
 			t.Fatal("expected panic")
 		}
 	}()
-	it.Commit(rec.ResponseWriter, req, nil, nil)
+	it.Commit(fakeRW, req, nil, nil)
 }
 
 func TestPostProtection(t *testing.T) {
@@ -155,20 +144,12 @@ func TestPostProtection(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 			i := Default()
-			i.Before(rec.ResponseWriter, test.req, nil)
-
-			if got := rec.Status(); got != test.wantStatus {
-				t.Errorf("rec.Status(): got %v, want %v", got, test.wantStatus)
-			}
-
-			if diff := cmp.Diff(test.wantHeader, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
-			}
+			i.Before(fakeRW, test.req, nil)
 
-			if got := rec.Body(); got != test.wantBody {
-				t.Errorf("rec.Body(): got %q want %q", got, test.wantBody)
+			if got := rr.Code; got != int(test.wantStatus) {
+				t.Errorf("rr.Code: got %v, want %v", got, test.wantStatus)
 			}
 		})
 	}
diff --git a/safehttp/plugins/xsrf/xsrfhtml/xsrf_test.go b/safehttp/plugins/xsrf/xsrfhtml/xsrf_test.go
index 538fbbf..1220e55 100644
--- a/safehttp/plugins/xsrf/xsrfhtml/xsrf_test.go
+++ b/safehttp/plugins/xsrf/xsrfhtml/xsrf_test.go
@@ -18,7 +18,6 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-safeweb/safehttp"
 	"github.com/google/go-safeweb/safehttp/safehttptest"
 	"golang.org/x/net/xsrftoken"
@@ -26,39 +25,26 @@ import (
 
 var (
 	formTokenTests = []struct {
-		name, cookieVal, actionID, wantBody string
-		wantStatus                          safehttp.StatusCode
-		wantHeader                          map[string][]string
+		name, cookieVal, actionID string
+		wantStatus                safehttp.StatusCode
 	}{
 		{
 			name:       "Valid token",
 			cookieVal:  "abcdef",
 			actionID:   "/pizza",
 			wantStatus: safehttp.StatusOK,
-			wantHeader: map[string][]string{},
-			wantBody:   "",
 		},
 		{
 			name:       "Invalid actionID in token generation",
 			cookieVal:  "abcdef",
 			actionID:   "/spaghetti",
 			wantStatus: safehttp.StatusForbidden,
-			wantHeader: map[string][]string{
-				"Content-Type":           {"text/plain; charset=utf-8"},
-				"X-Content-Type-Options": {"nosniff"},
-			},
-			wantBody: "Forbidden\n",
 		},
 		{
 			name:       "Invalid cookie value in token generation",
 			cookieVal:  "evilvalue",
 			actionID:   "/pizza",
 			wantStatus: safehttp.StatusForbidden,
-			wantHeader: map[string][]string{
-				"Content-Type":           {"text/plain; charset=utf-8"},
-				"X-Content-Type-Options": {"nosniff"},
-			},
-			wantBody: "Forbidden\n",
 		},
 	}
 )
@@ -66,23 +52,17 @@ var (
 func TestTokenPost(t *testing.T) {
 	for _, test := range formTokenTests {
 		t.Run(test.name, func(t *testing.T) {
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 			tok := xsrftoken.Generate("testSecretAppKey", test.cookieVal, test.actionID)
 			req := safehttptest.NewRequest(safehttp.MethodPost, "https://foo.com/pizza", strings.NewReader(TokenKey+"="+tok))
 			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 			req.Header.Set("Cookie", cookieIDKey+"=abcdef")
 
 			i := Interceptor{SecretAppKey: "testSecretAppKey"}
-			i.Before(rec.ResponseWriter, req, nil)
+			i.Before(fakeRW, req, nil)
 
-			if got := rec.Status(); got != test.wantStatus {
-				t.Errorf("rec.Status(): got %v, want %v", got, test.wantStatus)
-			}
-			if diff := cmp.Diff(test.wantHeader, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
-			}
-			if got := rec.Body(); got != test.wantBody {
-				t.Errorf("rec.Body(): got %q want %q", got, test.wantBody)
+			if got := rr.Code; got != int(test.wantStatus) {
+				t.Errorf("rr.Code: got %v, want %v", got, test.wantStatus)
 			}
 		})
 	}
@@ -91,7 +71,7 @@ func TestTokenPost(t *testing.T) {
 func TestTokenMultipart(t *testing.T) {
 	for _, test := range formTokenTests {
 		t.Run(test.name, func(t *testing.T) {
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 			tok := xsrftoken.Generate("testSecretAppKey", test.cookieVal, test.actionID)
 			b := "--123\r\n" +
 				"Content-Disposition: form-data; name=\"xsrf-token\"\r\n" +
@@ -103,42 +83,26 @@ func TestTokenMultipart(t *testing.T) {
 			req.Header.Set("Cookie", cookieIDKey+"=abcdef")
 
 			i := Interceptor{SecretAppKey: "testSecretAppKey"}
-			i.Before(rec.ResponseWriter, req, nil)
+			i.Before(fakeRW, req, nil)
 
-			if got := rec.Status(); got != test.wantStatus {
-				t.Errorf("rec.Status(): got %v, want %v", got, test.wantStatus)
-			}
-			if diff := cmp.Diff(test.wantHeader, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
-			}
-			if got := rec.Body(); got != test.wantBody {
-				t.Errorf("rec.Body(): got %q want %q", got, test.wantBody)
+			if got := rr.Code; got != int(test.wantStatus) {
+				t.Errorf("rr.Code: got %v, want %v", got, test.wantStatus)
 			}
 		})
 	}
 }
 
 func TestMalformedForm(t *testing.T) {
-	rec := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 	req := safehttptest.NewRequest(safehttp.MethodPost, "https://foo.com/pizza", nil)
 	req.Header.Set("Content-Type", "wrong")
 	req.Header.Set("Cookie", cookieIDKey+"=abcdef")
 
 	i := Interceptor{SecretAppKey: "testSecretAppKey"}
-	i.Before(rec.ResponseWriter, req, nil)
+	i.Before(fakeRW, req, nil)
 
-	if want, got := safehttp.StatusBadRequest, rec.Status(); got != want {
-		t.Errorf("rec.Status(): got %v, want %v", got, want)
-	}
-	wantHeaders := map[string][]string{
-		"Content-Type":           {"text/plain; charset=utf-8"},
-		"X-Content-Type-Options": {"nosniff"},
-	}
-	if diff := cmp.Diff(wantHeaders, map[string][]string(rec.Header())); diff != "" {
-		t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
-	}
-	if want, got := "Bad Request\n", rec.Body(); got != want {
-		t.Errorf("rec.Body(): got %q want %q", got, want)
+	if want, got := int(safehttp.StatusBadRequest), rr.Code; got != want {
+		t.Errorf("rr.Code: got %v, want %v", got, want)
 	}
 }
 
@@ -196,50 +160,34 @@ func TestMissingTokenInBody(t *testing.T) {
 	}
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 
 			i := Interceptor{SecretAppKey: "testSecretAppKey"}
-			i.Before(rec.ResponseWriter, test.req, nil)
+			i.Before(fakeRW, test.req, nil)
 
-			if want, got := safehttp.StatusUnauthorized, rec.Status(); got != want {
-				t.Errorf("rec.Status(): got %v, want %v", got, want)
-			}
-			wantHeaders := map[string][]string{
-				"Content-Type":           {"text/plain; charset=utf-8"},
-				"X-Content-Type-Options": {"nosniff"},
-			}
-			if diff := cmp.Diff(wantHeaders, map[string][]string(rec.Header())); diff != "" {
-				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
-			}
-			if want, got := "Unauthorized\n", rec.Body(); got != want {
-				t.Errorf("rec.Body(): got %q want %q", got, want)
+			if want, got := safehttp.StatusUnauthorized, rr.Code; got != int(want) {
+				t.Errorf("rr.Code: got %v, want %v", got, want)
 			}
 		})
 	}
 }
 
 func TestMissingCookieInGetRequest(t *testing.T) {
-	rec := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 	req := safehttptest.NewRequest(safehttp.MethodGet, "https://foo.com/pizza", nil)
 
 	i := Interceptor{SecretAppKey: "testSecretAppKey"}
-	i.Commit(rec.ResponseWriter, req, nil, nil)
+	i.Commit(fakeRW, req, nil, nil)
 
-	if want, got := safehttp.StatusOK, rec.Status(); want != got {
-		t.Errorf("rec.Status(): got %v, want %v", got, want)
+	if want, got := safehttp.StatusOK, rr.Code; got != int(want) {
+		t.Errorf("rr.Code: got %v, want %v", got, want)
 	}
 
-	tokCookieDefaults := "HttpOnly; Secure; SameSite=Strict"
-	got := map[string][]string(rec.Header())["Set-Cookie"]
-	if len(got) == 0 {
-		t.Error("rec.Header(): expected Set-Cookie header to be set")
-	}
-	if !strings.Contains(got[0], tokCookieDefaults) {
-		t.Errorf("Set-Cookie header: got %s, want defaults %s", got, tokCookieDefaults)
+	if len(fakeRW.Cookies) != 1 {
+		t.Errorf("len(Cookies) = %v, want 1", len(fakeRW.Cookies))
 	}
-
-	if want, got := "", rec.Body(); got != want {
-		t.Errorf("rec.Body(): got %q want %q", got, want)
+	if got, want := fakeRW.Cookies[0].String(), "HttpOnly; Secure; SameSite=Strict"; !strings.Contains(got, want) {
+		t.Errorf("XSRF cookie got %q, want to contain %q", got, want)
 	}
 }
 
@@ -268,14 +216,14 @@ func TestMissingCookieInPostRequest(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
-			rec := safehttptest.NewResponseRecorder()
+			fakeRW, rr := safehttptest.NewFakeResponseWriter()
 			req := safehttptest.NewRequest(safehttp.MethodPost, "/", strings.NewReader("foo=bar"))
 			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 
-			test.stage(&Interceptor{SecretAppKey: "testSecretAppKey"}, rec.ResponseWriter, req)
+			test.stage(&Interceptor{SecretAppKey: "testSecretAppKey"}, fakeRW, req)
 
-			if gotStatus := rec.Status(); gotStatus != test.wantStatus {
-				t.Errorf("rec.Status(): got %v, want %v", gotStatus, test.wantStatus)
+			if gotStatus := rr.Code; gotStatus != int(test.wantStatus) {
+				t.Errorf("rr.Code: got %v, want %v", gotStatus, test.wantStatus)
 			}
 		})
 	}
@@ -283,12 +231,12 @@ func TestMissingCookieInPostRequest(t *testing.T) {
 }
 
 func TestCommitTokenInResponse(t *testing.T) {
-	rec := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 	req := safehttptest.NewRequest(safehttp.MethodGet, "https://foo.com/pizza", nil)
 
 	i := Interceptor{SecretAppKey: "testSecretAppKey"}
 	tr := &safehttp.TemplateResponse{}
-	i.Commit(rec.ResponseWriter, req, tr, nil)
+	i.Commit(fakeRW, req, tr, nil)
 
 	tok, ok := tr.FuncMap["XSRFToken"]
 	if !ok {
@@ -303,27 +251,27 @@ func TestCommitTokenInResponse(t *testing.T) {
 		t.Error(`tr.FuncMap["XSRFToken"](): got empty token`, got)
 	}
 
-	if want, got := safehttp.StatusOK, rec.Status(); want != got {
-		t.Errorf("rec.Status(): got %v, want %v", got, want)
+	if want, got := safehttp.StatusOK, rr.Code; got != int(want) {
+		t.Errorf("rr.Code: got %v, want %v", got, want)
 	}
 
-	if want, got := "", rec.Body(); got != want {
-		t.Errorf("rec.Body(): got %q want %q", got, want)
+	if want, got := "", rr.Body.String(); got != want {
+		t.Errorf("rr.Body.String(): got %q want %q", got, want)
 	}
 }
 
 func TestCommitNotTemplateResponse(t *testing.T) {
-	rec := safehttptest.NewResponseRecorder()
+	fakeRW, rr := safehttptest.NewFakeResponseWriter()
 	req := safehttptest.NewRequest(safehttp.MethodGet, "https://foo.com/pizza", nil)
 
 	i := Interceptor{SecretAppKey: "testSecretAppKey"}
-	i.Commit(rec.ResponseWriter, req, safehttp.NoContentResponse{}, nil)
+	i.Commit(fakeRW, req, safehttp.NoContentResponse{}, nil)
 
-	if want, got := safehttp.StatusOK, rec.Status(); want != got {
-		t.Errorf("rec.Status(): got %v, want %v", got, want)
+	if want, got := safehttp.StatusOK, rr.Code; got != int(want) {
+		t.Errorf("rr.Code: got %v, want %v", got, want)
 	}
 
-	if want, got := "", rec.Body(); got != want {
-		t.Errorf("rec.Body(): got %q want %q", got, want)
+	if want, got := "", rr.Body.String(); got != want {
+		t.Errorf("rr.Body.String(): got %q want %q", got, want)
 	}
 }
diff --git a/safehttp/safehttptest/recorder.go b/safehttp/safehttptest/recorder.go
index 89d653b..03db8ad 100644
--- a/safehttp/safehttptest/recorder.go
+++ b/safehttp/safehttptest/recorder.go
@@ -21,34 +21,96 @@ import (
 	"github.com/google/go-safeweb/safehttp"
 )
 
-// ResponseRecorder encapsulates a safehttp.ResponseWriter that records
-// mutations for later inspection in tests. The safehttp.ResponseWriter
-// should be passed as part of the handler function in tests.
-type ResponseRecorder struct {
-	safehttp.ResponseWriter
-	rw *httptest.ResponseRecorder
-}
-
-// NewResponseRecorder creates a ResponseRecorder.
-func NewResponseRecorder() *ResponseRecorder {
-	rw := httptest.NewRecorder()
-	return &ResponseRecorder{
-		rw:             rw,
-		ResponseWriter: safehttp.DeprecatedNewResponseWriter(rw, nil),
+// FakeResponseWriter creates a fake safehttp.ResponseWriter implementation.
+//
+// It does no error checking nor runs interceptors.
+type FakeResponseWriter struct {
+	// The Dispatcher implementation
+	Dispatcher safehttp.Dispatcher
+
+	// ResponseWriter is only used for calls to Dispatcher. Calls to AddCookie()
+	// do not affect it.
+	ResponseWriter http.ResponseWriter
+
+	// Cookies coming from AddCookie() calls. Use the ResponseWriter to see what
+	// cookies have been set by the Dispatcher.
+	Cookies []*safehttp.Cookie
+
+	// Response headers.
+	Headers safehttp.Header
+
+	// The redirect URL.
+	RedirectURL string
+}
+
+// FakeDispatcher provides a minimal implementation of the Dispatcher to be used for testing Interceptors.
+type FakeDispatcher struct{}
+
+// Write is not implemented.
+func (d FakeDispatcher) Write(rw http.ResponseWriter, resp safehttp.Response) error {
+	panic("not implemented") // TODO: Implement
+}
+
+// Error writes just the status code.
+func (d FakeDispatcher) Error(rw http.ResponseWriter, resp safehttp.ErrorResponse) error {
+	rw.WriteHeader(int(resp.Code()))
+	return nil
+}
+
+// NewFakeResponseWriter creates a new safehttp.ResponseWriter implementation
+// and a httptest.ResponseRecorder, for testing purposes only.
+func NewFakeResponseWriter() (*FakeResponseWriter, *httptest.ResponseRecorder) {
+	recorder := httptest.NewRecorder()
+	return &FakeResponseWriter{
+		Dispatcher:     FakeDispatcher{},
+		ResponseWriter: recorder,
+		Headers:        safehttp.NewHeader(recorder.HeaderMap),
+	}, recorder
+}
+
+var _ safehttp.ResponseWriter = (*FakeResponseWriter)(nil)
+
+// Header returns the Header.
+func (frw *FakeResponseWriter) Header() safehttp.Header {
+	return frw.Headers
+}
+
+// AddCookie appends the given cookie to the Cookies field.
+func (frw *FakeResponseWriter) AddCookie(c *safehttp.Cookie) error {
+	if len(c.Name()) == 0 {
+		panic("empty cookie name")
 	}
+
+	frw.Cookies = append(frw.Cookies, c)
+	return nil
 }
 
-// Header returns the recorded response headers.
-func (r *ResponseRecorder) Header() http.Header {
-	return r.rw.Header()
+// Write forwards the response to Dispatcher.Write.
+func (frw *FakeResponseWriter) Write(resp safehttp.Response) safehttp.Result {
+	if err := frw.Dispatcher.Write(frw.ResponseWriter, resp); err != nil {
+		panic(err)
+	}
+	return safehttp.Result{}
 }
 
-// Status returns the recorded response status code.
-func (r *ResponseRecorder) Status() safehttp.StatusCode {
-	return safehttp.StatusCode(r.rw.Code)
+// NoContent writes just the NoContent status code.
+func (frw *FakeResponseWriter) NoContent() safehttp.Result {
+	frw.ResponseWriter.WriteHeader(int(safehttp.StatusNoContent))
+	return safehttp.Result{}
+}
+
+// WrriteError forwards the error response to Dispatcher.WriteError.
+func (frw *FakeResponseWriter) WriteError(resp safehttp.ErrorResponse) safehttp.Result {
+	if err := frw.Dispatcher.Error(frw.ResponseWriter, resp); err != nil {
+		panic(err)
+	}
+	return safehttp.Result{}
 }
 
-// Body returns the recorded response body.
-func (r *ResponseRecorder) Body() string {
-	return r.rw.Body.String()
+// Redirect saves the URL in RedirectURL and writes the MovedPermentantly status
+// code.
+func (frw *FakeResponseWriter) Redirect(r *safehttp.IncomingRequest, url string, code safehttp.StatusCode) safehttp.Result {
+	frw.RedirectURL = url
+	frw.ResponseWriter.WriteHeader(int(safehttp.StatusMovedPermanently))
+	return safehttp.Result{}
 }
diff --git a/safehttp/task_test.go b/safehttp/task_test.go
deleted file mode 100644
index 0873c9c..0000000
--- a/safehttp/task_test.go
+++ /dev/null
@@ -1,154 +0,0 @@
-// Copyright 2020 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// 	https://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package safehttp_test
-
-import (
-	"math"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-	"text/template"
-
-	"github.com/google/go-cmp/cmp"
-	"github.com/google/go-safeweb/safehttp"
-	"github.com/google/go-safeweb/safehttp/safehttptest"
-	"github.com/google/safehtml"
-)
-
-func stubResponseWriter(dispatcher safehttp.Dispatcher, rw http.ResponseWriter) safehttp.ResponseWriter {
-	if dispatcher == nil {
-		dispatcher = safehttp.DefaultDispatcher{}
-	}
-	return safehttp.DeprecatedNewResponseWriter(rw, dispatcher)
-}
-
-func TestTaskSetCookie(t *testing.T) {
-	testRw := httptest.NewRecorder()
-	rw := stubResponseWriter(safehttp.DefaultDispatcher{}, testRw)
-
-	c := safehttp.NewCookie("foo", "bar")
-	err := rw.AddCookie(c)
-	if err != nil {
-		t.Errorf("rw.AddCookie(c) got: %v want: nil", err)
-	}
-
-	wantHeaders := map[string][]string{
-		"Set-Cookie": {"foo=bar; HttpOnly; Secure; SameSite=Lax"},
-	}
-	if diff := cmp.Diff(wantHeaders, map[string][]string(testRw.Header())); diff != "" {
-		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
-	}
-}
-
-func TestTaskSetInvalidCookie(t *testing.T) {
-	testRw := httptest.NewRecorder()
-	rw := stubResponseWriter(safehttp.DefaultDispatcher{}, testRw)
-
-	c := safehttp.NewCookie("f=oo", "bar")
-	err := rw.AddCookie(c)
-	if err == nil {
-		t.Error("rw.AddCookie(c) got: nil want: error")
-	}
-}
-
-func TestTaskWriteTwicePanic(t *testing.T) {
-	tests := []struct {
-		name  string
-		write func(w safehttp.ResponseWriter)
-	}{
-		{
-			name: "Call Write twice",
-			write: func(w safehttp.ResponseWriter) {
-				w.Write(safehtml.HTMLEscaped("<h1>Escaped, so not really a heading</h1>"))
-				w.Write(safehtml.HTMLEscaped("<h1>Escaped, so not really a heading</h1>"))
-			},
-		},
-		{
-			name: "Call NoContent twice",
-			write: func(w safehttp.ResponseWriter) {
-				w.NoContent()
-				w.NoContent()
-			},
-		},
-		{
-			name: "Call WriteError twice",
-			write: func(w safehttp.ResponseWriter) {
-				w.WriteError(safehttp.StatusInternalServerError)
-				w.WriteError(safehttp.StatusInternalServerError)
-			},
-		},
-		{
-			name: "Call Redirect twice",
-			write: func(w safehttp.ResponseWriter) {
-				ir := safehttptest.NewRequest(safehttp.MethodGet, "/", nil)
-				w.Redirect(ir, "/asdf", safehttp.StatusMovedPermanently)
-				w.Redirect(ir, "/asdf", safehttp.StatusMovedPermanently)
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			w := stubResponseWriter(safehttp.DefaultDispatcher{}, httptest.NewRecorder())
-			defer func() {
-				if r := recover(); r != nil {
-					return
-				}
-				t.Errorf("tt.write(w) expected panic")
-			}()
-			tt.write(w)
-		})
-	}
-}
-
-func TestTaskUnsafeResponse(t *testing.T) {
-	tests := []struct {
-		name  string
-		write func(w safehttp.ResponseWriter)
-	}{
-		{
-			name: "Unsafe HTML Response",
-			write: func(w safehttp.ResponseWriter) {
-				w.Write("<h1>Hello World!</h1>")
-			},
-		},
-		{
-			name: "Invalid JSON Response",
-			write: func(w safehttp.ResponseWriter) {
-				safehttp.WriteJSON(w, math.Inf(1))
-			},
-		},
-		{
-			name: "Unsafe Template Response",
-			write: func(w safehttp.ResponseWriter) {
-				safehttp.ExecuteTemplate(w, template.
-					Must(template.New("name").
-						Parse("<h1>{{ . }}</h1>")), "This is an actual heading, though.")
-			},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			w := stubResponseWriter(safehttp.DefaultDispatcher{}, httptest.NewRecorder())
-			defer func() {
-				if r := recover(); r != nil {
-					return
-				}
-				t.Errorf("tt.write(w) expected panic")
-			}()
-			tt.write(w)
-		})
-	}
-}