Make debugging a breeze with Dependency Injection in GoLang
Dependency injection is the idea of removing dependencies from within a function and aids in building a more resilient codebase by doing so. Removing dependencies from a function also makes it easier to debug because tests are more straightforward to conduct. To better understand dependency injection, an example will be shown using test-driven development.
Example
Suppose we have a file called `user.json` containing a single user's information, and we want to get their age via a function.
Data structure
// user.json
{
"age":34
}
One might think the first step might be to create a `user.json` file to work with.
Function
type User struct {
Age int `json:"age"`
}
// GetUserData opens a file and returns a piece of datafunc GetUserData(fPath String) (String, error) {
userFile, err := os.Open(fPath)
if err != nil {
fmt.Println(err)
}
byteValue, _ := ioutil.ReadAll(userFile)
var u User
json.Unmarshal(byteValue, &u)
return u
}
Test Function
func TestGetUserData(t *testing.T) {
t.Parallel() got, _ := GetUserData("../test/resources/user.json")
s := fmt.Sprint(got)
if s == "" {
t.Errorf("Wanted %s got %s", got, s)
}
}
Dependencies
The above function works, and the test also works. However, the following dependencies have been introduced:
- File path
- Existence of user.json file in the codebase
At first glance, it seems like no big deal. However, this small decision adds up over time as the codebase grows.
If the pattern of supplying sample files for testing is established early on, it is likely to continue.
And if it continues, then there may end up being tens, if not at worst, hundred of fake test files.
That means unnecessary bloat and also additional work on maintenance of the fake test files.
Solution - Split the function's responsibilities apart
Remove the dependencies from the function. It should not be responsible for opening a file. Instead, the opening and returning of the file's data will be a function `GetFileData` , which will then be passed to the new function `GetUserAge`.
1) Get File Data
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"testing"
)
type User struct {
Age int `json:"age"`
}
func GetFileData(rdr io.Reader) ([]byte, error) {
contents, err := ioutil.ReadAll(rdr)
if err != nil {
log.Fatalf("Error reading data %v", err)
}
return contents, nil
}
func TestGetFileData(t *testing.T) {
t.Parallel() got, _ := GetFileData(bytes.NewBufferString(`{"age":34}`))
s := fmt.Sprint(got)
if s == "" {
t.Errorf("Wanted %s got %s", got, s)
}
}
2) GetUserAge
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"testing"
)
type User struct {
Age int `json:"age"`
}
func GetUserAge(data []byte) int {
u := &User{}
err := json.Unmarshal(data, u)
if err != nil {
log.Fatalf("JSON unmarshal error %v", err)
}
return u.Age
}
func TestGetAge(t *testing.T) {
t.Parallel() data, _ := GetFileData(bytes.NewBufferString(`{"age": 34}`))
got := GetUserAge(data)
want := 34 if got != want {
t.Errorf("Got %d want %d", got, want)
}
}
Final Solution
In the`GetUserAge` function, there's a declaration of the `User` structure, which is not what the service is responsible for. So instead, it can be refactored to be removed from the function.
As shown in the final result below. Again the idea is only to have code related to precisely what the function is supposed to do; anything else can be removed and passed in as an argument..
See it Live on GO playground
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"testing"
)
type User struct {
Age int `json:"age"`
}
// Declare an empty User struct for testing
var u = &User{}
func TestGetAge(t *testing.T) {
t.Parallel()
data, _ := GetFileData(bytes.NewBufferString(`{"age": 34}`))
got := GetUserAge(data,u)
want := 34
if got != want {
t.Errorf("Got %d want %d", got, want)
}
}
func TestGetFileData(t *testing.T) {
t.Parallel()
got, _ := GetFileData(bytes.NewBufferString(`{"age": 34}`))
s := fmt.Sprint(got)
if s == "" {
t.Errorf("Wanted %s got %s", got, s)
}
}
// Pass in a pointer to the userfunc
GetUserAge(data []byte, u *User) int {
err := json.Unmarshal(data, u)
if err != nil {
log.Fatalf("JSON unmarshal error %v", err)
}
return u.Age
}
func GetFileData(rdr io.Reader) ([]byte, error) {
contents, err := ioutil.ReadAll(rdr)
if err != nil {
log.Fatalf("Error reading data %v", err)
}
return contents, nil
}
Conclusion
Big Idea - The function is split into two, each with their own single responsibility. Allowing them to receive the data in its true form and not be responsible for dealing with file paths and files.