mirror of
https://github.com/akvorado/akvorado.git
synced 2025-12-12 06:24:10 +01:00
common/helpers: move some test functions to separate files
This commit is contained in:
@@ -8,240 +8,14 @@
|
|||||||
package helpers
|
package helpers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/kylelemons/godebug/pretty"
|
|
||||||
"github.com/mitchellh/mapstructure"
|
|
||||||
"gopkg.in/yaml.v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var prettyC = pretty.Config{
|
|
||||||
Diffable: true,
|
|
||||||
PrintStringers: false,
|
|
||||||
SkipZeroFields: true,
|
|
||||||
IncludeUnexported: false,
|
|
||||||
Formatter: map[reflect.Type]interface{}{
|
|
||||||
reflect.TypeOf(net.IP{}): fmt.Sprint,
|
|
||||||
reflect.TypeOf(netip.Addr{}): fmt.Sprint,
|
|
||||||
reflect.TypeOf(time.Time{}): fmt.Sprint,
|
|
||||||
reflect.TypeOf(SubnetMap[string]{}): fmt.Sprint,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiffOption changes the behavior of the Diff function.
|
|
||||||
type DiffOption struct {
|
|
||||||
kind int
|
|
||||||
// When this is a formatter
|
|
||||||
t reflect.Type
|
|
||||||
fn interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diff return a diff of two objects. If no diff, an empty string is
|
|
||||||
// returned.
|
|
||||||
func Diff(a, b interface{}, options ...DiffOption) string {
|
|
||||||
prettyC = prettyC
|
|
||||||
for _, option := range options {
|
|
||||||
switch option.kind {
|
|
||||||
case DiffUnexported.kind:
|
|
||||||
prettyC.IncludeUnexported = true
|
|
||||||
case DiffZero.kind:
|
|
||||||
prettyC.SkipZeroFields = false
|
|
||||||
case DiffFormatter(nil, nil).kind:
|
|
||||||
prettyC.Formatter[option.t] = option.fn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return prettyC.Compare(a, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
// DiffUnexported will display diff of unexported fields too.
|
|
||||||
DiffUnexported = DiffOption{kind: 1}
|
|
||||||
// DiffZero will include zero-field in diff
|
|
||||||
DiffZero = DiffOption{kind: 2}
|
|
||||||
)
|
|
||||||
|
|
||||||
// DiffFormatter adds a new formatter
|
|
||||||
func DiffFormatter(t reflect.Type, fn interface{}) DiffOption {
|
|
||||||
return DiffOption{kind: 3, t: t, fn: fn}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPEndpointCases describes case for TestHTTPEndpoints
|
|
||||||
type HTTPEndpointCases []struct {
|
|
||||||
Description string
|
|
||||||
Method string
|
|
||||||
URL string
|
|
||||||
Header http.Header
|
|
||||||
JSONInput interface{}
|
|
||||||
|
|
||||||
ContentType string
|
|
||||||
StatusCode int
|
|
||||||
FirstLines []string
|
|
||||||
JSONOutput interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTTPEndpoints test a few HTTP endpoints
|
|
||||||
func TestHTTPEndpoints(t *testing.T, serverAddr net.Addr, cases HTTPEndpointCases) {
|
|
||||||
t.Helper()
|
|
||||||
for _, tc := range cases {
|
|
||||||
desc := tc.Description
|
|
||||||
if desc == "" {
|
|
||||||
desc = tc.URL
|
|
||||||
}
|
|
||||||
t.Run(desc, func(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
if tc.FirstLines != nil && tc.JSONOutput != nil {
|
|
||||||
t.Fatalf("Cannot have both FirstLines and JSONOutput")
|
|
||||||
}
|
|
||||||
var resp *http.Response
|
|
||||||
var err error
|
|
||||||
if tc.Method == "" {
|
|
||||||
if tc.JSONInput == nil {
|
|
||||||
tc.Method = "GET"
|
|
||||||
} else {
|
|
||||||
tc.Method = "POST"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tc.JSONInput == nil {
|
|
||||||
req, _ := http.NewRequest(tc.Method,
|
|
||||||
fmt.Sprintf("http://%s%s", serverAddr, tc.URL),
|
|
||||||
nil)
|
|
||||||
if tc.Header != nil {
|
|
||||||
req.Header = tc.Header
|
|
||||||
}
|
|
||||||
resp, err = http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s %s:\n%+v", tc.Method, tc.URL, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
payload := new(bytes.Buffer)
|
|
||||||
err = json.NewEncoder(payload).Encode(tc.JSONInput)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Encode() error:\n%+v", err)
|
|
||||||
}
|
|
||||||
req, _ := http.NewRequest(tc.Method,
|
|
||||||
fmt.Sprintf("http://%s%s", serverAddr, tc.URL),
|
|
||||||
payload)
|
|
||||||
if tc.Header != nil {
|
|
||||||
req.Header = tc.Header
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
resp, err = http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s %s:\n%+v", tc.Method, tc.URL, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if tc.StatusCode == 0 {
|
|
||||||
tc.StatusCode = 200
|
|
||||||
}
|
|
||||||
if resp.StatusCode != tc.StatusCode {
|
|
||||||
t.Errorf("%s %s: got status code %d, not %d", tc.URL,
|
|
||||||
tc.Method, resp.StatusCode, tc.StatusCode)
|
|
||||||
}
|
|
||||||
if tc.JSONOutput != nil {
|
|
||||||
tc.ContentType = "application/json; charset=utf-8"
|
|
||||||
}
|
|
||||||
gotContentType := resp.Header.Get("Content-Type")
|
|
||||||
if gotContentType != tc.ContentType {
|
|
||||||
t.Errorf("%s %s Content-Type (-got, +want):\n-%s\n+%s",
|
|
||||||
tc.Method, tc.URL, gotContentType, tc.ContentType)
|
|
||||||
}
|
|
||||||
if tc.JSONOutput == nil {
|
|
||||||
reader := bufio.NewScanner(resp.Body)
|
|
||||||
got := []string{}
|
|
||||||
for reader.Scan() && len(got) < len(tc.FirstLines) {
|
|
||||||
got = append(got, reader.Text())
|
|
||||||
}
|
|
||||||
if diff := Diff(got, tc.FirstLines); diff != "" {
|
|
||||||
t.Errorf("%s %s (-got, +want):\n%s", tc.Method, tc.URL, diff)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
var got gin.H
|
|
||||||
if err := decoder.Decode(&got); err != nil {
|
|
||||||
t.Fatalf("%s %s:\n%+v", tc.Method, tc.URL, err)
|
|
||||||
}
|
|
||||||
if diff := Diff(got, tc.JSONOutput); diff != "" {
|
|
||||||
t.Fatalf("%s %s (-got, +want):\n%s", tc.Method, tc.URL, diff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigurationDecodeCases describes a test case for configuration
|
|
||||||
// decode. We use functions to return value as the decoding process
|
|
||||||
// may mutate the configuration.
|
|
||||||
type ConfigurationDecodeCases []struct {
|
|
||||||
Description string
|
|
||||||
Initial func() interface{} // initial value for configuration
|
|
||||||
Configuration func() interface{} // configuration to decode
|
|
||||||
Expected interface{}
|
|
||||||
Error bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigurationDecode helps decoding configuration. It also test decoding from YAML.
|
|
||||||
func TestConfigurationDecode(t *testing.T, cases ConfigurationDecodeCases, options ...DiffOption) {
|
|
||||||
t.Helper()
|
|
||||||
for _, tc := range cases {
|
|
||||||
for _, fromYAML := range []bool{false, true} {
|
|
||||||
title := tc.Description
|
|
||||||
if fromYAML {
|
|
||||||
title = fmt.Sprintf("%s (from YAML)", title)
|
|
||||||
if tc.Configuration == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Run(title, func(t *testing.T) {
|
|
||||||
var configuration interface{}
|
|
||||||
if fromYAML {
|
|
||||||
// Encode and decode with YAML
|
|
||||||
out, err := yaml.Marshal(tc.Configuration())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("yaml.Marshal() error:\n%+v", err)
|
|
||||||
}
|
|
||||||
if err := yaml.Unmarshal(out, &configuration); err != nil {
|
|
||||||
t.Fatalf("yaml.Unmarshal() error:\n%+v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Just use as is
|
|
||||||
configuration = tc.Configuration()
|
|
||||||
}
|
|
||||||
got := tc.Initial()
|
|
||||||
|
|
||||||
decoder, err := mapstructure.NewDecoder(GetMapStructureDecoderConfig(&got))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewDecoder() error:\n%+v", err)
|
|
||||||
}
|
|
||||||
err = decoder.Decode(configuration)
|
|
||||||
if err != nil && !tc.Error {
|
|
||||||
t.Fatalf("Decode() error:\n%+v", err)
|
|
||||||
} else if err == nil && tc.Error {
|
|
||||||
t.Errorf("Decode() did not error")
|
|
||||||
}
|
|
||||||
|
|
||||||
if diff := Diff(got, tc.Expected, options...); diff != "" && err == nil {
|
|
||||||
t.Fatalf("Decode() (-got, +want):\n%s", diff)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckExternalService checks an external service, available either
|
// CheckExternalService checks an external service, available either
|
||||||
// as a named service or on a specific port on localhost. This applies
|
// as a named service or on a specific port on localhost. This applies
|
||||||
// for example for Kafka and ClickHouse. The timeouts are quite short,
|
// for example for Kafka and ClickHouse. The timeouts are quite short,
|
||||||
|
|||||||
73
common/helpers/tests_config.go
Normal file
73
common/helpers/tests_config.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
//go:build !release
|
||||||
|
|
||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mitchellh/mapstructure"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigurationDecodeCases describes a test case for configuration
|
||||||
|
// decode. We use functions to return value as the decoding process
|
||||||
|
// may mutate the configuration.
|
||||||
|
type ConfigurationDecodeCases []struct {
|
||||||
|
Description string
|
||||||
|
Initial func() interface{} // initial value for configuration
|
||||||
|
Configuration func() interface{} // configuration to decode
|
||||||
|
Expected interface{}
|
||||||
|
Error bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigurationDecode helps decoding configuration. It also test decoding from YAML.
|
||||||
|
func TestConfigurationDecode(t *testing.T, cases ConfigurationDecodeCases, options ...DiffOption) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tc := range cases {
|
||||||
|
for _, fromYAML := range []bool{false, true} {
|
||||||
|
title := tc.Description
|
||||||
|
if fromYAML {
|
||||||
|
title = fmt.Sprintf("%s (from YAML)", title)
|
||||||
|
if tc.Configuration == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Run(title, func(t *testing.T) {
|
||||||
|
var configuration interface{}
|
||||||
|
if fromYAML {
|
||||||
|
// Encode and decode with YAML
|
||||||
|
out, err := yaml.Marshal(tc.Configuration())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("yaml.Marshal() error:\n%+v", err)
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(out, &configuration); err != nil {
|
||||||
|
t.Fatalf("yaml.Unmarshal() error:\n%+v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just use as is
|
||||||
|
configuration = tc.Configuration()
|
||||||
|
}
|
||||||
|
got := tc.Initial()
|
||||||
|
|
||||||
|
decoder, err := mapstructure.NewDecoder(GetMapStructureDecoderConfig(&got))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDecoder() error:\n%+v", err)
|
||||||
|
}
|
||||||
|
err = decoder.Decode(configuration)
|
||||||
|
if err != nil && !tc.Error {
|
||||||
|
t.Fatalf("Decode() error:\n%+v", err)
|
||||||
|
} else if err == nil && tc.Error {
|
||||||
|
t.Errorf("Decode() did not error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := Diff(got, tc.Expected, options...); diff != "" && err == nil {
|
||||||
|
t.Fatalf("Decode() (-got, +want):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
common/helpers/tests_diff.go
Normal file
66
common/helpers/tests_diff.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
//go:build !release
|
||||||
|
|
||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kylelemons/godebug/pretty"
|
||||||
|
)
|
||||||
|
|
||||||
|
var prettyC = pretty.Config{
|
||||||
|
Diffable: true,
|
||||||
|
PrintStringers: false,
|
||||||
|
SkipZeroFields: true,
|
||||||
|
IncludeUnexported: false,
|
||||||
|
Formatter: map[reflect.Type]interface{}{
|
||||||
|
reflect.TypeOf(net.IP{}): fmt.Sprint,
|
||||||
|
reflect.TypeOf(netip.Addr{}): fmt.Sprint,
|
||||||
|
reflect.TypeOf(time.Time{}): fmt.Sprint,
|
||||||
|
reflect.TypeOf(SubnetMap[string]{}): fmt.Sprint,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffOption changes the behavior of the Diff function.
|
||||||
|
type DiffOption struct {
|
||||||
|
kind int
|
||||||
|
// When this is a formatter
|
||||||
|
t reflect.Type
|
||||||
|
fn interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff return a diff of two objects. If no diff, an empty string is
|
||||||
|
// returned.
|
||||||
|
func Diff(a, b interface{}, options ...DiffOption) string {
|
||||||
|
prettyC = prettyC
|
||||||
|
for _, option := range options {
|
||||||
|
switch option.kind {
|
||||||
|
case DiffUnexported.kind:
|
||||||
|
prettyC.IncludeUnexported = true
|
||||||
|
case DiffZero.kind:
|
||||||
|
prettyC.SkipZeroFields = false
|
||||||
|
case DiffFormatter(nil, nil).kind:
|
||||||
|
prettyC.Formatter[option.t] = option.fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prettyC.Compare(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// DiffUnexported will display diff of unexported fields too.
|
||||||
|
DiffUnexported = DiffOption{kind: 1}
|
||||||
|
// DiffZero will include zero-field in diff
|
||||||
|
DiffZero = DiffOption{kind: 2}
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiffFormatter adds a new formatter
|
||||||
|
func DiffFormatter(t reflect.Type, fn interface{}) DiffOption {
|
||||||
|
return DiffOption{kind: 3, t: t, fn: fn}
|
||||||
|
}
|
||||||
123
common/helpers/tests_http.go
Normal file
123
common/helpers/tests_http.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
//go:build !release
|
||||||
|
|
||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPEndpointCases describes case for TestHTTPEndpoints
|
||||||
|
type HTTPEndpointCases []struct {
|
||||||
|
Description string
|
||||||
|
Method string
|
||||||
|
URL string
|
||||||
|
Header http.Header
|
||||||
|
JSONInput interface{}
|
||||||
|
|
||||||
|
ContentType string
|
||||||
|
StatusCode int
|
||||||
|
FirstLines []string
|
||||||
|
JSONOutput interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPEndpoints test a few HTTP endpoints
|
||||||
|
func TestHTTPEndpoints(t *testing.T, serverAddr net.Addr, cases HTTPEndpointCases) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tc := range cases {
|
||||||
|
desc := tc.Description
|
||||||
|
if desc == "" {
|
||||||
|
desc = tc.URL
|
||||||
|
}
|
||||||
|
t.Run(desc, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if tc.FirstLines != nil && tc.JSONOutput != nil {
|
||||||
|
t.Fatalf("Cannot have both FirstLines and JSONOutput")
|
||||||
|
}
|
||||||
|
var resp *http.Response
|
||||||
|
var err error
|
||||||
|
if tc.Method == "" {
|
||||||
|
if tc.JSONInput == nil {
|
||||||
|
tc.Method = "GET"
|
||||||
|
} else {
|
||||||
|
tc.Method = "POST"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.JSONInput == nil {
|
||||||
|
req, _ := http.NewRequest(tc.Method,
|
||||||
|
fmt.Sprintf("http://%s%s", serverAddr, tc.URL),
|
||||||
|
nil)
|
||||||
|
if tc.Header != nil {
|
||||||
|
req.Header = tc.Header
|
||||||
|
}
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s:\n%+v", tc.Method, tc.URL, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload := new(bytes.Buffer)
|
||||||
|
err = json.NewEncoder(payload).Encode(tc.JSONInput)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error:\n%+v", err)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(tc.Method,
|
||||||
|
fmt.Sprintf("http://%s%s", serverAddr, tc.URL),
|
||||||
|
payload)
|
||||||
|
if tc.Header != nil {
|
||||||
|
req.Header = tc.Header
|
||||||
|
}
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s:\n%+v", tc.Method, tc.URL, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if tc.StatusCode == 0 {
|
||||||
|
tc.StatusCode = 200
|
||||||
|
}
|
||||||
|
if resp.StatusCode != tc.StatusCode {
|
||||||
|
t.Errorf("%s %s: got status code %d, not %d", tc.URL,
|
||||||
|
tc.Method, resp.StatusCode, tc.StatusCode)
|
||||||
|
}
|
||||||
|
if tc.JSONOutput != nil {
|
||||||
|
tc.ContentType = "application/json; charset=utf-8"
|
||||||
|
}
|
||||||
|
gotContentType := resp.Header.Get("Content-Type")
|
||||||
|
if gotContentType != tc.ContentType {
|
||||||
|
t.Errorf("%s %s Content-Type (-got, +want):\n-%s\n+%s",
|
||||||
|
tc.Method, tc.URL, gotContentType, tc.ContentType)
|
||||||
|
}
|
||||||
|
if tc.JSONOutput == nil {
|
||||||
|
reader := bufio.NewScanner(resp.Body)
|
||||||
|
got := []string{}
|
||||||
|
for reader.Scan() && len(got) < len(tc.FirstLines) {
|
||||||
|
got = append(got, reader.Text())
|
||||||
|
}
|
||||||
|
if diff := Diff(got, tc.FirstLines); diff != "" {
|
||||||
|
t.Errorf("%s %s (-got, +want):\n%s", tc.Method, tc.URL, diff)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
decoder := json.NewDecoder(resp.Body)
|
||||||
|
var got gin.H
|
||||||
|
if err := decoder.Decode(&got); err != nil {
|
||||||
|
t.Fatalf("%s %s:\n%+v", tc.Method, tc.URL, err)
|
||||||
|
}
|
||||||
|
if diff := Diff(got, tc.JSONOutput); diff != "" {
|
||||||
|
t.Fatalf("%s %s (-got, +want):\n%s", tc.Method, tc.URL, diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user