Testing
Introduction
Goyave is test framework agnostic. You can use any testing library you want and won't be limited. To make testing easier, Goyave provides some test utilities located in the goyave.dev/goyave/v5/util/testutil
package.
Test servers
*testutil.TestServer
is a wrapper around *goyave.Server
that provides useful functions for testing.
- Create test requests
- Create test responses
- Test an endpoint without starting the server and listen on a network port
- Test middleware
- Discard logs to make them silent by default
- Load the config from the project's root directory easily
- Run the tests inside a transaction that can be easily rolled back
Create a new test server using NewTestServer()
or NewTestServerWithOptions()
. You can then use this server as root component for your controllers, middleware, and other components.
func TestSomething(t *testing.T) {
server := testutil.NewTestServer(t, "config.test.json")
// or
cfg := config.LoadDefault()
cfg.Set("app.debug", false)
opts := goyave.Options{
Config: cfg,
//...
}
server := testutil.NewTestServerWithOptions(t, opts)
//...
}
INFO
- By default, the language files are loaded from the project's root directory, identified by the presence of a
go.mod
file. - The configuration path given in
NewTestServer()
is relative to the project's root directory. - Using
NewTestServerWithOptions()
without specifying a configuration in the options will attempt to load the configuration file relative to the current package (not relative to the project's root). - Test servers are concurrently unsafe. Don't use one single instance across multiple tests.
Transaction mode
server.Transaction()
replaces the root DB instance of the server with a transaction. This way, all database requests using the server's DB will be executed inside of it and won't have any side-effect or clash with concurrent tests running in parallel.
A rollback
function is returned. It is advised to call it inside a test cleanup. The original database instance is restored after the transaction is rolled back.
func TestSomething(t *testing.T) {
server := testutil.NewTestServer(t, "config.test.json")
rollback := server.Transaction(&sql.TxOptions{})
t.Cleanup(rollback)
//...
}
Mocking database
If you want to use database mocks, for example with the go-sqlmock
library, you can force a test server to use a custom DB dialector with server.ReplaceDB()
.
First create your mock, then use it to create the Gorm dialector of your choice (here Postgres).
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/postgres"
"goyave.dev/goyave/v5"
"goyave.dev/goyave/v5/config"
"goyave.dev/goyave/v5/util/testutil"
)
func TestMockDB(t *testing.T) {
// Important! Disable prepared statements for mock expectations to work
cfg := config.LoadDefault()
cfg.Set("database.config.prepareStmt", false)
server := testutil.NewTestServerWithOptions(t, goyave.Options{Config: cfg})
mockDB, mock, err := sqlmock.New()
if err != nil {
panic(err)
}
dialector := postgres.New(postgres.Config{
DSN: "mock_db",
DriverName: "postgres",
Conn: mockDB,
PreferSimpleProtocol: true,
})
err = server.ReplaceDB(dialector)
//...
mock.ExpectClose()
}
TIP
Test servers automatically close the database in a test cleanup hook. If you are using go-sqlmock
, this will generate an error for unexpected Close
unless you add mock.ExpectClose()
at the very end of your test.
Logs
The default logger for test servers is slog.DiscardLogger()
, which outputs to io.Discard
, making logs silent.
You may want to print logs coming from your tests for functional reasons or for debugging. testutil.LogWriter
is an implementation of io.Writer
that redirects logs to testing.T.Log()
for better readability.
func TestSomething(t *testing.T) {
opts := goyave.Options{
Logger: slog.New(slog.NewHandler(true, &testutil.LogWriter{t: t})),
}
server := testutil.NewTestServerWithOptions(t, opts)
//...
}
HTTP Tests
You may want to write tests that simulate how a client would interact with your API through HTTP calls. In order to do so, use the test server's TestRequest(*http.Request)
method. It will execute the request all the way from the router's ServeHTTP()
implementation. Therefore, the request's lifecycle will be executed from start to finish.
For this type of tests, it is advised to mock your services. In the following example, we will test the "show" route for a user, which is supposed to return a user by its ID.
// http/controller/user/user_test.go
package user
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"goyave.dev/goyave/v5"
"goyave.dev/goyave/v5/config"
"goyave.dev/goyave/v5/util/testutil"
"my-project/dto"
"my-project/service"
)
type MockService struct{}
func (*MockService) First(_ context.Context, id uint) (*dto.User, error) {
return &dto.User{
ID: id,
Name: "John Doe",
Email: "johndoe@example.org",
}, nil
}
func (*MockService) Name() string {
return service.User
}
func TestShowUser(t *testing.T) {
server := testutil.NewTestServerWithOptions(t, goyave.Options{Config: config.LoadDefault()})
server.RegisterService(&MockService{})
server.RegisterRoutes(func(_ *goyave.Server, r *goyave.Router) {
r.Controller(&Controller{})
// The controller registers /users/{userID:[0-9+]}
})
request := httptest.NewRequest(http.MethodGet, "/users/123", nil)
response := server.TestRequest(request)
defer response.Body.Close()
//...
}
TIP
You can use testutil.ToJSON()
to quickly marshal anything and create a reader from the result that can be used as your test request's body. Don't forget to set the Content-Type: application/json
header to your request.
JSON responses
To make testing JSON response easier, testutil.ReadJSONBody[T](io.Reader)
helps you unmarshal a response's body into the type of your choice in a neat one-liner:
user, err := testutil.ReadJSONBody[*dto.User](response.Body)
You can then easily make assertions on the returned user
DTO to check if it meets expectations. If you prefer, you can also use maps instead of structures. But as your controllers should return marshaled DTOs, you should expect a response body that correctly unmarshals into the same DTO type.
Testing handlers
You can test handlers without having to simulate an entire HTTP request by generating a test *goyave.Request
and *goyave.Response
.
func TestShowUser(t *testing.T) {
cfg := config.LoadDefault()
server := testutil.NewTestServerWithOptions(t, goyave.Options{Config: cfg})
request := server.NewTestRequest(http.MethodGet, "/users/123", nil)
request.RouteParams["userID"] = "123"
response, recorder := server.NewTestResponse(request)
ctrl := &Controller{}
ctrl.Init(server.Server)
ctrl.Show(response, request)
result := recorder.Result()
defer result.Body.Close()
user, err := testutil.ReadJSONBody[*dto.User](result.Body)
}
INFO
If you are not using a test server, you can generate your request and response with testutil.NewTestRequest()
and testutil.NewTestResponse()
.
server.NewTestRequest()
automatically sets the request'sLang
to the default server language. If you are usingtestutil.NewTestRequest()
, the request'sLang
will not be set.*goyave.Response
requires a*goyave.Server
in order to work.testutil.NewTestResponse()
will create a temporary test server using the default configuration, used only for this*goyave.Response
instance.
Testing middleware
You can unit-test middleware using server.TestMiddleware()
. This function executes the given request and returns the response. The given procedure
callback is the next
handler passed to the middleware being tested, and can be used to make assertions. Remember that if your middleware is blocking, the callback won't be called. The request will go through the entire lifecycle like a regular request, and the middleware will be initialized automatically.
func TestMiddleware(t *testing.T) {
server := testutil.NewTestServerWithOptions(t, goyave.Options{Config: config.LoadDefault()})
request := server.NewTestRequest(http.MethodGet, "/path", nil)
response := server.TestMiddleware(&CustomMiddleware{}, request, func(response *goyave.Response, _ *goyave.Request) {
// The middleware passed
// ...
response.Status(http.StatusOK)
})
defer response.Body.Close()
//...
}
INFO
Note that the given request is cloned when using TestMiddleware
. If the middleware alters the request object, these changes won't be reflected on the input request. You can do your assertions inside the procedure
.
Multipart and file upload
You may need to test requests requiring file uploads. The best way to do this is using Go's multipart.Writer
. Adding files to such forms is made easier by testutil.WriteMultipartFile()
.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("textField", "value")
err := testutil.WriteMultipartFile(writer, &osfs.FS{}, "test_file.txt", "fileField", "test_file.txt")
if err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/file-upload", body)
// Don't forget to set the "Content-Type" header!
request.Header.Set("Content-Type", writer.FormDataContentType())
//...
TIP
testutil.WriteMultipartFile()
and all file-related features accept file systems. Here, &osfs.FS{}
represents the local OS file system.
If you want to use files with NewTestRequest()
, you will have to generate []fsutil.File
using testutil.CreateTestFiles()
. This function will create the files in the same way they are obtained in real scenarios.
request := testutil.NewTestRequest(http.MethodPost, "/file-upload", nil)
files, err := testutil.CreateTestFiles(&osfs.FS{}, "file_1.txt", "file_2.txt")
if err != nil {
t.Fatal(err)
}
request.Data = map[string]any{
"files": files,
}
INFO
testutil.CreateTestFiles()
paths are relative to the caller, not relative to the project's root directory.
Factories
Factories help you generate records for testing purposes, and save them to a database easily.
A factory uses a generator function, which will create one random record of the desired model. You can use any fake data generator library. In this example, we are using go-faker.
Generator functions are written inside the database/seed
package.
// database/seed/user.go
package seed
import (
"github.com/go-faker/faker/v4"
"github.com/go-faker/faker/v4/pkg/options"
"my-project/database/model"
)
func UserGenerator() *model.User {
user := &model.User{}
user.Name = faker.Name()
user.Email = faker.Email(options.WithGenerateUniqueValues(true))
return user
}
// database/seed/seed.go
func Seed(db *gorm.DB) {
userFactory := database.NewFactory(UserGenerator)
// Generate 10 users without inserting them
users := userFactory.Generate(10)
// Generate and insert 10 users
insertedUsers := userFactory.Save(db, 10)
//...
}
Generators can also create associated records. Associated records should be generated using their respective generators. In the following example, we are generating users for an application allowing users to write blog posts.
func UserGenerator() *model.User {
user := &model.User{}
// Generate user fields...
// Generate between 0 and 10 blog posts
user.Posts = database.NewFactory(PostGenerator).Generate(rand.Intn(10))
return user
}
Overrides
It is possible to override some of the generated data if needed, for example if you need to test the behavior of a function with a specific value. All the non-zero fields of the given override structure will be copied into all generated records. The copy is deep, meaning all nested fields will be copied.
userOverride := &model.User{
Name: "Jérémy",
}
userFactory := database.NewFactory(UserGenerator).Override(userOverride)
userFactory.Save(db, 10)
// All generated records will have the same name: "Jérémy"