visit
Functional tests mimic how the client would interact with the server, and have intuitive names of what the client is doing, like
TestDeleteMiddleFrame
. The reason I don't write other types of tests is because I want to ship quickly as a startup. Functional end-to-end tests can catch regressions early on with a relatively small number of tests that at least executes the majority of the application code.End-to-end means that my tests are purely giving input to the API router as a client would, and compares the given responses to the expected responses. Some API calls produce side effects that aren't visible in the response, and I'll check the results of them as well when the response comes back, like confirming that the Redis cache was written to.Since database reads and writes are involved in most API calls, a first-class consideration should be how the database works for tests. I want to mimic real calls as much as possible, so I don't use mocks or stubs for database calls, and instead, spin up a new database for testing every single time.func SetupRouter() {
router = gin.New()
injectMiddleware(router)
initializeRoutes(router)
}
func injectMiddleware(router) {
router.Use(middleware.DBConnectionPool())
...
}
func initializeRoutes(router) {
router.POST("/login", handlers.Login)
...
}
What does
middleware.DBConnectionPool()
do?func DBConnectionPool() gin.HandlerFunc {
pool := db.Init()
return func(c *gin.Context) {
c.Set("DB", pool)
c.Next()
}
}
Okay, so what does
db.Init()
do?var db *sql.DB
func Init() (conn *sql.DB) {
cred := config.DbCredentials()
connString := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
cred["user"], cred["password"], cred["host"], cred["port"], cred["dbName"])
db = connectPool("main", connString)
return db
}
func connectPool(applicationName string, connString string) (conn *sql.DB) {
db, err := sql.Open("postgres", connString)
// errcheck
err = db.Ping()
// errcheck
log.Info("Successfully connected")
return db
}
func DbCredentials() map[string]string {
m := map[string]string{
"host": os.Getenv("CASE_DB_HOST"),
"user": os.Getenv("CASE_DB_USER"),
"password": os.Getenv("CASE_DB_PASSWORD"),
"dbName": os.Getenv("CASE_DB_DBNAME"),
"port": os.Getenv("PORT"),
}
if m["host"] == "" {
m["host"] = "localhost"
}
if m["user"] == "" {
m["user"] = "me"
}
if m["password"] == "" {
m["password"] = "password"
}
if m["dbName"] == "" {
m["dbName"] = "qa_real"
}
if m["port"] == "" {
m["port"] = "5432"
}
return m
}
It initializes the database by building a connection string and calling the builtin library
sql.Open
. It also starts with a ping, to fail early if something went wrong.func TestAddAndGetDiagram(t *testing.T) {
database := utils.InitDB()
defer utils.Cleanup(database)
...
}
This is what
InitDB
looks like.var testDBName = "functional_tester"
var cred = config.DbCredentials()
func InitDB() *sql.DB {
connString := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
cred["user"], cred["password"], cred["host"], cred["port"], cred["dbName"])
db, err := sql.Open("postgres", connString)
if err != nil {
panic(err)
}
// Delete the database if exists
_, err = db.Exec("DROP DATABASE IF EXISTS " + testDBName)
if err != nil {
panic(err)
}
_, err = db.Exec("CREATE DATABASE " + testDBName + " WITH TEMPLATE " + cred["dbName"])
if err != nil {
panic(err)
}
db.Close()
connString = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
cred["user"], cred["password"], cred["host"], cred["port"], testDBName)
db, err = sql.Open("postgres", connString)
if err != nil {
panic(err)
}
// So that the server will use the right db
os.Setenv("CASE_DB_DBNAME", testDBName)
return db
}
Once inside, I'll drop any existing databases from other test runs. This is necessary because previous runs might've failed and panicked before the
Cleanup
function, so this extra safeguard ensures that other test runs aren't brought down by a single test failure.Next, I create the database using the operation
WITH TEMPLATE
. This is basically the database equivalent of copy and paste. This ensures that the database we use mirrors the actual one. And then I connect to it to get a reference for the Cleanup
.func Cleanup(db *sql.DB) {
// Close the current connection (since we can't drop while inside db we want to drop)
err := db.Close()
if err != nil {
panic(err)
}
// Reconnect without being in current database
connString := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
cred["user"], cred["password"], cred["host"], cred["port"], cred["dbName"])
db, err = sql.Open("postgres", connString)
if err != nil {
panic(err)
}
defer db.Close()
// Delete the used database
_, err = db.Exec("DROP DATABASE " + testDBName)
if err != nil {
panic(err)
}
}
func TestAddAndGetDiagram(t *testing.T) {
database := utils.InitDB()
defer utils.Cleanup(database)
testRouter := routes.SetupRouter()
utils.CreateUser(testRouter, tests.TestEmail, tests.TestPassword)
cookie := utils.Login(t, testRouter, tests.TestEmail, tests.TestPassword)
newDiagramName := "new diagram"
addDiagramRawRequest := []byte(fmt.Sprintf(`
{
"name": "%v",
"baseWidth": %v,
"baseHeight": %v
}
`, newDiagramName, 1080, 720))
addDiagramRequest, err := http.NewRequest("POST", "/api/v1/diagrams", bytes.NewBuffer(addDiagramRawRequest))
if err != nil {
t.Fatal(err)
}
addDiagramRequest.Header.Set("Content-Type", "application/json")
addDiagramRequest.AddCookie(cookie)
resp := httptest.NewRecorder()
testRouter.ServeHTTP(resp, addDiagramRequest)
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
var expectedResponse struct {
Diagram models.Diagram `json:"diagram"`
}
err = json.Unmarshal(body, &expectedResponse)
if err != nil {
t.Fatal(err)
}
addedDiagramID := expectedResponse.Diagram.ID
assert.Equal(t, expectedResponse.Diagram.Name, newDiagramName)
assert.Equal(t, resp.Code, 200)
...
}
// utils
func CreateUser(testRouter *gin.Engine, email, password string) {
request := []byte(fmt.Sprintf(`
{
"username": "%v",
"password": "%v",
"baseWidth": %v,
"baseHeight": %v
}
`, email, password, 1080, 720))
req, _ := http.NewRequest("POST", "/api/v1/register", bytes.NewBuffer(request))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
testRouter.ServeHTTP(resp, req)
}
There's no stubbing, mocks, or anything "simulated" in these tests. All the operations are isolated from each other, I don't serially test
DeleteDiagram
after AddDiagram
, because even though it'd save lines of code, it can also lead to false positive tests that only work when ran in conjunction with another. Instead, I just create another called AddAndDeleteDiagram
.It's the closest I can get to end-to-end for the entire backend. The downside is that it's very time-consuming to build and tear down the database for every test. On my 2019 Macbook Pro, it's well over a minute for all the tests, and a lot slower on some other machines I've run it on. The tradeoff is worth it though, but maybe as the application grows, I'll have to rely on other types of tests.