All programmers should consider writing unit tests while writing code. If the code is not unit test friendly, it will be tricky to write proper unit tests. For example, writing a unit test for the function below is difficult because the function doesn't use dependency injection and creates an instance of a repository inside it.
func GetPersonNames() ([]Person, error) {
fmt.Println("Inside Get Person function")
dbOperation := databaseLayer.NewDatabaseOperation()
// repository
repo := databaseLayer.NewRepository(dbOperation)
// Query to select data from the database table
query := `SELECT "PersonId", "FirstName", "LastName", "CreatedDate", "UserId"
FROM learngo."Person"`
rows, err := repo.ExecuteSelect(query)
....
...
}
Now, modify the function to use the dependency injection pattern. Injecting the repository into the function makes the code more unit-test-friendly. This way, we can easily mock the repository and inject it into the function, allowing us to write unit tests without involving a real database connection.
func GetPersonNames(repo *databaseLayer.Repository) ([]Address, error) {
query := `SELECT "PersonId", "FirstName", "LastName", "CreatedDate", "UserId"
FROM learngo."Person"`
rows, err := repo.ExecuteSelect(query)
....
...
}
Observe the difference between the two methods above. In the first method, we can't use a mocked repository. In the second method, however, we can simply inject the mocked repository to write the unit test.
Go Frameworks and libraries for Testing
There are several libraries available in Go to help with writing unit tests and creating mocks. We will use and discuss some of them here.
gomockgomock is a mocking framework for Go used to mock Go interfaces for their implementation for testing purposes.
To add the gomock library/package, please run the following command:
$ go get github.com/golang/mock/gomock
mockgen: Mock generator
mockgen is a tool to generate mock files for Go testing. It generates mock files based on the interface definitions in your Go code. To install the mockgen tool, use the following command:
$ go install github.com/golang/mock/mockgen@v1.6.0
After installing the mockgen library, you will be able to generate mock files. In this exercise, we will generate a mock file for db_repository, where we are connecting to the database and executing queries. For testing, we will use a mocked connection and result instead of the actual database.
To verify that mockgen is installed and available for use, run the below command
$ mockgen --version
If you encounter the error "mockgen: command not found," add the Go binary path to your system's PATH. Generally, the Go binaries are located in $HOME/go/bin.
Use the below steps to update the go binary PATH:
Step 1. Open the .bashrc file in edit mode
$nano ~/.bashrc
Step 2. Add the following line to the file
export PATH=$PATH:$HOME/go/bin
Step 3: Save the file and reload
$source ~/.bashrc
Now, mockgen is ready to generate the mock file. We will see how to generate the mock file later in the exercise.
sqlmock
sqlmock is a library built to mock the SQL interaction from the code. It mocks the database driver simulates the interaction with the database and returns the mocked data as defined i.e. executing the queries, stored procedures, etc without connecting to the actual database for testing the code.
We can easily integrate the sqlmock with other testing frameworks in Go like testify, gomock, testing, etc.
Command to install go-sqlmock library/package:
$go get github.com/DATA-DOG/go-sqlmock
testify
Testify is another popular testing framework widely used in Go testing. It provides several features and libraries to write unit tests in Go. Testify integrates with mock libraries like gomock or mockgen, enhancing its capabilities for writing reliable and maintainable tests.
Testify has several features to support writing reliable and maintainable tests.
Assertion: Testify facilitates adding assertions in unit tests to verify the actual output against the expected output.
Test Suite: It enables the grouping of tests, allowing for better organization and structuring of test code.
Mock Support: Testify supports popular mock libraries like gomock, mockgen, etc., providing flexibility in mocking dependencies for testing.
To install testify, use the below command
$go get github.com/stretchr/testify
After installing Testify, you'll have access to several important packages for testing:
github.com/stretchr/testify/assert: Provides assertion functions for writing test assertions.
github.com/stretchr/testify/require: Similar to assert, but stops test execution immediately upon failure.
github.com/stretchr/testify/mock: Support for mocking dependencies in tests
github.com/stretchr/testify/suite: Enables the creation of test suites for organizing related tests.
testing
The Go package testing provides support for automated testing in golnag program. the package comes with the comment $go test, to execute the unit test written in the package.
Code Coverage
High code coverage is essential for building software intended to run for several years. A higher code coverage ensures that the code is properly tested and covers all possible execution paths. While ideally, the coverage should be 100%, there may be some exceptions where certain parts of the code cannot be mocked or reproduced in the test environment.
Go provides built-in functionality to evaluate code coverage after running test cases. You can use the command $
go test -cover to execute tests and analyze code coverage. After running this command, you will see the test execution results along with the coverage report.
This command helps developers assess the effectiveness of their tests and identify areas of the code that need more testing. By achieving the higher code coverage, developers can increase confidence in the reliability and stability of their software over time.
Example:
Continue from the Part 10 of this series(
Mastering Go: Part 10 - Writing Web API in Golang)
In this example we will write unit test for the function GetAddress from the stringformater module.
Step 1: If the code written is not unit test-friendly, then it's hard to write unit tests. Therefore, before starting to write unit tests, we need to fix or rewrite the code that is not unit test-friendly. I explained this at the beginning of this section and showed how to convert code to unit test-friendly code.
Modify address_formater.go and use dependency injection to inject the Repository into the function GetAddress.
Modify address_handler.go file to call the GetAddress function by injecting a repository instance.
After modification, the code will look like this:
address_formater.go
package stringformater
import (
"fmt"
databaseLayer "learngo/db_operation" // import data formatter package
)
// Address struct
type Address struct {
HouseNumber string
StreetName string
City string
State string
ZipCode string
}
func (a *Address) Format() string {
return a.HouseNumber + " " + a.StreetName + ", " + a.City + ", " + a.State + " " + a.ZipCode
}
// This function call DB operation and return the collection of address
func GetAddress(repo *databaseLayer.Repository) ([]Address, error) {
// Query to select data from the database table
query := `SELECT "HouseNumber", "StreetName", "City", "State", "ZipCode"
FROM learngo."Address"`
// Call the eecuteselect DB operation
rows, err := repo.ExecuteSelect(query)
if err != nil {
return nil, err
}
// Initialize a slice to store Address structs
var addresses []Address
// Iterate over the rows
for rows.Next() {
var address Address
// Scan the values into variables
err := rows.Scan(&address.HouseNumber, &address.StreetName, &address.City, &address.State, &address.ZipCode)
if err != nil {
return nil, fmt.Errorf("error scanning row: %v", err)
}
// Append the scanned address to the slice
addresses = append(addresses, address)
}
// Check for errors during iteration
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating over rows: %v", err)
}
// Return the list of address structs
return addresses, nil
}
address_handler.go
package handlers
import (
"fmt"
databaseLayer "learngo/db_operation" // import data formatter package
stringformater "learngo/string_formater" // import string formater package
"net/http"
"github.com/gin-gonic/gin"
)
// Get the address from the database and respond with the list of all addresses as JSON.
func GetAddress(c *gin.Context) {
// initializ DB operation
dbOperation := databaseLayer.NewDatabaseOperation()
// initialze the reporsitory injecting DatabaseOperations interface
repo := databaseLayer.NewRepository(dbOperation)
//get addresses
addresses, err := stringformater.GetAddress(repo)
if err != nil {
fmt.Println("Exception occured to get address")
panic(err)
}
c.IndentedJSON(http.StatusOK, addresses)
}
Step 2: Generate mock db operation
Note that we are using a DB connection to retrieve the address from the database. Since using the real database connection is not recommended for writing unit tests, we need to mock the DB activity.
Now, run the following command to generate a mock file for the db_operation.go file. If you have not completed the required library installation as described above, please do so before executing this command.
$mockgen -source=db_operation.go -destination=mocks/mock_db_operation.go -package=mocks
The autogenerated file content by mockgen looks like this:
// Code generated by MockGen. DO NOT EDIT.
// Source: db_operation.go
// Package mocks is a generated GoMock package.
package mocks
import (
sql "database/sql"
reflect "reflect"
gomock "github.com/golang/mock/gomock"
)
// MockDatabaseOperations is a mock of DatabaseOperations interface.
type MockDatabaseOperations struct {
ctrl *gomock.Controller
recorder *MockDatabaseOperationsMockRecorder
}
// MockDatabaseOperationsMockRecorder is the mock recorder for MockDatabaseOperations.
type MockDatabaseOperationsMockRecorder struct {
mock *MockDatabaseOperations
}
// NewMockDatabaseOperations creates a new mock instance.
func NewMockDatabaseOperations(ctrl *gomock.Controller) *MockDatabaseOperations {
mock := &MockDatabaseOperations{ctrl: ctrl}
mock.recorder = &MockDatabaseOperationsMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockDatabaseOperations) EXPECT() *MockDatabaseOperationsMockRecorder {
return m.recorder
}
// ExecuteSelect mocks base method.
func (m *MockDatabaseOperations) ExecuteSelect(query string) (*sql.Rows, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ExecuteSelect", query)
ret0, _ := ret[0].(*sql.Rows)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ExecuteSelect indicates an expected call of ExecuteSelect.
func (mr *MockDatabaseOperationsMockRecorder) ExecuteSelect(query interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteSelect", reflect.TypeOf((*MockDatabaseOperations)(nil).ExecuteSelect), query)
}
After generating the mocked file the project structure should look like this:
/Users/s********i/Documents/learngo/
├── db_operation/
│ ├── db_operations.go // Contains the DatabaseOperations interface
│ ├── db_repository.go // Contains the Repository struct and methods
│ ├── mocks/
│ │ └── mock_db_operations.go // Destination for the generated mock
└── go.mod
Step 3: Add test file and test case
Now add the test file named address_formater_test.go in the folder string_formater and add below code:
package stringformater
import (
"database/sql"
db_repository "learngo/db_operation"
"learngo/db_operation/mocks"
"reflect"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/golang/mock/gomock"
)
// Provide correct address data for address formatting
func Test_Format(t *testing.T) {
tests := []struct {
name string
address Address
expected string
}{
{
name: "standard address",
address: Address{
HouseNumber: "123",
StreetName: "Main St",
City: "Springfield",
State: "IL",
ZipCode: "62704",
},
expected: "123 Main St, Springfield, IL 62704",
},
{
name: "address with no state",
address: Address{
HouseNumber: "789",
StreetName: "Pine St",
City: "Atlanta",
State: "",
ZipCode: "30303",
},
expected: "789 Pine St, Atlanta, 30303",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.address.Format(); got != tt.expected {
t.Errorf("Address.Format() = %v, want %v", got, tt.expected)
}
})
}
}
// Unit test to test the getAddress function
func Test_GetAddress(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Get the instance of mock DB_operation
mockDB := mocks.NewMockDatabaseOperations(ctrl)
// Query to execute
query := `SELECT "HouseNumber", "StreetName", "City", "State", "ZipCode"
FROM learngo."Address"`
// Create the instance of SQLmock to fake execute the sql query
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("Error occured on creating mocked sql instance : '%s'", err)
}
defer db.Close()
// Prepare data for test
rows := sqlmock.NewRows([]string{"HouseNumber", "StreetName", "City", "State", "ZipCode"}).
AddRow("3800", "Market St", "Frederick", "MD", "21701").
AddRow("4500", "Walnut St", "Chevy Chase", "MD", "21901")
// Set the expectation of the query
mock.ExpectQuery(query).WillReturnRows(rows)
// set the mock expect for the execute select
mockDB.EXPECT().ExecuteSelect(query).DoAndReturn(func(query string) (*sql.Rows, error) {
return db.Query(query)
})
// Get repository using MOCKED DB
repo := db_repository.NewRepository(mockDB)
// Execute the GetAddress function with repo instance created with mocked DB
addresses, err := GetAddress(repo)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// set the expected data for comparison
expected := []Address{
{HouseNumber: "3800", StreetName: "Market St", City: "Frederick", State: "MD", ZipCode: "21701"},
{HouseNumber: "4500", StreetName: "Walnut St", City: "Chevy Chase", State: "MD", ZipCode: "21901"},
}
// Assert
if !reflect.DeepEqual(addresses, expected) {
t.Errorf("Test actual result got %v, Expected result: %v", addresses, expected)
}
}
Take some time to understand the above code, please carefully review:
- Observe how the test struct is created and looped through to run the tests for the address format function.
- Observe how the gomock controller is used to initiate the mocked db_operations instance.
- Observe the use of mocked db_operation in the db_repository to fake the database interaction inside the repository.
- Notice that we are suing go standard library reflect to compare the actual vs expected values in the test
Step 4: Run test caseExecuting test case in go is fairly simple, just need to run this command
S*****-MacBook-Pro:string_formater s********i$ go test
PASS
ok learngo/string_formater 0.251s
Step 5: Run test with code coverage
S*****-MacBook-Pro:string_formater s*******i$ go test -cover
PASS
coverage: 34.3% of statements
ok learngo/string_formater 0.246s
Step 6. Use assertion
If you prefer to use the assert package instead of comparing results using reflection, you can utilize the assert package provided by the testify framework. Now, modify the assertion part of the unit test Test_GetAddress. After modification, the code looks like below:
..........
// Execute the GetAddress function with repo instance created with mocked DB
addresses, err := GetAddress(repo)
assert.NoError(t, err, "unexpected error")
.............
.............
// Assert
assert.Equal(t, expected, addresses, "address slice mismatch")
Observe the code change above: we are now using the "assert" package instead of "reflect" to validate test case outputs. Utilizing the assert package makes the code cleaner and easier to implement.
Exercise Code Link: https://github.com/learnwithsharad/learngo/tree/sharad-AddUnitTests
Reference
https://pkg.go.dev/testing
https://pkg.go.dev/github.com/stretchr/testify@v1.9.0/assert
https://go.dev/doc/tutorial/add-a-test
No comments:
Post a Comment