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