goyave.dev/goyave/v4@v4.4.11/testsuite.go (about) 1 package goyave 2 3 import ( 4 "bytes" 5 "context" 6 "crypto/tls" 7 "encoding/json" 8 "io" 9 "mime/multipart" 10 "net/http" 11 "net/http/httptest" 12 "os" 13 "path" 14 "path/filepath" 15 "runtime" 16 "sync" 17 "testing" 18 "time" 19 20 "gorm.io/gorm" 21 "goyave.dev/goyave/v4/database" 22 "goyave.dev/goyave/v4/util/fsutil" 23 24 "github.com/stretchr/testify/assert" 25 "github.com/stretchr/testify/suite" 26 testify "github.com/stretchr/testify/suite" 27 "goyave.dev/goyave/v4/config" 28 "goyave.dev/goyave/v4/lang" 29 ) 30 31 // ITestSuite is an extension of testify's Suite for 32 // Goyave-specific testing. 33 type ITestSuite interface { 34 RunServer(func(*Router), func()) 35 Timeout() time.Duration 36 SetTimeout(time.Duration) 37 Middleware(Middleware, *Request, Handler) *http.Response 38 39 Get(string, map[string]string) (*http.Response, error) 40 Post(string, map[string]string, io.Reader) (*http.Response, error) 41 Put(string, map[string]string, io.Reader) (*http.Response, error) 42 Patch(string, map[string]string, io.Reader) (*http.Response, error) 43 Delete(string, map[string]string, io.Reader) (*http.Response, error) 44 Request(string, string, map[string]string, io.Reader) (*http.Response, error) 45 46 T() *testing.T 47 SetT(*testing.T) 48 SetS(suite suite.TestingSuite) 49 50 GetBody(*http.Response) []byte 51 GetJSONBody(*http.Response, interface{}) error 52 CreateTestFiles(paths ...string) []fsutil.File 53 WriteFile(*multipart.Writer, string, string, string) 54 WriteField(*multipart.Writer, string, string) 55 CreateTestRequest(*http.Request) *Request 56 CreateTestResponse(http.ResponseWriter) *Response 57 getHTTPClient() *http.Client 58 } 59 60 // TestSuite is an extension of testify's Suite for 61 // Goyave-specific testing. 62 type TestSuite struct { 63 testify.Suite 64 httpClient *http.Client 65 timeout time.Duration // Timeout for functional tests 66 mu sync.Mutex 67 } 68 69 var _ ITestSuite = (*TestSuite)(nil) // implements ITestSuite 70 71 // Use a mutex to avoid parallel goyave test suites to be run concurrently. 72 var mu sync.Mutex 73 74 // Timeout get the timeout for test failure when using RunServer or requests. 75 func (s *TestSuite) Timeout() time.Duration { 76 s.mu.Lock() 77 defer s.mu.Unlock() 78 return s.timeout 79 } 80 81 // SetTimeout set the timeout for test failure when using RunServer or requests. 82 func (s *TestSuite) SetTimeout(timeout time.Duration) { 83 s.mu.Lock() 84 s.timeout = timeout 85 s.mu.Unlock() 86 } 87 88 // CreateTestRequest create a "goyave.Request" from the given raw request. 89 // This function is aimed at making it easier to unit test Requests. 90 // 91 // If passed request is "nil", a default GET request to "/" is used. 92 // 93 // rawRequest := httptest.NewRequest("GET", "/test-route", nil) 94 // rawRequest.Header.Set("Content-Type", "application/json") 95 // request := goyave.CreateTestRequest(rawRequest) 96 // request.Lang = "en-US" 97 // request.Data = map[string]interface{}{"field": "value"} 98 func (s *TestSuite) CreateTestRequest(rawRequest *http.Request) *Request { 99 if rawRequest == nil { 100 rawRequest = httptest.NewRequest("GET", "/", nil) 101 } 102 return &Request{ 103 httpRequest: rawRequest, 104 route: nil, 105 Data: nil, 106 Rules: nil, 107 Lang: "en-US", 108 Params: map[string]string{}, 109 Extra: map[string]interface{}{}, 110 } 111 } 112 113 // CreateTestResponse create an empty response with the given response writer. 114 // This function is aimed at making it easier to unit test Responses. 115 // 116 // writer := httptest.NewRecorder() 117 // response := suite.CreateTestResponse(writer) 118 // response.Status(http.StatusNoContent) 119 // result := writer.Result() 120 // fmt.Println(result.StatusCode) // 204 121 func (s *TestSuite) CreateTestResponse(recorder http.ResponseWriter) *Response { 122 return newResponse(recorder, nil) 123 } 124 125 // CreateTestResponseWithRequest create an empty response with the given response writer HTTP request. 126 // This function is aimed at making it easier to unit test Responses needing the raw request's 127 // information, such as redirects. 128 // 129 // writer := httptest.NewRecorder() 130 // rawRequest := httptest.NewRequest("POST", "/test-route", strings.NewReader("body")) 131 // response := suite.CreateTestResponseWithRequest(writer, rawRequest) 132 // response.Status(http.StatusNoContent) 133 // result := writer.Result() 134 // fmt.Println(result.StatusCode) // 204 135 func (s *TestSuite) CreateTestResponseWithRequest(recorder http.ResponseWriter, rawRequest *http.Request) *Response { 136 return newResponse(recorder, rawRequest) 137 } 138 139 // RunServer start the application and run the given functional test procedure. 140 // 141 // This function is the equivalent of "goyave.Start()". 142 // The test fails if the suite's timeout is exceeded. 143 // The server automatically shuts down when the function ends. 144 // This function is synchronized, that means that the server is properly stopped 145 // when the function returns. 146 func (s *TestSuite) RunServer(routeRegistrer func(*Router), procedure func()) { 147 c := make(chan bool, 1) 148 c2 := make(chan bool, 1) 149 ctx, cancel := context.WithTimeout(context.Background(), s.Timeout()) 150 defer cancel() 151 152 RegisterStartupHook(func() { 153 procedure() 154 if ctx.Err() == nil { 155 Stop() 156 c <- true 157 } 158 }) 159 160 go func() { 161 if err := Start(routeRegistrer); err != nil { 162 s.Fail(err.Error()) 163 c <- true 164 } 165 c2 <- true 166 }() 167 168 select { 169 case <-ctx.Done(): 170 s.Fail("Timeout exceeded in goyave.TestSuite.RunServer") 171 Stop() 172 case sig := <-c: 173 s.True(sig) 174 } 175 ClearStartupHooks() 176 ClearShutdownHooks() 177 <-c2 178 } 179 180 // Middleware executes the given middleware and returns the HTTP response. 181 // Core middleware (recovery, parsing and language) is not executed. 182 func (s *TestSuite) Middleware(middleware Middleware, request *Request, procedure Handler) *http.Response { 183 cacheCriticalConfig() 184 recorder := httptest.NewRecorder() 185 response := s.CreateTestResponse(recorder) 186 router := NewRouter() 187 router.Middleware(middleware) 188 middleware(procedure)(response, request) 189 router.finalize(response, request) 190 191 return recorder.Result() 192 } 193 194 // Get execute a GET request on the given route. 195 // Headers are optional. 196 func (s *TestSuite) Get(route string, headers map[string]string) (*http.Response, error) { 197 return s.Request(http.MethodGet, route, headers, nil) 198 } 199 200 // Post execute a POST request on the given route. 201 // Headers and body are optional. 202 func (s *TestSuite) Post(route string, headers map[string]string, body io.Reader) (*http.Response, error) { 203 return s.Request(http.MethodPost, route, headers, body) 204 } 205 206 // Put execute a PUT request on the given route. 207 // Headers and body are optional. 208 func (s *TestSuite) Put(route string, headers map[string]string, body io.Reader) (*http.Response, error) { 209 return s.Request(http.MethodPut, route, headers, body) 210 } 211 212 // Patch execute a PATCH request on the given route. 213 // Headers and body are optional. 214 func (s *TestSuite) Patch(route string, headers map[string]string, body io.Reader) (*http.Response, error) { 215 return s.Request(http.MethodPatch, route, headers, body) 216 } 217 218 // Delete execute a DELETE request on the given route. 219 // Headers and body are optional. 220 func (s *TestSuite) Delete(route string, headers map[string]string, body io.Reader) (*http.Response, error) { 221 return s.Request(http.MethodDelete, route, headers, body) 222 } 223 224 // Request execute a request on the given route. 225 // Headers and body are optional. 226 func (s *TestSuite) Request(method, route string, headers map[string]string, body io.Reader) (*http.Response, error) { 227 req, err := http.NewRequest(method, BaseURL()+route, body) 228 if err != nil { 229 return nil, err 230 } 231 req.Close = true 232 for k, v := range headers { 233 req.Header.Set(k, v) 234 } 235 return s.getHTTPClient().Do(req) 236 } 237 238 // GetBody read the whole body of a response. 239 // If read failed, test fails and return empty byte slice. 240 func (s *TestSuite) GetBody(response *http.Response) []byte { 241 body, err := io.ReadAll(response.Body) 242 if err != nil { 243 s.Fail("Couldn't read response body", err) 244 } 245 return body 246 } 247 248 // GetJSONBody read the whole body of a response and decode it as JSON. 249 // If read or decode failed, test fails. 250 func (s *TestSuite) GetJSONBody(response *http.Response, data interface{}) error { 251 err := json.NewDecoder(response.Body).Decode(data) 252 if err != nil { 253 s.Fail("Couldn't read response body as JSON", err) 254 return err 255 } 256 return nil 257 } 258 259 // CreateTestFiles create a slice of "fsutil.File" from the given paths. 260 // Files are passed to a temporary http request and parsed as Multipart form, 261 // to reproduce the way files are obtained in real scenarios. 262 func (s *TestSuite) CreateTestFiles(paths ...string) []fsutil.File { 263 body := &bytes.Buffer{} 264 writer := multipart.NewWriter(body) 265 for _, p := range paths { 266 s.WriteFile(writer, p, "file", filepath.Base(p)) 267 } 268 err := writer.Close() 269 if err != nil { 270 panic(err) 271 } 272 273 req, _ := http.NewRequest("POST", "/test-route", body) 274 req.Header.Set("Content-Type", writer.FormDataContentType()) 275 if err := req.ParseMultipartForm(10 << 20); err != nil { 276 panic(err) 277 } 278 return fsutil.ParseMultipartFiles(req, "file") 279 } 280 281 // WriteFile write a file to the given writer. 282 // This function is handy for file upload testing. 283 // The test fails if an error occurred. 284 func (s *TestSuite) WriteFile(writer *multipart.Writer, path, fieldName, fileName string) { 285 file, err := os.Open(path) 286 if err != nil { 287 s.Fail(err.Error()) 288 return 289 } 290 defer file.Close() 291 part, err := writer.CreateFormFile(fieldName, fileName) 292 if err != nil { 293 s.Fail(err.Error()) 294 return 295 } 296 _, err = io.Copy(part, file) 297 if err != nil { 298 s.Fail(err.Error()) 299 } 300 } 301 302 // WriteField create and write a new multipart form field. 303 // The test fails if the field couldn't be written. 304 func (s *TestSuite) WriteField(writer *multipart.Writer, fieldName, value string) { 305 if err := writer.WriteField(fieldName, value); err != nil { 306 s.Fail(err.Error()) 307 } 308 } 309 310 // getHTTPClient get suite's http client or create it if it doesn't exist yet. 311 // The HTTP client is created with a timeout, disabled redirect and disabled TLS cert checking. 312 func (s *TestSuite) getHTTPClient() *http.Client { 313 config := &tls.Config{ 314 InsecureSkipVerify: true, 315 } 316 317 if s.httpClient == nil { 318 s.httpClient = &http.Client{ 319 Timeout: s.Timeout(), 320 Transport: &http.Transport{TLSClientConfig: config}, 321 CheckRedirect: func(req *http.Request, via []*http.Request) error { 322 return http.ErrUseLastResponse 323 }, 324 } 325 } 326 327 return s.httpClient 328 } 329 330 // ClearDatabase delete all records in all tables. 331 // This function only clears the tables of registered models, ignoring 332 // models implementing `database.IView`. 333 func (s *TestSuite) ClearDatabase() { 334 db := database.GetConnection() 335 for _, m := range database.GetRegisteredModels() { 336 if view, ok := m.(database.IView); ok && view.IsView() { 337 continue 338 } 339 tx := db.Unscoped().Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(m) 340 if tx.Error != nil { 341 panic(tx.Error) 342 } 343 } 344 } 345 346 // ClearDatabaseTables drop all tables. 347 // This function only clears the tables of registered models. 348 func (s *TestSuite) ClearDatabaseTables() { 349 db := database.GetConnection() 350 for _, m := range database.GetRegisteredModels() { 351 if err := db.Migrator().DropTable(m); err != nil { 352 panic(err) 353 } 354 } 355 } 356 357 // RunTest run a test suite with prior initialization of a test environment. 358 // The GOYAVE_ENV environment variable is automatically set to "test" and restored 359 // to its original value at the end of the test run. 360 // All tests are run using your project's root as working directory. This directory is determined 361 // by the presence of a "go.mod" file. 362 func RunTest(t *testing.T, suite ITestSuite) bool { 363 mu.Lock() 364 defer mu.Unlock() 365 if suite.Timeout() == 0 { 366 suite.SetTimeout(5 * time.Second) 367 } 368 _, ok := os.LookupEnv("GOYAVE_ENV") 369 if !ok { 370 os.Setenv("GOYAVE_ENV", "test") 371 defer os.Unsetenv("GOYAVE_ENV") 372 } 373 setRootWorkingDirectory() 374 375 if !config.IsLoaded() { 376 if err := config.Load(); err != nil { 377 return assert.Fail(t, "Failed to load config", err) 378 } 379 } 380 defer config.Clear() 381 lang.LoadDefault() 382 lang.LoadAllAvailableLanguages() 383 384 if config.GetBool("database.autoMigrate") && config.GetString("database.connection") != "none" { 385 database.Migrate() 386 } 387 388 testify.Run(t, suite) 389 390 database.Close() 391 return !t.Failed() 392 } 393 394 func setRootWorkingDirectory() { 395 sep := string(os.PathSeparator) 396 _, filename, _, _ := runtime.Caller(2) 397 directory := path.Dir(filename) + sep 398 for !fsutil.FileExists(directory + sep + "go.mod") { 399 directory += ".." + sep 400 if !fsutil.IsDirectory(directory) { 401 panic("Couldn't find project's root directory.") 402 } 403 } 404 if err := os.Chdir(directory); err != nil { 405 panic(err) 406 } 407 }