CRUD RESTful API with Go, GORM, JWT, MySQL and Testing
Go, also known as Golang, is a programming language developed by Google. It was created by Robert Griesemer, Rob Pike, and Ken Thompson and was first announced in 2009. Go was designed with the goal of providing a simple and efficient programming language that is easy to learn, write, and read.
In this blog post:
We will build a blog application where a user can:
- Signup (Register)
- Edit his account
- Shutdown (Delete his account)
- Create a blog post
- Edit blog post created by him
- View all blog posts
- View a particular blog post
- View other blog posts published by other users
- Delete blog post created by him
This API will be built with:
- Go
- GORM (A Golang ORM)
- JWT
- Mysql
- Gorilla Mux (For HTTP routing and URL matcher)
All methods and endpoints will tested in in the next post. Table tests will also be used to test every possible case for a particular functionality.
In Future Articles:
This article is the first part. There will be future articles that will explain how to:
- Creating The API
- Testing the API
- Dockerize the API
- Deploy on Kubernetes
In case you just want to see the code, check this at github
Step 1. Setup Go Project and create Database
Create the directory the project will live in. This can be created anywhere on your computer. Let’s call it skillpedia
mkdir skillpedia && cd skillpedia
Initiate go modules which makes dependency version information explicit and easier to manage
go mod init github.com/{username}/{projectdir}
Where {username} is your github username and {projectdir} is the directory created above. For my case:
go mod init github.com/Sangwan70/skillpedia
We will have to use third party packages in this application. If you are setting this for the first time, run the following commands:
go get github.com/badoux/checkmail go get github.com/jinzhu/gorm go get golang.org/x/crypto/bcrypt go get github.com/dgrijalva/jwt-go go get github.com/gorilla/mux go get github.com/jinzhu/gorm/dialects/mysql go get github.com/joho/godotenv go get gopkg.in/go-playground/assert.v1 go get golang.org/x/text
This command add all modules to go.mod in the project directory. Your go.mod will look like:
Next, create two directories, api and tests, inside the skillpedia directory
mkdir api tests
Create the .env file to setup environmental variables required by our project:
# vi .env # DB_HOST=skillpedia-mysql # will be required for docker container DB_HOST=127.0.0.1 DB_DRIVER=mysql API_SECRET=8tY6ew32U # Any Random string Required for JWT. DB_USER=sangwan DB_PASSWORD=skpDBPass#12 DB_NAME=skillpedia_api DB_PORT=3306 # MySQL Test # TEST_DB_HOST=mysql_test # will be required for docker container TEST_DB_HOST=127.0.0.1 TEST_DB_DRIVER=mysql TEST_API_SECRET=8tY6ew32U TEST_DB_USER=sangwan TEST_DB_PASSWORD=skpDBPass#12 TEST_DB_NAME=skillpedia_api_test TEST_DB_PORT=3306
Note the MySQL database connection details. You will have to create a MySQL Database with same name, user id and password as given.
You can user this script to install mysql on RHEL 8/Rocky Linux 8. Simply download and run the script as root user.
After logging in as root user to mysql, use the following commands to create required users and databases:
mysql> create user sangwan@localhost identified by 'skpDBPass#12'; Query OK, 0 rows affected (0.05 sec)
mysql> create database skillpedia_api; Query OK, 1 row affected (0.01 sec)
mysql> create database skillpedia_api_test; Query OK, 1 row affected (0.00 sec)
mysql> grant all privileges on skillpedia_api.* to sangwan@localhost; Query OK, 0 rows affected (0.09 sec)
mysql> grant all privileges on skillpedia_api_test.* to sangwan@localhost; Query OK, 0 rows affected (0.00 sec)
At this point, this is the structure we have:
[root@server skillpedia]# tree -a . ├── api ├── .env ├── go.mod ├── go.sum └── tests
Step 2: Create Models
Create a directory, modules inside the api
mkdir api/models
For our blog, we will have two model files: “User” to handle business logic for users and “Post” to handle business logic for posts.
The User
model:
Create the user model in api/models:
vi api/models/User.go package models import ( "errors" "html" "log" "strings" "time" "github.com/badoux/checkmail" "github.com/jinzhu/gorm" "golang.org/x/crypto/bcrypt" ) type User struct { ID uint32 `gorm:"primary_key;auto_increment" json:"id"` Nickname string `gorm:"size:255;not null;unique" json:"nickname"` Email string `gorm:"size:100;not null;unique" json:"email"` Password string `gorm:"size:100;not null;" json:"password"` CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` } func Hash(password string) ([]byte, error) { return bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) } func VerifyPassword(hashedPassword, password string) error { return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) } func (u *User) BeforeSave() error { hashedPassword, err := Hash(u.Password) if err != nil { return err } u.Password = string(hashedPassword) return nil } func (u *User) Prepare() { u.ID = 0 u.Nickname = html.EscapeString(strings.TrimSpace(u.Nickname)) u.Email = html.EscapeString(strings.TrimSpace(u.Email)) u.CreatedAt = time.Now() u.UpdatedAt = time.Now() } func (u *User) Validate(action string) error { switch strings.ToLower(action) { case "update": if u.Nickname == "" { return errors.New("Required Nickname") } if u.Password == "" { return errors.New("Required Password") } if u.Email == "" { return errors.New("Required Email") } if err := checkmail.ValidateFormat(u.Email); err != nil { return errors.New("Invalid Email") } return nil case "login": if u.Password == "" { return errors.New("Required Password") } if u.Email == "" { return errors.New("Required Email") } if err := checkmail.ValidateFormat(u.Email); err != nil { return errors.New("Invalid Email") } return nil default: if u.Nickname == "" { return errors.New("Required Nickname") } if u.Password == "" { return errors.New("Required Password") } if u.Email == "" { return errors.New("Required Email") } if err := checkmail.ValidateFormat(u.Email); err != nil { return errors.New("Invalid Email") } return nil } } func (u *User) SaveUser(db *gorm.DB) (*User, error) { var err error err = db.Debug().Create(&u).Error if err != nil { return &User{}, err } return u, nil } func (u *User) FindAllUsers(db *gorm.DB) (*[]User, error) { var err error users := []User{} err = db.Debug().Model(&User{}).Limit(100).Find(&users).Error if err != nil { return &[]User{}, err } return &users, err } func (u *User) FindUserByID(db *gorm.DB, uid uint32) (*User, error) { var err error err = db.Debug().Model(User{}).Where("id = ?", uid).Take(&u).Error if err != nil { return &User{}, err } if gorm.IsRecordNotFoundError(err) { return &User{}, errors.New("User Not Found") } return u, err } func (u *User) UpdateAUser(db *gorm.DB, uid uint32) (*User, error) { // To hash the password err := u.BeforeSave() if err != nil { log.Fatal(err) } db = db.Debug().Model(&User{}).Where("id = ?", uid).Take(&User{}).UpdateColumns( map[string]interface{}{ "password": u.Password, "nickname": u.Nickname, "email": u.Email, "update_at": time.Now(), }, ) if db.Error != nil { return &User{}, db.Error } // This is the display the updated user err = db.Debug().Model(&User{}).Where("id = ?", uid).Take(&u).Error if err != nil { return &User{}, err } return u, nil } func (u *User) DeleteAUser(db *gorm.DB, uid uint32) (int64, error) { db = db.Debug().Model(&User{}).Where("id = ?", uid).Take(&User{}).Delete(&User{}) if db.Error != nil { return 0, db.Error } return db.RowsAffected, nil }
The Post model:
Create the post model in the path api/models
vi api/models/Post.go
package models
import (
"errors"
"html"
"strings"
"time"
"github.com/jinzhu/gorm"
)
type Post struct {
ID uint64 `gorm:"primary_key;auto_increment" json:"id"`
Title string `gorm:"size:255;not null;unique" json:"title"`
Content string `gorm:"size:255;not null;" json:"content"`
Author User `json:"author"`
AuthorID uint32 `gorm:"not null" json:"author_id"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
}
func (p *Post) Prepare() {
p.ID = 0
p.Title = html.EscapeString(strings.TrimSpace(p.Title))
p.Content = html.EscapeString(strings.TrimSpace(p.Content))
p.Author = User{}
p.CreatedAt = time.Now()
p.UpdatedAt = time.Now()
}
func (p *Post) Validate() error {
if p.Title == "" {
return errors.New("Required Title")
}
if p.Content == "" {
return errors.New("Required Content")
}
if p.AuthorID < 1 {
return errors.New("Required Author")
}
return nil
}
func (p *Post) SavePost(db *gorm.DB) (*Post, error) {
var err error
err = db.Debug().Model(&Post{}).Create(&p).Error
if err != nil {
return &Post{}, err
}
if p.ID != 0 {
err = db.Debug().Model(&User{}).Where("id = ?", p.AuthorID).Take(&p.Author).Error
if err != nil {
return &Post{}, err
}
}
return p, nil
}
func (p *Post) FindAllPosts(db *gorm.DB) (*[]Post, error) {
var err error
posts := []Post{}
err = db.Debug().Model(&Post{}).Limit(100).Find(&posts).Error
if err != nil {
return &[]Post{}, err
}
if len(posts) > 0 {
for i, _ := range posts {
err := db.Debug().Model(&User{}).Where("id = ?", posts[i].AuthorID).Take(&posts[i].Author).Error
if err != nil {
return &[]Post{}, err
}
}
}
return &posts, nil
}
func (p *Post) FindPostByID(db *gorm.DB, pid uint64) (*Post, error) {
var err error
err = db.Debug().Model(&Post{}).Where("id = ?", pid).Take(&p).Error
if err != nil {
return &Post{}, err
}
if p.ID != 0 {
err = db.Debug().Model(&User{}).Where("id = ?", p.AuthorID).Take(&p.Author).Error
if err != nil {
return &Post{}, err
}
}
return p, nil
}
func (p *Post) UpdateAPost(db *gorm.DB, pid uint64) (*Post, error) {
var err error
db = db.Debug().Model(&Post{}).Where("id = ?", pid).Take(&Post{}).UpdateColumns(
map[string]interface{}{
"title": p.Title,
"content": p.Content,
"updated_at": time.Now(),
},
)
err = db.Debug().Model(&Post{}).Where("id = ?", pid).Take(&p).Error
if err != nil {
return &Post{}, err
}
if p.ID != 0 {
err = db.Debug().Model(&User{}).Where("id = ?", p.AuthorID).Take(&p.Author).Error
if err != nil {
return &Post{}, err
}
}
return p, nil
}
func (p *Post) DeleteAPost(db *gorm.DB, pid uint64, uid uint32) (int64, error) {
db = db.Debug().Model(&Post{}).Where("id = ? and author_id = ?", pid, uid).Take(&Post{}).Delete(&Post{})
if db.Error != nil {
if gorm.IsRecordNotFoundError(db.Error) {
return 0, errors.New("Post not found")
}
return 0, db.Error
}
return db.RowsAffected, nil
}
Step 3. Create a Custom Response
Before we create controllers that will interact with models defined above, we will create a custom response package. This will be used for the http responses.
Inside the api directory, create the responses directory
mkdir api/responses
Then create the json.go file in the path: api/responses
vi api/responses/json.go
package responses
import (
"encoding/json"
"fmt"
"net/http"
)
func JSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.WriteHeader(statusCode)
err := json.NewEncoder(w).Encode(data)
if err != nil {
fmt.Fprintf(w, "%s", err.Error())
}
}
func ERROR(w http.ResponseWriter, statusCode int, err error) {
if err != nil {
JSON(w, statusCode, struct {
Error string `json:"error"`
}{
Error: err.Error(),
})
return
}
JSON(w, http.StatusBadRequest, nil)
}
Step 4. Create JSON Web Tokens (JWT)
Remember that users need to be authenticated before they can: Update or Shutdown their accounts, Create, Update, and Delete Posts.
Let’s write a package that will help us generate a JWT token that will enable the user to perform the above actions.
Create the auth package (directory) inside the api directory.
mkdir api/auth
Create the token.go file in the path: api/auth
vi api/auth/token.go
package auth
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
jwt "github.com/dgrijalva/jwt-go"
)
func CreateToken(user_id uint32) (string, error) {
claims := jwt.MapClaims{}
claims["authorized"] = true
claims["user_id"] = user_id
claims["exp"] = time.Now().Add(time.Hour * 1).Unix() //Token expires after 1 hour
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(os.Getenv("API_SECRET")))
}
func TokenValid(r *http.Request) error {
tokenString := ExtractToken(r)
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return []byte(os.Getenv("API_SECRET")), nil
})
if err != nil {
return err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
Pretty(claims)
}
return nil
}
func ExtractToken(r *http.Request) string {
keys := r.URL.Query()
token := keys.Get("token")
if token != "" {
return token
}
bearerToken := r.Header.Get("Authorization")
if len(strings.Split(bearerToken, " ")) == 2 {
return strings.Split(bearerToken, " ")[1]
}
return ""
}
func ExtractTokenID(r *http.Request) (uint32, error) {
tokenString := ExtractToken(r)
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return []byte(os.Getenv("API_SECRET")), nil
})
if err != nil {
return 0, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if ok && token.Valid {
uid, err := strconv.ParseUint(fmt.Sprintf("%.0f", claims["user_id"]), 10, 32)
if err != nil {
return 0, err
}
return uint32(uid), nil
}
return 0, nil
}
//Pretty display the claims licely in the terminal
func Pretty(data interface{}) {
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
log.Println(err)
return
}
fmt.Println(string(b))
}
Observe from the token.go file that we assigned the import: "github.com/dgrijalva/jwt-go" to jwt.
Step 5. Create Middlewares
We will create two main middlewares:
- SetMiddlewareJSON: This will format all responses to JSON
- SetMiddlewareAuthentication: This will check for the validity of the authentication token provided.
create the middlewares package(directory) inside the api directory,
mkdir api/middlewares
Then create a middlewares.go file in the path api/middlewares
vi api/middlewares/middlewares.go
package middlewares
import (
"errors"
"net/http"
"github.com/Sangwan70/skillpedia/api/auth"
"github.com/Sangwan70/skillpedia/api/responses"
)
func SetMiddlewareJSON(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next(w, r)
}
}
func SetMiddlewareAuthentication(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := auth.TokenValid(r)
if err != nil {
responses.ERROR(w, http.StatusUnauthorized, errors.New("Unauthorized"))
return
}
next(w, r)
}
}
Step 6. Create Custom Error Handling
To format some error messages in a more readable manner, we need to a create a package to help us achieve that.
Create a directory utils,and the package formaterror, inside this directory.
mkdir -p api/utils/formaterror
Then create a formaterror.go file in the path: api/utils/formaterror
vi api/utils/formaterror/formaterror.go
package formaterror
import (
"errors"
"strings"
)
func FormatError(err string) error {
if strings.Contains(err, "nickname") {
return errors.New("Nickname Already Taken")
}
if strings.Contains(err, "email") {
return errors.New("Email Already Taken")
}
if strings.Contains(err, "title") {
return errors.New("Title Already Taken")
}
if strings.Contains(err, "hashedPassword") {
return errors.New("Incorrect Password")
}
return errors.New("Incorrect Details")
}
Step 7. Create Controllers
Now let's create the controllers package that will interact with our models package:
Create the controllers (directory) inside the api directory,
mkdir api/controllers
Create base.go in api/controllers for our database connection information, initialise our routes, and start our server
vi api/controllers/base.go
package controllers
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/mux"
"github.com/jinzhu/gorm"
_ "github.com/badoux/checkmail"
_ "github.com/jinzhu/gorm"
_ "golang.org/x/crypto/bcrypt"
_ "github.com/dgrijalva/jwt-go"
_ "github.com/gorilla/mux"
_ "github.com/joho/godotenv"
_ "golang.org/x/text"
_ "gopkg.in/go-playground/assert.v1"
_ "github.com/jinzhu/gorm/dialects/mysql"
"github.com/Sangwan70/skillpedia/api/models"
)
type Server struct {
DB *gorm.DB
Router *mux.Router
}
func (server *Server) Initialize(Dbdriver, DbUser, DbPassword, DbPort, DbHost, DbName string) {
var err error
if Dbdriver == "mysql" {
DBURL := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local", DbUser, DbPassword, DbHost, DbPort, DbName)
server.DB, err = gorm.Open(Dbdriver, DBURL)
if err != nil {
fmt.Printf("Cannot connect to %s database", Dbdriver)
log.Fatal("This is the error:", err)
} else {
fmt.Printf("We are connected to the %s database", Dbdriver)
}
}
if Dbdriver == "postgres" {
DBURL := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", DbHost, DbPort, DbUser, DbName, DbPassword)
server.DB, err = gorm.Open(Dbdriver, DBURL)
if err != nil {
fmt.Printf("Cannot connect to %s database", Dbdriver)
log.Fatal("This is the error:", err)
} else {
fmt.Printf("We are connected to the %s database", Dbdriver)
}
}
server.DB.Debug().AutoMigrate(&models.User{}, &models.Post{}) //database migration
server.Router = mux.NewRouter()
server.initializeRoutes()
}
func (server *Server) Run(addr string) {
fmt.Println("Listening to port 8088")
log.Fatal(http.ListenAndServe(addr, server.Router))
}
Note the underscore we used when we imported the mysql package and that we called the initializeRoutes() method. It will be defined in the routes.go file shortly.
Let’s create a home_controller.go file to welcome us to the API in api/controllers
vi api/controllers/home_controller.go
package controllers
import (
"net/http"
"github.com/Sangwan70/skillpedia/api/responses"
)
func (server *Server) Home(w http.ResponseWriter, r *http.Request) {
responses.JSON(w, http.StatusOK, "Welcome To This Awesome API")
}
Create the users_controller.go file in api/controllers
vi api/controllers/users_controller.go
package controllers
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/Sangwan70/skillpedia/api/auth"
"github.com/Sangwan70/skillpedia/api/models"
"github.com/Sangwan70/skillpedia/api/responses"
"github.com/Sangwan70/skillpedia/api/utils/formaterror"
)
func (server *Server) CreateUser(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
}
user := models.User{}
err = json.Unmarshal(body, &user)
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
user.Prepare()
err = user.Validate("")
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
userCreated, err := user.SaveUser(server.DB)
if err != nil {
formattedError := formaterror.FormatError(err.Error())
responses.ERROR(w, http.StatusInternalServerError, formattedError)
return
}
w.Header().Set("Location", fmt.Sprintf("%s%s/%d", r.Host, r.RequestURI, userCreated.ID))
responses.JSON(w, http.StatusCreated, userCreated)
}
func (server *Server) GetUsers(w http.ResponseWriter, r *http.Request) {
user := models.User{}
users, err := user.FindAllUsers(server.DB)
if err != nil {
responses.ERROR(w, http.StatusInternalServerError, err)
return
}
responses.JSON(w, http.StatusOK, users)
}
func (server *Server) GetUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
uid, err := strconv.ParseUint(vars["id"], 10, 32)
if err != nil {
responses.ERROR(w, http.StatusBadRequest, err)
return
}
user := models.User{}
userGotten, err := user.FindUserByID(server.DB, uint32(uid))
if err != nil {
responses.ERROR(w, http.StatusBadRequest, err)
return
}
responses.JSON(w, http.StatusOK, userGotten)
}
func (server *Server) UpdateUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
uid, err := strconv.ParseUint(vars["id"], 10, 32)
if err != nil {
responses.ERROR(w, http.StatusBadRequest, err)
return
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
user := models.User{}
err = json.Unmarshal(body, &user)
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
tokenID, err := auth.ExtractTokenID(r)
if err != nil {
responses.ERROR(w, http.StatusUnauthorized, errors.New("Unauthorized"))
return
}
if tokenID != uint32(uid) {
responses.ERROR(w, http.StatusUnauthorized, errors.New(http.StatusText(http.StatusUnauthorized)))
return
}
user.Prepare()
err = user.Validate("update")
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
updatedUser, err := user.UpdateAUser(server.DB, uint32(uid))
if err != nil {
formattedError := formaterror.FormatError(err.Error())
responses.ERROR(w, http.StatusInternalServerError, formattedError)
return
}
responses.JSON(w, http.StatusOK, updatedUser)
}
func (server *Server) DeleteUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
user := models.User{}
uid, err := strconv.ParseUint(vars["id"], 10, 32)
if err != nil {
responses.ERROR(w, http.StatusBadRequest, err)
return
}
tokenID, err := auth.ExtractTokenID(r)
if err != nil {
responses.ERROR(w, http.StatusUnauthorized, errors.New("Unauthorized"))
return
}
if tokenID != uint32(uid) {
responses.ERROR(w, http.StatusUnauthorized, errors.New(http.StatusText(http.StatusUnauthorized)))
return
}
_, err = user.DeleteAUser(server.DB, uint32(uid))
if err != nil {
responses.ERROR(w, http.StatusBadRequest, err)
return
}
w.Header().Set("Entity", fmt.Sprintf("%d", uid))
responses.JSON(w, http.StatusNoContent, "")
}
Create the posts_controller.go file in api/controllers
vi api/controllers/posts_controller.go
package controllers
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/Sangwan70/skillpedia/api/auth"
"github.com/Sangwan70/skillpedia/api/models"
"github.com/Sangwan70/skillpedia/api/responses"
"github.com/Sangwan70/skillpedia/api/utils/formaterror"
)
func (server *Server) CreatePost(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
post := models.Post{}
err = json.Unmarshal(body, &post)
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
post.Prepare()
err = post.Validate()
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
uid, err := auth.ExtractTokenID(r)
if err != nil {
responses.ERROR(w, http.StatusUnauthorized, errors.New("Unauthorized"))
return
}
if uid != post.AuthorID {
responses.ERROR(w, http.StatusUnauthorized, errors.New(http.StatusText(http.StatusUnauthorized)))
return
}
postCreated, err := post.SavePost(server.DB)
if err != nil {
formattedError := formaterror.FormatError(err.Error())
responses.ERROR(w, http.StatusInternalServerError, formattedError)
return
}
w.Header().Set("Lacation", fmt.Sprintf("%s%s/%d", r.Host, r.URL.Path, postCreated.ID))
responses.JSON(w, http.StatusCreated, postCreated)
}
func (server *Server) GetPosts(w http.ResponseWriter, r *http.Request) {
post := models.Post{}
posts, err := post.FindAllPosts(server.DB)
if err != nil {
responses.ERROR(w, http.StatusInternalServerError, err)
return
}
responses.JSON(w, http.StatusOK, posts)
}
func (server *Server) GetPost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
pid, err := strconv.ParseUint(vars["id"], 10, 64)
if err != nil {
responses.ERROR(w, http.StatusBadRequest, err)
return
}
post := models.Post{}
postReceived, err := post.FindPostByID(server.DB, pid)
if err != nil {
responses.ERROR(w, http.StatusInternalServerError, err)
return
}
responses.JSON(w, http.StatusOK, postReceived)
}
func (server *Server) UpdatePost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
pid, err := strconv.ParseUint(vars["id"], 10, 64)
if err != nil {
responses.ERROR(w, http.StatusBadRequest, err)
return
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
post := models.Post{}
err = json.Unmarshal(body, &post)
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
post.Prepare()
err = post.Validate()
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
uid, err := auth.ExtractTokenID(r)
if err != nil {
responses.ERROR(w, http.StatusUnauthorized, errors.New("Unauthorized"))
return
}
if uid != post.AuthorID {
responses.ERROR(w, http.StatusUnauthorized, errors.New(http.StatusText(http.StatusUnauthorized)))
return
}
postUpdated, err := post.UpdateAPost(server.DB, pid)
if err != nil {
formattedError := formaterror.FormatError(err.Error())
responses.ERROR(w, http.StatusInternalServerError, formattedError)
return
}
responses.JSON(w, http.StatusOK, postUpdated)
}
func (server *Server) DeletePost(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
post := models.Post{}
pid, err := strconv.ParseUint(vars["id"], 10, 64)
if err != nil {
responses.ERROR(w, http.StatusBadRequest, err)
return
}
uid, err := auth.ExtractTokenID(r)
if err != nil {
responses.ERROR(w, http.StatusUnauthorized, errors.New("Unauthorized"))
return
}
_, err = post.DeleteAPost(server.DB, pid, uid)
if err != nil {
responses.ERROR(w, http.StatusBadRequest, err)
return
}
w.Header().Set("Entity", fmt.Sprintf("%d", pid))
responses.JSON(w, http.StatusNoContent, "")
}
Create the login_controller.go file in api/controllers
vi api/controllers/login_controller.go
package controllers
import (
"encoding/json"
"io/ioutil"
"net/http"
"github.com/Sangwan70/skillpedia/api/auth"
"github.com/Sangwan70/skillpedia/api/models"
"github.com/Sangwan70/skillpedia/api/responses"
"github.com/Sangwan70/skillpedia/api/utils/formaterror"
"golang.org/x/crypto/bcrypt"
)
func (server *Server) Login(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
user := models.User{}
err = json.Unmarshal(body, &user)
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
user.Prepare()
err = user.Validate("login")
if err != nil {
responses.ERROR(w, http.StatusUnprocessableEntity, err)
return
}
token, err := server.SignIn(user.Email, user.Password)
if err != nil {
formattedError := formaterror.FormatError(err.Error())
responses.ERROR(w, http.StatusUnprocessableEntity, formattedError)
return
}
responses.JSON(w, http.StatusOK, token)
}
func (server *Server) SignIn(email, password string) (string, error) {
var err error
user := models.User{}
err = server.DB.Debug().Model(models.User{}).Where("email = ?", email).Take(&user).Error
if err != nil {
return "", err
}
err = models.VerifyPassword(user.Password, password)
if err != nil && err == bcrypt.ErrMismatchedHashAndPassword {
return "", err
}
return auth.CreateToken(user.ID)
}
Light up the whole place up by creating the routes.go file in api/controllers
vi api/controllers/routes.go
package controllers
import "github.com/Sangwan70/skillpedia/api/middlewares"
func (s *Server) initializeRoutes() {
// Home Route
s.Router.HandleFunc("/", middlewares.SetMiddlewareJSON(s.Home)).Methods("GET")
// Login Route
s.Router.HandleFunc("/login", middlewares.SetMiddlewareJSON(s.Login)).Methods("POST")
//Users routes
s.Router.HandleFunc("/users", middlewares.SetMiddlewareJSON(s.CreateUser)).Methods("POST")
s.Router.HandleFunc("/users", middlewares.SetMiddlewareJSON(s.GetUsers)).Methods("GET")
s.Router.HandleFunc("/users/{id}", middlewares.SetMiddlewareJSON(s.GetUser)).Methods("GET")
s.Router.HandleFunc("/users/{id}", middlewares.SetMiddlewareJSON(middlewares.SetMiddlewareAuthentication(s.UpdateUser))).Methods("PUT")
s.Router.HandleFunc("/users/{id}", middlewares.SetMiddlewareAuthentication(s.DeleteUser)).Methods("DELETE")
//Posts routes
s.Router.HandleFunc("/posts", middlewares.SetMiddlewareJSON(s.CreatePost)).Methods("POST")
s.Router.HandleFunc("/posts", middlewares.SetMiddlewareJSON(s.GetPosts)).Methods("GET")
s.Router.HandleFunc("/posts/{id}", middlewares.SetMiddlewareJSON(s.GetPost)).Methods("GET")
s.Router.HandleFunc("/posts/{id}", middlewares.SetMiddlewareJSON(middlewares.SetMiddlewareAuthentication(s.UpdatePost))).Methods("PUT")
s.Router.HandleFunc("/posts/{id}", middlewares.SetMiddlewareAuthentication(s.DeletePost)).Methods("DELETE")
}
Step 8: Seeding the Database with Dummy Data
If you wish, you can add dummy data to the database before adding a real one.
Let’s create a seed package to achieve that in the path api/.
mkdir api/seed
Then create the seeder.go file:
vi api/seed/seeder.go
package seed
import (
"log"
"github.com/jinzhu/gorm"
"github.com/Sangwan70/skillpedia/api/models"
)
var users = []models.User{
models.User{
Nickname: "Ram N Sangwan",
Email: "ramnsangwan@gmail.com",
Password: "password",
},
models.User{
Nickname: "Kapil Dev",
Email: "kapil@gmail.com",
Password: "password",
},
}
var posts = []models.Post{
models.Post{
Title: "The SkillPedia Launched",
Content: "A Great Portal to learn and Teach - www.theskillpedia.com",
},
models.Post{
Title: "Alliance Software Technology",
Content: "Providing Consulting to IT Companies.",
},
}
func Load(db *gorm.DB) {
err := db.Debug().DropTableIfExists(&models.Post{}, &models.User{}).Error
if err != nil {
log.Fatalf("cannot drop table: %v", err)
}
err = db.Debug().AutoMigrate(&models.User{}, &models.Post{}).Error
if err != nil {
log.Fatalf("cannot migrate table: %v", err)
}
err = db.Debug().Model(&models.Post{}).AddForeignKey("author_id", "users(id)", "cascade", "cascade").Error
if err != nil {
log.Fatalf("attaching foreign key error: %v", err)
}
for i, _ := range users {
err = db.Debug().Model(&models.User{}).Create(&users[i]).Error
if err != nil {
log.Fatalf("cannot seed users table: %v", err)
}
posts[i].AuthorID = users[i].ID
err = db.Debug().Model(&models.Post{}).Create(&posts[i]).Error
if err != nil {
log.Fatalf("cannot seed posts table: %v", err)
}
}
}
Step 9. Create an Entry File to the API Directory
We need to call the initialize() method we defined in the base.go file and also the seeder we define above. We will create a file called server.go in the path api/
vi api/server.go
package api
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"github.com/Sangwan70/skillpedia/api/controllers"
"github.com/Sangwan70/skillpedia/api/seed"
)
var server = controllers.Server{}
func init() {
// loads values from .env into the system
if err := godotenv.Load(); err != nil {
log.Print("sad .env file found")
}
}
func Run() {
var err error
err = godotenv.Load()
if err != nil {
log.Fatalf("Error getting env, %v", err)
} else {
fmt.Println("We are getting the env values")
}
server.Initialize(os.Getenv("DB_DRIVER"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_PORT"), os.Getenv("DB_HOST"), os.Getenv("DB_NAME"))
seed.Load(server.DB)
server.Run(":8088")
}
Step 10: Create app entry file: main.go
Create the main.go file in the root of the skillpedia directory
vi main.go
package main
import (
"github.com/Sangwan70/skillpedia/api"
)
func main() {
api.Run()
}
This is the file that actually “start the engine”. We will call the Run method that was defined in server.go file
Before we run the app, let's confirm your directory structure:
Now let’s run the app to start up the app and run migrations.
go run main.go
Step 11: Testing the Endpoints in Postman
You can use Postman or any other testing tool, then in the next step, we will write test cases.
Lets test endpoints at random:
a. GetUsers (/users): send a GET request "http://localhost:8088/users"
Remember we seeded the users table
b. GetPosts (/posts): http://localhost:8088/posts
Remember we also seeded the posts table
c. Login (/login): http://localhost:8088/login
Lets Login User 1: We will use that token to update the post in the next test.
d. UpdatePost (/posts/{id}): http://localhost:8088/posts/{id}
To update a post, we will need the authentication token for a user and in the above, we generated a token when we logged in user 1. The token look like this:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkIjp0cnVlLCJleHAiOjE2ODYwMjgxMjMsInVzZXJfaWQiOjF9.I5eFKSoSmGGLYc5406Z0mA3vzgNr62qszHn39nC24ss"
I will use this token and put it in Authorization: Bearer Token in Postman:
Then I will use JSON in the body to send data to update:
{
"title":"SkillPedia LMS",
"author_id": 1,
"content": "A Complete Learning Management System"
}
If user 2 tries to update user 1 post, the response will be authorized
e. DeleteUser (/users/{id}): http://localhost:8088/users/{id}
To delete an account, the user needs to be logged in first, so as to generate the JWT token. Let us login user 2 and delete him.
Grab the token, insert it in Authorization: Bearer Token. Then use a “DELETE” method in your Postman.
Note that Status Code is 204, which means No Content. And the response is 1, which means success.
f. UpdateUser (/users/{id}): http://localhost:8088/users/{id}
Remember to login the ‘user to update’ and use the Token. Let’s update User 1:
{
"nickname": "R N Sangwan",
"email": "admin@theskillpedia.com",
"password": "NewPassword"
}
g. CreateUser (/users):
We don’t need to be authenticated to create a user (signup). Let’s create a third user:
h. CreatePost (/posts):
Let the new user create a post. Remember again, he must log in and his token used in Authorization: Bearer Token
{
"email": "rnsangwan@gmail.com",
"password": "NewPassword"
}
{
"title":"Go Language is Fun",
"author_id": 3,
"content": "A Complete Development Environment"
}
You can test the remaining endpoints:
i. GetUser (/users/{id})— Get one user (No token required)
j. GetPost(/posts/{id}) — Get one post (No token required)
k. DeletePost(/posts/{id}) — An authenticated User deletes a post he created( Token required)
l. Home(/) — Get Home page (No token required)
Step 12: Writing Test Cases For Endpoints
We have proven that then endpoints are working. But that is not enough, we still need really test each of them to further proof.
Remember we defined a test database in our .env so, you can create a new database for the tests alone.
I am aware that we can use Interfaces and mock database calls, but I decided to be replicate the exact same process that happens in real life for testing.
At the start of this project, we created the tests directory.
Now, we will create two packages inside it: modeltests and controllertests
a. Models Tests
We are going to test the methods in the models package defined in step 3.
Create the modeltests package in tests:
mkdir tests/modeltests
cd into the package and create a file model_test.go
cd tests/modeltests
vi model_test.go
package modeltests
import (
"fmt"
"log"
"os"
"testing"
"github.com/jinzhu/gorm"
"github.com/joho/godotenv"
"github.com/victorsteven/fullstack/api/controllers"
"github.com/victorsteven/fullstack/api/models"
)
var server = controllers.Server{}
var userInstance = models.User{}
var postInstance = models.Post{}
func TestMain(m *testing.M) {
var err error
err = godotenv.Load(os.ExpandEnv("../../.env"))
if err != nil {
log.Fatalf("Error getting env %v\n", err)
}
Database()
log.Printf("Before calling m.Run() !!!")
ret := m.Run()
log.Printf("After calling m.Run() !!!")
//os.Exit(m.Run())
os.Exit(ret)
}
func Database() {
var err error
TestDbDriver := os.Getenv("TestDbDriver")
if TestDbDriver == "mysql" {
DBURL := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local", os.Getenv("TestDbUser"), os.Getenv("TestDbPassword"), os.Getenv("TestDbHost"), os.Getenv("TestDbPort"), os.Getenv("TestDbName"))
server.DB, err = gorm.Open(TestDbDriver, DBURL)
if err != nil {
fmt.Printf("Cannot connect to %s database\n", TestDbDriver)
log.Fatal("This is the error:", err)
} else {
fmt.Printf("We are connected to the %s database\n", TestDbDriver)
}
}
if TestDbDriver == "postgres" {
DBURL := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", os.Getenv("TestDbHost"), os.Getenv("TestDbPort"), os.Getenv("TestDbUser"), os.Getenv("TestDbName"), os.Getenv("TestDbPassword"))
server.DB, err = gorm.Open(TestDbDriver, DBURL)
if err != nil {
fmt.Printf("Cannot connect to %s database\n", TestDbDriver)
log.Fatal("This is the error:", err)
} else {
fmt.Printf("We are connected to the %s database\n", TestDbDriver)
}
}
if TestDbDriver == "sqlite3" {
//DBURL := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", DbHost, DbPort, DbUser, DbName, DbPassword)
testDbName := os.Getenv("TestDbName")
server.DB, err = gorm.Open(TestDbDriver, testDbName)
if err != nil {
fmt.Printf("Cannot connect to %s database\n", TestDbDriver)
log.Fatal("This is the error:", err)
} else {
fmt.Printf("We are connected to the %s database\n", TestDbDriver)
}
server.DB.Exec("PRAGMA foreign_keys = ON")
}
}
func refreshUserTable() error {
server.DB.Exec("SET foreign_key_checks=0")
err := server.DB.Debug().DropTableIfExists(&models.User{}).Error
if err != nil {
return err
}
server.DB.Exec("SET foreign_key_checks=1")
err = server.DB.Debug().AutoMigrate(&models.User{}).Error
if err != nil {
return err
}
log.Printf("Successfully refreshed table")
log.Printf("refreshUserTable routine OK !!!")
return nil
}
func seedOneUser() (models.User, error) {
_ = refreshUserTable()
user := models.User{
Nickname: "Pet",
Email: "pet@gmail.com",
Password: "password",
}
err := server.DB.Debug().Model(&models.User{}).Create(&user).Error
if err != nil {
log.Fatalf("cannot seed users table: %v", err)
}
log.Printf("seedOneUser routine OK !!!")
return user, nil
}
func seedUsers() error {
users := []models.User{
models.User{
Nickname: "Steven victor",
Email: "steven@gmail.com",
Password: "password",
},
models.User{
Nickname: "Kenny Morris",
Email: "kenny@gmail.com",
Password: "password",
},
}
for i := range users {
err := server.DB.Debug().Model(&models.User{}).Create(&users[i]).Error
if err != nil {
return err
}
}
log.Printf("seedUsers routine OK !!!")
return nil
}
func refreshUserAndPostTable() error {
server.DB.Exec("SET foreign_key_checks=0")
// NOTE: when deleting first delete Post as Post is depending on User table
err := server.DB.Debug().DropTableIfExists(&models.Post{}, &models.User{}).Error
if err != nil {
return err
}
server.DB.Exec("SET foreign_key_checks=1")
err = server.DB.Debug().AutoMigrate(&models.User{}, &models.Post{}).Error
if err != nil {
return err
}
log.Printf("Successfully refreshed tables")
log.Printf("refreshUserAndPostTable routine OK !!!")
return nil
}
func seedOneUserAndOnePost() (models.Post, error) {
err := refreshUserAndPostTable()
if err != nil {
return models.Post{}, err
}
user := models.User{
Nickname: "Sam Phil",
Email: "sam@gmail.com",
Password: "password",
}
err = server.DB.Debug().Model(&models.User{}).Create(&user).Error
if err != nil {
return models.Post{}, err
}
post := models.Post{
Title: "This is the title sam",
Content: "This is the content sam",
AuthorID: user.ID,
}
err = server.DB.Debug().Model(&models.Post{}).Create(&post).Error
if err != nil {
return models.Post{}, err
}
log.Printf("seedOneUserAndOnePost routine OK !!!")
return post, nil
}
func seedUsersAndPosts() ([]models.User, []models.Post, error) {
var err error
if err != nil {
return []models.User{}, []models.Post{}, err
}
var users = []models.User{
models.User{
Nickname: "Steven victor",
Email: "steven@gmail.com",
Password: "password",
},
models.User{
Nickname: "Magu Frank",
Email: "magu@gmail.com",
Password: "password",
},
}
var posts = []models.Post{
models.Post{
Title: "Title 1",
Content: "Hello world 1",
},
models.Post{
Title: "Title 2",
Content: "Hello world 2",
},
}
for i := range users {
err = server.DB.Debug().Model(&models.User{}).Create(&users[i]).Error
if err != nil {
log.Fatalf("cannot seed users table: %v", err)
}
posts[i].AuthorID = users[i].ID
err = server.DB.Debug().Model(&models.Post{}).Create(&posts[i]).Error
if err != nil {
log.Fatalf("cannot seed posts table: %v", err)
}
}
log.Printf("seedUsersAndPosts routine OK !!!")
return users, posts, nil
}
TestMain() does the necessary setup before testing. It loads the .env file and calls the Database() function to connect to the test database.
You can also see that we wrote other helper functions that we will use in the actual test, such as refreshing the test database and seeding it, this will be done for every test. So no test depends on another to pass.
i. user_model_test.go
Now, create tests for the methods in the User model. Create the file user_model_test.go in the path same as the model_test.go: tests/modeltests
vi user_model_test.go
package modeltests
import (
"log"
"testing"
_ "github.com/jinzhu/gorm/dialects/mysql" //mysql driver
_ "github.com/jinzhu/gorm/dialects/postgres" //postgres driver
"github.com/victorsteven/fullstack/api/models"
"gopkg.in/go-playground/assert.v1"
)
func TestFindAllUsers(t *testing.T) {
err := refreshUserTable()
if err != nil {
log.Fatalf("Error refreshing user table %v\n", err)
}
err = seedUsers()
if err != nil {
log.Fatalf("Error seeding user table %v\n", err)
}
users, err := userInstance.FindAllUsers(server.DB)
if err != nil {
t.Errorf("this is the error getting the users: %v\n", err)
return
}
assert.Equal(t, len(*users), 2)
}
func TestSaveUser(t *testing.T) {
err := refreshUserTable()
if err != nil {
log.Fatalf("Error user refreshing table %v\n", err)
}
newUser := models.User{
ID: 1,
Email: "test@gmail.com",
Nickname: "test",
Password: "password",
}
savedUser, err := newUser.SaveUser(server.DB)
if err != nil {
t.Errorf("Error while saving a user: %v\n", err)
return
}
assert.Equal(t, newUser.ID, savedUser.ID)
assert.Equal(t, newUser.Email, savedUser.Email)
assert.Equal(t, newUser.Nickname, savedUser.Nickname)
}
func TestGetUserByID(t *testing.T) {
err := refreshUserTable()
if err != nil {
log.Fatalf("Error user refreshing table %v\n", err)
}
user, err := seedOneUser()
if err != nil {
log.Fatalf("cannot seed users table: %v", err)
}
foundUser, err := userInstance.FindUserByID(server.DB, user.ID)
if err != nil {
t.Errorf("this is the error getting one user: %v\n", err)
return
}
assert.Equal(t, foundUser.ID, user.ID)
assert.Equal(t, foundUser.Email, user.Email)
assert.Equal(t, foundUser.Nickname, user.Nickname)
}
func TestUpdateAUser(t *testing.T) {
err := refreshUserTable()
if err != nil {
log.Fatal(err)
}
user, err := seedOneUser()
if err != nil {
log.Fatalf("Cannot seed user: %v\n", err)
}
userUpdate := models.User{
ID: 1,
Nickname: "modiUpdate",
Email: "modiupdate@gmail.com",
Password: "password",
}
updatedUser, err := userUpdate.UpdateAUser(server.DB, user.ID)
if err != nil {
t.Errorf("this is the error updating the user: %v\n", err)
return
}
assert.Equal(t, updatedUser.ID, userUpdate.ID)
assert.Equal(t, updatedUser.Email, userUpdate.Email)
assert.Equal(t, updatedUser.Nickname, userUpdate.Nickname)
}
func TestDeleteAUser(t *testing.T) {
err := refreshUserTable()
if err != nil {
log.Fatal(err)
}
user, err := seedOneUser()
if err != nil {
log.Fatalf("Cannot seed user: %v\n", err)
}
isDeleted, err := userInstance.DeleteAUser(server.DB, user.ID)
if err != nil {
t.Errorf("this is the error deleting the user: %v\n", err)
return
}
//one shows that the record has been deleted or:
// assert.Equal(t, int(isDeleted), 1)
//Can be done this way too
assert.Equal(t, isDeleted, int64(1))
}
We can run individual tests in user_model_test.go. To run TestFindAllUsers, simply do:
go test --run TestFindAllUsers
Get a more detailed output by attaching the -v flag:
go test -v --run TestFindAllUsers
ii. post_model_test.go
Let’s also create tests for the methods in the Post model. Create the file post_model_test.go in the path same as the model_test.go: tests/modeltests
This is the content:
Let's run one test:
go test -v --run TestUpdateAPost
Output:
Let’s make a test FAIL so that you can see what it looks like for a failing test. For the TestUpdateAPost test, I will change this assertion:
assert.Equal(t, updatedPost.ID, postUpdate.ID)
to
assert.Equal(t, updatedPost.ID, 544) //where 544 is invalid
Now, when we run this test, it will fail:
You can see that line:
post_model_test.go:100 1 does not equal 544
Running all tests in the modeltests package:
To run the test suite in the modeltests package, make sure in your terminal, you are in the path: tests/modeltests .
Then run:
go test -v
All tests in the modeltests package are run and all passed.
b. Controllers Tests
In the tests paths: /tests, we will create a package called controllertests, which is in the same directory path as modeltests.
mkdir controllertests
Next, will we create the controller_test.go file where we will define the TestMain() which loads the .env file, setup database connection, and call seeder functions.
In the path: tests/controllertests
vi controller_test.go
i. login_controller_test.go
We will use TABLE TEST here. The idea behind the table test is that all possible cases defined in a struct, which uses a loop to run each test case. This saves us a lot of time as we tend to test all cases for a particular functionality with just one test function.
In the same directory path as controller_test.go, we create a file
touch login_controller_test.go
With the content:
Running the TestLogin:
go test -v --run TestLogin
ii. user_controller_test.go
This test file extensively uses TABLE TEST to test all possible cases, rather creating separate test functions for each case, hence saving us a lot of time.
In the same path as controller_test.go, create the user_controller_test.go file
touch user_controller_test.go file
Let’s run one test function inside that file to demonstrate.
Running TestCreateUser
go test -v --run TestCreateUser
Observe this:
This is one of the cases in our table test:
{ inputJSON: `{"nickname":"Pet", "email": "grand@gmail.com", "password": "password"}`,statusCode: 500,errorMessage: "Nickname Already Taken", },
Since this test hits the database and identified that the Nickname already exist and cannot insert that record, an error is returned, this is exactly what we want in that test. Hence the test passed. The same thing applies when the email is duplicated.
iii. post_controller_test.go
TABLE TEST is also extensively used in this file. In the same directory as controller_test.go, create the post_controller_test.go file
touch post_controller_test.go
With the content:
In this test file, we can run TestDeletePost function
go test -v --run TestDeletePost
Running all tests in the controllertests package
We can also run the test suite for the controllertests package.
In the path tests/controllertests run:
go test -v
All Tests passed. Feels Good!
c. Running the Entire Tests in the app.
At this point, your folder structure should look like this:
Wrapping up.
Frankly, I did a lot of research to put this article together. Because of writing test cases, I had to change the entire app structure. This was really a pain in the butt. I think is all worth it now, testing every single function/method ships a better software. Really, I feel good.
What Next?
- Dockerizing the application(Part 2 of the article here)
- Deploying on Kubernetes(Part 3 of the article here)
- Integrate Travis, Code Climate and Coveralls
- Deploy the application on AWS, Digital Ocean or Heroku.
- Consuming the API with React/Vue.
I actually summed everything together in this article here. Where I built a Forum App using Gin Framework. React is used as the Frontend Stack.
You can follow me on medium to get updates. You can also follow me on twitter
Get the source code for this article on github