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  }