Skip to content

Commit

Permalink
πŸ”₯ Feature: Add TestConfig to app.Test() for configurable testing (#3161)
Browse files Browse the repository at this point in the history
* πŸ”₯ Feature: Add thread-safe reading from a closed testConn

* πŸ”₯ Feature: Add TestConfig to app.Test()

This commit is summarized as:
- Add the struct `TestConfig` as a parameter for `app.Test()` instead of `timeout`
- Add documentation of `TestConfig` to docs/api/app.md and in-line
- Modify middleware to use `TestConfig` instead of the previous implementation

Fixes #3149

* πŸ“š Doc: Add more details about TestConfig in docs

* 🩹 Fix: Correct testConn tests

- Fixes Test_Utils_TestConn_Closed_Write
- Fixes missing regular write test

* 🎨 Style: Respect linter in Add App Test Config

* 🎨 Styles: Update app.go to respect linter

* ♻️ Refactor: Rename TestConfig's ErrOnTimeout to FailOnTimeout

- Rename TestConfig.ErrOnTimeout to TestConfig.FailOnTimeout
- Update documentation to use changed name
  - Also fix stale documentation about passing Timeout as a
    single argument

* 🩹 Fix: Fix typo in TestConfig struct comment in app.go

* ♻️ Refactor: Change app.Test() fail on timeouterror to os.ErrDeadlineExceeded

* ♻️ Refactor:Update middleware that use the same TestConfig to use a global variable

* 🩹 Fix: Update error from FailOnTimeout to os.ErrDeadlineExceeded in tests

* 🩹 Fix: Remove errors import from middlware/proxy/proxy_test.go

* πŸ“š Doc: Add `app.Test()` config changes to docs/whats_new.md

* β™» Refactor: Change app.Test() and all uses to accept 0 as no timeout instead of -1

* πŸ“š Doc: Add TestConfig option details to docs/whats_new.md

* 🎨 Styles: Update docs/whats_new.md to respect markdown-lint

* 🎨 Styles: Update docs/whats_new.md to use consistent style for TestConfig options description

---------

Co-authored-by: Juan Calderon-Perez <[email protected]>
  • Loading branch information
grivera64 and gaby authored Nov 22, 2024
1 parent b6ecd63 commit f8b490f
Show file tree
Hide file tree
Showing 14 changed files with 328 additions and 56 deletions.
46 changes: 37 additions & 9 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import (
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"os"
"reflect"
"strconv"
"strings"
Expand Down Expand Up @@ -901,13 +903,33 @@ func (app *App) Hooks() *Hooks {
return app.hooks
}

// TestConfig is a struct holding Test settings
type TestConfig struct {
// Timeout defines the maximum duration a
// test can run before timing out.
// Default: time.Second
Timeout time.Duration

// FailOnTimeout specifies whether the test
// should return a timeout error if the HTTP response
// exceeds the Timeout duration.
// Default: true
FailOnTimeout bool
}

// Test is used for internal debugging by passing a *http.Request.
// Timeout is optional and defaults to 1s, -1 will disable it completely.
func (app *App) Test(req *http.Request, timeout ...time.Duration) (*http.Response, error) {
// Set timeout
to := 1 * time.Second
if len(timeout) > 0 {
to = timeout[0]
// Config is optional and defaults to a 1s error on timeout,
// 0 timeout will disable it completely.
func (app *App) Test(req *http.Request, config ...TestConfig) (*http.Response, error) {
// Default config
cfg := TestConfig{
Timeout: time.Second,
FailOnTimeout: true,
}

// Override config if provided
if len(config) > 0 {
cfg = config[0]
}

// Add Content-Length if not provided with body
Expand Down Expand Up @@ -946,12 +968,15 @@ func (app *App) Test(req *http.Request, timeout ...time.Duration) (*http.Respons
}()

// Wait for callback
if to >= 0 {
if cfg.Timeout > 0 {
// With timeout
select {
case err = <-channel:
case <-time.After(to):
return nil, fmt.Errorf("test: timeout error after %s", to)
case <-time.After(cfg.Timeout):
conn.Close() //nolint:errcheck, revive // It is fine to ignore the error here
if cfg.FailOnTimeout {
return nil, os.ErrDeadlineExceeded
}
}
} else {
// Without timeout
Expand All @@ -969,6 +994,9 @@ func (app *App) Test(req *http.Request, timeout ...time.Duration) (*http.Respons
// Convert raw http response to *http.Response
res, err := http.ReadResponse(buffer, req)
if err != nil {
if errors.Is(err, io.ErrUnexpectedEOF) {
return nil, errors.New("test: got empty response")
}
return nil, fmt.Errorf("failed to read response: %w", err)
}

Expand Down
37 changes: 32 additions & 5 deletions app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"reflect"
"regexp"
"runtime"
Expand Down Expand Up @@ -1124,7 +1125,9 @@ func Test_Test_Timeout(t *testing.T) {

app.Get("/", testEmptyHandler)

resp, err := app.Test(httptest.NewRequest(MethodGet, "/", nil), -1)
resp, err := app.Test(httptest.NewRequest(MethodGet, "/", nil), TestConfig{
Timeout: 0,
})
require.NoError(t, err, "app.Test(req)")
require.Equal(t, 200, resp.StatusCode, "Status code")

Expand All @@ -1133,7 +1136,10 @@ func Test_Test_Timeout(t *testing.T) {
return nil
})

_, err = app.Test(httptest.NewRequest(MethodGet, "/timeout", nil), 20*time.Millisecond)
_, err = app.Test(httptest.NewRequest(MethodGet, "/timeout", nil), TestConfig{
Timeout: 20 * time.Millisecond,
FailOnTimeout: true,
})
require.Error(t, err, "app.Test(req)")
}

Expand Down Expand Up @@ -1432,7 +1438,9 @@ func Test_App_Test_no_timeout_infinitely(t *testing.T) {
})

req := httptest.NewRequest(MethodGet, "/", nil)
_, err = app.Test(req, -1)
_, err = app.Test(req, TestConfig{
Timeout: 0,
})
}()

tk := time.NewTimer(5 * time.Second)
Expand Down Expand Up @@ -1460,8 +1468,27 @@ func Test_App_Test_timeout(t *testing.T) {
return nil
})

_, err := app.Test(httptest.NewRequest(MethodGet, "/", nil), 100*time.Millisecond)
require.Equal(t, errors.New("test: timeout error after 100ms"), err)
_, err := app.Test(httptest.NewRequest(MethodGet, "/", nil), TestConfig{
Timeout: 100 * time.Millisecond,
FailOnTimeout: true,
})
require.Equal(t, os.ErrDeadlineExceeded, err)
}

func Test_App_Test_timeout_empty_response(t *testing.T) {
t.Parallel()

app := New()
app.Get("/", func(_ Ctx) error {
time.Sleep(1 * time.Second)
return nil
})

_, err := app.Test(httptest.NewRequest(MethodGet, "/", nil), TestConfig{
Timeout: 100 * time.Millisecond,
FailOnTimeout: false,
})
require.Equal(t, errors.New("test: got empty response"), err)
}

func Test_App_SetTLSHandler(t *testing.T) {
Expand Down
5 changes: 4 additions & 1 deletion ctx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3243,7 +3243,10 @@ func Test_Static_Compress(t *testing.T) {

req := httptest.NewRequest(MethodGet, "/file", nil)
req.Header.Set("Accept-Encoding", algo)
resp, err := app.Test(req, 10*time.Second)
resp, err := app.Test(req, TestConfig{
Timeout: 10 * time.Second,
FailOnTimeout: true,
})

require.NoError(t, err, "app.Test(req)")
require.Equal(t, 200, resp.StatusCode, "Status code")
Expand Down
29 changes: 27 additions & 2 deletions docs/api/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,10 +641,10 @@ func (app *App) SetTLSHandler(tlsHandler *TLSHandler)

## Test

Testing your application is done with the `Test` method. Use this method for creating `_test.go` files or when you need to debug your routing logic. The default timeout is `1s`; to disable a timeout altogether, pass `-1` as the second argument.
Testing your application is done with the `Test` method. Use this method for creating `_test.go` files or when you need to debug your routing logic. The default timeout is `1s`; to disable a timeout altogether, pass a `TestConfig` struct with `Timeout: 0`.

```go title="Signature"
func (app *App) Test(req *http.Request, msTimeout ...int) (*http.Response, error)
func (app *App) Test(req *http.Request, config ...TestConfig) (*http.Response, error)
```

```go title="Example"
Expand Down Expand Up @@ -685,6 +685,31 @@ func main() {
}
```

If not provided, TestConfig is set to the following defaults:

```go title="Default TestConfig"
config := fiber.TestConfig{
Timeout: time.Second(),
FailOnTimeout: true,
}
```

:::caution

This is **not** the same as supplying an empty `TestConfig{}` to
`app.Test(), but rather be the equivalent of supplying:

```go title="Empty TestConfig"
cfg := fiber.TestConfig{
Timeout: 0,
FailOnTimeout: false,
}
```

This would make a Test that has no timeout.

:::

## Hooks

`Hooks` is a method to return the [hooks](./hooks.md) property.
Expand Down
65 changes: 64 additions & 1 deletion docs/whats_new.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ We have made several changes to the Fiber app, including:

### Methods changes

- Test -> timeout changed to 1 second
- Test -> Replaced timeout with a config parameter
- -1 represents no timeout -> 0 represents no timeout
- Listen -> has a config parameter
- Listener -> has a config parameter

Expand Down Expand Up @@ -184,6 +185,68 @@ To enable the routing changes above we had to slightly adjust the signature of t
+ Add(methods []string, path string, handler Handler, middleware ...Handler) Router
```

### Test Config

The `app.Test()` method now allows users to customize their test configurations:

<details>
<summary>Example</summary>

```go
// Create a test app with a handler to test
app := fiber.New()
app.Get("/", func(c fiber.Ctx) {
return c.SendString("hello world")
})

// Define the HTTP request and custom TestConfig to test the handler
req := httptest.NewRequest(MethodGet, "/", nil)
testConfig := fiber.TestConfig{
Timeout: 0,
FailOnTimeout: false,
}

// Test the handler using the request and testConfig
resp, err := app.Test(req, testConfig)
```

</details>

To provide configurable testing capabilities, we had to change
the signature of the `Test` method.

```diff
- Test(req *http.Request, timeout ...time.Duration) (*http.Response, error)
+ Test(req *http.Request, config ...fiber.TestConfig) (*http.Response, error)
```

The `TestConfig` struct provides the following configuration options:

- `Timeout`: The duration to wait before timing out the test. Use 0 for no timeout.
- `FailOnTimeout`: Controls the behavior when a timeout occurs:
- When true, the test will return an `os.ErrDeadlineExceeded` if the test exceeds the `Timeout` duration.
- When false, the test will return the partial response received before timing out.

If a custom `TestConfig` isn't provided, then the following will be used:

```go
testConfig := fiber.TestConfig{
Timeout: time.Second,
FailOnTimeout: true,
}
```

**Note:** Using this default is **NOT** the same as providing an empty `TestConfig` as an argument to `app.Test()`.

An empty `TestConfig` is the equivalent of:

```go
testConfig := fiber.TestConfig{
Timeout: 0,
FailOnTimeout: false,
}
```

---

## 🧠 Context
Expand Down
33 changes: 28 additions & 5 deletions helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,13 +613,36 @@ func isNoCache(cacheControl string) bool {
}

type testConn struct {
r bytes.Buffer
w bytes.Buffer
r bytes.Buffer
w bytes.Buffer
isClosed bool
sync.Mutex
}

func (c *testConn) Read(b []byte) (int, error) { return c.r.Read(b) } //nolint:wrapcheck // This must not be wrapped
func (c *testConn) Write(b []byte) (int, error) { return c.w.Write(b) } //nolint:wrapcheck // This must not be wrapped
func (*testConn) Close() error { return nil }
func (c *testConn) Read(b []byte) (int, error) {
c.Lock()
defer c.Unlock()

return c.r.Read(b) //nolint:wrapcheck // This must not be wrapped
}

func (c *testConn) Write(b []byte) (int, error) {
c.Lock()
defer c.Unlock()

if c.isClosed {
return 0, errors.New("testConn is closed")
}
return c.w.Write(b) //nolint:wrapcheck // This must not be wrapped
}

func (c *testConn) Close() error {
c.Lock()
defer c.Unlock()

c.isClosed = true
return nil
}

func (*testConn) LocalAddr() net.Addr { return &net.TCPAddr{Port: 0, Zone: "", IP: net.IPv4zero} }
func (*testConn) RemoteAddr() net.Addr { return &net.TCPAddr{Port: 0, Zone: "", IP: net.IPv4zero} }
Expand Down
42 changes: 42 additions & 0 deletions helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,48 @@ func Test_Utils_TestConn_Deadline(t *testing.T) {
require.NoError(t, conn.SetWriteDeadline(time.Time{}))
}

func Test_Utils_TestConn_ReadWrite(t *testing.T) {
t.Parallel()
conn := &testConn{}

// Verify read of request
_, err := conn.r.Write([]byte("Request"))
require.NoError(t, err)

req := make([]byte, 7)
_, err = conn.Read(req)
require.NoError(t, err)
require.Equal(t, []byte("Request"), req)

// Verify write of response
_, err = conn.Write([]byte("Response"))
require.NoError(t, err)

res := make([]byte, 8)
_, err = conn.w.Read(res)
require.NoError(t, err)
require.Equal(t, []byte("Response"), res)
}

func Test_Utils_TestConn_Closed_Write(t *testing.T) {
t.Parallel()
conn := &testConn{}

// Verify write of response
_, err := conn.Write([]byte("Response 1\n"))
require.NoError(t, err)

// Close early, write should fail
conn.Close() //nolint:errcheck, revive // It is fine to ignore the error here
_, err = conn.Write([]byte("Response 2\n"))
require.Error(t, err)

res := make([]byte, 11)
_, err = conn.w.Read(res)
require.NoError(t, err)
require.Equal(t, []byte("Response 1\n"), res)
}

func Test_Utils_IsNoCache(t *testing.T) {
t.Parallel()
testCases := []struct {
Expand Down
Loading

1 comment on commit f8b490f

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.50.

Benchmark suite Current: f8b490f Previous: 8c84b0f Ratio
Benchmark_Ctx_Send 6.812 ns/op 0 B/op 0 allocs/op 4.335 ns/op 0 B/op 0 allocs/op 1.57
Benchmark_Ctx_Send - ns/op 6.812 ns/op 4.335 ns/op 1.57
Benchmark_Utils_GetOffer/1_parameter 200.8 ns/op 0 B/op 0 allocs/op 131 ns/op 0 B/op 0 allocs/op 1.53
Benchmark_Utils_GetOffer/1_parameter - ns/op 200.8 ns/op 131 ns/op 1.53
Benchmark_Middleware_BasicAuth - B/op 80 B/op 48 B/op 1.67
Benchmark_Middleware_BasicAuth - allocs/op 5 allocs/op 3 allocs/op 1.67
Benchmark_Middleware_BasicAuth_Upper - B/op 80 B/op 48 B/op 1.67
Benchmark_Middleware_BasicAuth_Upper - allocs/op 5 allocs/op 3 allocs/op 1.67
Benchmark_CORS_NewHandler - B/op 16 B/op 0 B/op +∞
Benchmark_CORS_NewHandler - allocs/op 1 allocs/op 0 allocs/op +∞
Benchmark_CORS_NewHandlerSingleOrigin - B/op 16 B/op 0 B/op +∞
Benchmark_CORS_NewHandlerSingleOrigin - allocs/op 1 allocs/op 0 allocs/op +∞
Benchmark_CORS_NewHandlerPreflight - B/op 104 B/op 0 B/op +∞
Benchmark_CORS_NewHandlerPreflight - allocs/op 5 allocs/op 0 allocs/op +∞
Benchmark_CORS_NewHandlerPreflightSingleOrigin - B/op 104 B/op 0 B/op +∞
Benchmark_CORS_NewHandlerPreflightSingleOrigin - allocs/op 5 allocs/op 0 allocs/op +∞
Benchmark_CORS_NewHandlerPreflightWildcard - B/op 104 B/op 0 B/op +∞
Benchmark_CORS_NewHandlerPreflightWildcard - allocs/op 5 allocs/op 0 allocs/op +∞
Benchmark_Middleware_CSRF_GenerateToken - B/op 517 B/op 327 B/op 1.58
Benchmark_Middleware_CSRF_GenerateToken - allocs/op 10 allocs/op 6 allocs/op 1.67

This comment was automatically generated by workflow using github-action-benchmark.

Please sign in to comment.