Skip to content

Commit f8b490f

Browse files
grivera64gaby
andauthored
🔥 Feature: Add TestConfig to app.Test() for configurable testing (#3161)
* 🔥 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]>
1 parent b6ecd63 commit f8b490f

File tree

14 files changed

+328
-56
lines changed

14 files changed

+328
-56
lines changed

app.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import (
1414
"encoding/xml"
1515
"errors"
1616
"fmt"
17+
"io"
1718
"net"
1819
"net/http"
1920
"net/http/httputil"
21+
"os"
2022
"reflect"
2123
"strconv"
2224
"strings"
@@ -901,13 +903,33 @@ func (app *App) Hooks() *Hooks {
901903
return app.hooks
902904
}
903905

906+
// TestConfig is a struct holding Test settings
907+
type TestConfig struct {
908+
// Timeout defines the maximum duration a
909+
// test can run before timing out.
910+
// Default: time.Second
911+
Timeout time.Duration
912+
913+
// FailOnTimeout specifies whether the test
914+
// should return a timeout error if the HTTP response
915+
// exceeds the Timeout duration.
916+
// Default: true
917+
FailOnTimeout bool
918+
}
919+
904920
// Test is used for internal debugging by passing a *http.Request.
905-
// Timeout is optional and defaults to 1s, -1 will disable it completely.
906-
func (app *App) Test(req *http.Request, timeout ...time.Duration) (*http.Response, error) {
907-
// Set timeout
908-
to := 1 * time.Second
909-
if len(timeout) > 0 {
910-
to = timeout[0]
921+
// Config is optional and defaults to a 1s error on timeout,
922+
// 0 timeout will disable it completely.
923+
func (app *App) Test(req *http.Request, config ...TestConfig) (*http.Response, error) {
924+
// Default config
925+
cfg := TestConfig{
926+
Timeout: time.Second,
927+
FailOnTimeout: true,
928+
}
929+
930+
// Override config if provided
931+
if len(config) > 0 {
932+
cfg = config[0]
911933
}
912934

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

948970
// Wait for callback
949-
if to >= 0 {
971+
if cfg.Timeout > 0 {
950972
// With timeout
951973
select {
952974
case err = <-channel:
953-
case <-time.After(to):
954-
return nil, fmt.Errorf("test: timeout error after %s", to)
975+
case <-time.After(cfg.Timeout):
976+
conn.Close() //nolint:errcheck, revive // It is fine to ignore the error here
977+
if cfg.FailOnTimeout {
978+
return nil, os.ErrDeadlineExceeded
979+
}
955980
}
956981
} else {
957982
// Without timeout
@@ -969,6 +994,9 @@ func (app *App) Test(req *http.Request, timeout ...time.Duration) (*http.Respons
969994
// Convert raw http response to *http.Response
970995
res, err := http.ReadResponse(buffer, req)
971996
if err != nil {
997+
if errors.Is(err, io.ErrUnexpectedEOF) {
998+
return nil, errors.New("test: got empty response")
999+
}
9721000
return nil, fmt.Errorf("failed to read response: %w", err)
9731001
}
9741002

app_test.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"net"
1717
"net/http"
1818
"net/http/httptest"
19+
"os"
1920
"reflect"
2021
"regexp"
2122
"runtime"
@@ -1124,7 +1125,9 @@ func Test_Test_Timeout(t *testing.T) {
11241125

11251126
app.Get("/", testEmptyHandler)
11261127

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

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

1136-
_, err = app.Test(httptest.NewRequest(MethodGet, "/timeout", nil), 20*time.Millisecond)
1139+
_, err = app.Test(httptest.NewRequest(MethodGet, "/timeout", nil), TestConfig{
1140+
Timeout: 20 * time.Millisecond,
1141+
FailOnTimeout: true,
1142+
})
11371143
require.Error(t, err, "app.Test(req)")
11381144
}
11391145

@@ -1432,7 +1438,9 @@ func Test_App_Test_no_timeout_infinitely(t *testing.T) {
14321438
})
14331439

14341440
req := httptest.NewRequest(MethodGet, "/", nil)
1435-
_, err = app.Test(req, -1)
1441+
_, err = app.Test(req, TestConfig{
1442+
Timeout: 0,
1443+
})
14361444
}()
14371445

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

1463-
_, err := app.Test(httptest.NewRequest(MethodGet, "/", nil), 100*time.Millisecond)
1464-
require.Equal(t, errors.New("test: timeout error after 100ms"), err)
1471+
_, err := app.Test(httptest.NewRequest(MethodGet, "/", nil), TestConfig{
1472+
Timeout: 100 * time.Millisecond,
1473+
FailOnTimeout: true,
1474+
})
1475+
require.Equal(t, os.ErrDeadlineExceeded, err)
1476+
}
1477+
1478+
func Test_App_Test_timeout_empty_response(t *testing.T) {
1479+
t.Parallel()
1480+
1481+
app := New()
1482+
app.Get("/", func(_ Ctx) error {
1483+
time.Sleep(1 * time.Second)
1484+
return nil
1485+
})
1486+
1487+
_, err := app.Test(httptest.NewRequest(MethodGet, "/", nil), TestConfig{
1488+
Timeout: 100 * time.Millisecond,
1489+
FailOnTimeout: false,
1490+
})
1491+
require.Equal(t, errors.New("test: got empty response"), err)
14651492
}
14661493

14671494
func Test_App_SetTLSHandler(t *testing.T) {

ctx_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3243,7 +3243,10 @@ func Test_Static_Compress(t *testing.T) {
32433243

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

32483251
require.NoError(t, err, "app.Test(req)")
32493252
require.Equal(t, 200, resp.StatusCode, "Status code")

docs/api/app.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -641,10 +641,10 @@ func (app *App) SetTLSHandler(tlsHandler *TLSHandler)
641641

642642
## Test
643643

644-
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.
644+
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`.
645645

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

650650
```go title="Example"
@@ -685,6 +685,31 @@ func main() {
685685
}
686686
```
687687

688+
If not provided, TestConfig is set to the following defaults:
689+
690+
```go title="Default TestConfig"
691+
config := fiber.TestConfig{
692+
Timeout: time.Second(),
693+
FailOnTimeout: true,
694+
}
695+
```
696+
697+
:::caution
698+
699+
This is **not** the same as supplying an empty `TestConfig{}` to
700+
`app.Test(), but rather be the equivalent of supplying:
701+
702+
```go title="Empty TestConfig"
703+
cfg := fiber.TestConfig{
704+
Timeout: 0,
705+
FailOnTimeout: false,
706+
}
707+
```
708+
709+
This would make a Test that has no timeout.
710+
711+
:::
712+
688713
## Hooks
689714

690715
`Hooks` is a method to return the [hooks](./hooks.md) property.

docs/whats_new.md

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ We have made several changes to the Fiber app, including:
7575

7676
### Methods changes
7777

78-
- Test -> timeout changed to 1 second
78+
- Test -> Replaced timeout with a config parameter
79+
- -1 represents no timeout -> 0 represents no timeout
7980
- Listen -> has a config parameter
8081
- Listener -> has a config parameter
8182

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

188+
### Test Config
189+
190+
The `app.Test()` method now allows users to customize their test configurations:
191+
192+
<details>
193+
<summary>Example</summary>
194+
195+
```go
196+
// Create a test app with a handler to test
197+
app := fiber.New()
198+
app.Get("/", func(c fiber.Ctx) {
199+
return c.SendString("hello world")
200+
})
201+
202+
// Define the HTTP request and custom TestConfig to test the handler
203+
req := httptest.NewRequest(MethodGet, "/", nil)
204+
testConfig := fiber.TestConfig{
205+
Timeout: 0,
206+
FailOnTimeout: false,
207+
}
208+
209+
// Test the handler using the request and testConfig
210+
resp, err := app.Test(req, testConfig)
211+
```
212+
213+
</details>
214+
215+
To provide configurable testing capabilities, we had to change
216+
the signature of the `Test` method.
217+
218+
```diff
219+
- Test(req *http.Request, timeout ...time.Duration) (*http.Response, error)
220+
+ Test(req *http.Request, config ...fiber.TestConfig) (*http.Response, error)
221+
```
222+
223+
The `TestConfig` struct provides the following configuration options:
224+
225+
- `Timeout`: The duration to wait before timing out the test. Use 0 for no timeout.
226+
- `FailOnTimeout`: Controls the behavior when a timeout occurs:
227+
- When true, the test will return an `os.ErrDeadlineExceeded` if the test exceeds the `Timeout` duration.
228+
- When false, the test will return the partial response received before timing out.
229+
230+
If a custom `TestConfig` isn't provided, then the following will be used:
231+
232+
```go
233+
testConfig := fiber.TestConfig{
234+
Timeout: time.Second,
235+
FailOnTimeout: true,
236+
}
237+
```
238+
239+
**Note:** Using this default is **NOT** the same as providing an empty `TestConfig` as an argument to `app.Test()`.
240+
241+
An empty `TestConfig` is the equivalent of:
242+
243+
```go
244+
testConfig := fiber.TestConfig{
245+
Timeout: 0,
246+
FailOnTimeout: false,
247+
}
248+
```
249+
187250
---
188251

189252
## 🧠 Context

helpers.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -613,13 +613,36 @@ func isNoCache(cacheControl string) bool {
613613
}
614614

615615
type testConn struct {
616-
r bytes.Buffer
617-
w bytes.Buffer
616+
r bytes.Buffer
617+
w bytes.Buffer
618+
isClosed bool
619+
sync.Mutex
618620
}
619621

620-
func (c *testConn) Read(b []byte) (int, error) { return c.r.Read(b) } //nolint:wrapcheck // This must not be wrapped
621-
func (c *testConn) Write(b []byte) (int, error) { return c.w.Write(b) } //nolint:wrapcheck // This must not be wrapped
622-
func (*testConn) Close() error { return nil }
622+
func (c *testConn) Read(b []byte) (int, error) {
623+
c.Lock()
624+
defer c.Unlock()
625+
626+
return c.r.Read(b) //nolint:wrapcheck // This must not be wrapped
627+
}
628+
629+
func (c *testConn) Write(b []byte) (int, error) {
630+
c.Lock()
631+
defer c.Unlock()
632+
633+
if c.isClosed {
634+
return 0, errors.New("testConn is closed")
635+
}
636+
return c.w.Write(b) //nolint:wrapcheck // This must not be wrapped
637+
}
638+
639+
func (c *testConn) Close() error {
640+
c.Lock()
641+
defer c.Unlock()
642+
643+
c.isClosed = true
644+
return nil
645+
}
623646

624647
func (*testConn) LocalAddr() net.Addr { return &net.TCPAddr{Port: 0, Zone: "", IP: net.IPv4zero} }
625648
func (*testConn) RemoteAddr() net.Addr { return &net.TCPAddr{Port: 0, Zone: "", IP: net.IPv4zero} }

helpers_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,48 @@ func Test_Utils_TestConn_Deadline(t *testing.T) {
514514
require.NoError(t, conn.SetWriteDeadline(time.Time{}))
515515
}
516516

517+
func Test_Utils_TestConn_ReadWrite(t *testing.T) {
518+
t.Parallel()
519+
conn := &testConn{}
520+
521+
// Verify read of request
522+
_, err := conn.r.Write([]byte("Request"))
523+
require.NoError(t, err)
524+
525+
req := make([]byte, 7)
526+
_, err = conn.Read(req)
527+
require.NoError(t, err)
528+
require.Equal(t, []byte("Request"), req)
529+
530+
// Verify write of response
531+
_, err = conn.Write([]byte("Response"))
532+
require.NoError(t, err)
533+
534+
res := make([]byte, 8)
535+
_, err = conn.w.Read(res)
536+
require.NoError(t, err)
537+
require.Equal(t, []byte("Response"), res)
538+
}
539+
540+
func Test_Utils_TestConn_Closed_Write(t *testing.T) {
541+
t.Parallel()
542+
conn := &testConn{}
543+
544+
// Verify write of response
545+
_, err := conn.Write([]byte("Response 1\n"))
546+
require.NoError(t, err)
547+
548+
// Close early, write should fail
549+
conn.Close() //nolint:errcheck, revive // It is fine to ignore the error here
550+
_, err = conn.Write([]byte("Response 2\n"))
551+
require.Error(t, err)
552+
553+
res := make([]byte, 11)
554+
_, err = conn.w.Read(res)
555+
require.NoError(t, err)
556+
require.Equal(t, []byte("Response 1\n"), res)
557+
}
558+
517559
func Test_Utils_IsNoCache(t *testing.T) {
518560
t.Parallel()
519561
testCases := []struct {

0 commit comments

Comments
 (0)