Building API with Test-Driven Development in Go: A Practical Guide | Part 1

Benediktus Satriya
CodeX
Published in
7 min readMay 7, 2024

What is Test Driven Development (TDD)?

Test-Driven Development (TDD) is a software development process where you write tests for your code before you write the actual implementation. The process typically follows these steps:

  1. Write a Test: Before writing any code, you first write a test that describes the behavior you want to implement. This test will initially fail because the corresponding code hasn’t been written yet.
  2. Run the Test: Execute all the tests you’ve written so far. Since the new test is supposed to fail (as you haven’t implemented the feature yet), it should indeed fail. This ensures that the test is valid and that it’s actually testing what you intend it to.
  3. Write the Code: Now you write the code necessary to make the test pass. You’re essentially writing code to fulfill the requirements defined by the test.
  4. Run the Tests Again: After writing the code, you rerun all the tests. If everything is implemented correctly, the new test you just wrote should now pass, along with all the existing tests. If any test fails, it means either your new code is incorrect or your test is flawed.
  5. Refactor: Once all tests pass, you may refactor your code. Refactoring involves restructuring your code without changing its external behavior.

This iterative cycle continues: write a test, watch it fail, write code to make it pass, and then refactor. This process helps to ensure that your codebase remains maintainable, as it encourages small. It also helps in catching bugs early in the development process, leading to more robust and reliable software.

source image: https://marsner.com/blog/why-test-driven-development-tdd/

Prerequisites

Before we begin, ensure that you have Go installed on your machine. If not, you can download and install it from the official Go website (golang.org).

Additionally, familiarity with RESTful API development in Go would be beneficial. If you haven’t yet explored this topic, feel free to read my article for insights.

Deep Dive into Go: Crafting a CRUD RESTful API Without Frameworks

Setup Project

To get started, let’s create a new directory for our project. Open your terminal and run the following commands:

mkdir tdd
cd tdd
go mod init tdd

This will create a go.mod file in your project directory, enabling Go modules for managing dependencies.

To install dependencies for testing and the PostgreSQL driver, you can use Go modules. Open your terminal and execute the following commands:

go get github.com/stretchr/testify

go get github.com/lib/pq

These commands will fetch and install the testify library for testing and the lib/pq package, which is the PostgreSQL driver for Go.

Setup Database
Create a database named “tdd” in your local PostgreSQL.

sudo docker exec -it postgres16 createdb --username=root --owner=root tdd

Now, let’s create a table in your PostgreSQL database. We’ll create a simple table named users with two columns: id , username, and password .

CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(100) NOT NULL
);

Add this query to your migration up SQL, then run it.

migrate -path db/migrations -database "postgresql://root:root@localhost:5432/tdd?sslmode=disable" -verbose up

Nice! Now the database is all set.

Setup Database for Test Case

Create function TestMain(m *testing.M) is a special function in Go testing package that allows setup and teardown code to be run for all tests in a package. It’s typically used for common setup and teardown tasks.

database.go

type DB struct {
db *sql.DB
}

func NewDB(db *sql.DB) *DB {
return &DB{
db: db,
}
}

setup_test.go


var (
testDB *sql.DB
)

const (
postgresDNS = "postgres://root:root@localhost:5432/tdd?sslmode=disable"
)

func TestMain(m *testing.M) {
db, err := sql.Open("postgres", postgresDNS)
if err != nil {
log.Fatal(err)
}

err = db.Ping()
if err != nil {
log.Fatal(err)
}

testDB = db

// Truncate the users table before running the tests
_, err = testDB.Exec("TRUNCATE users")
if err != nil {
log.Fatal(err)

}

code := m.Run()

// Close the database connection after running the tests
err = testDB.Close()
if err != nil {
log.Fatal(err)
}

os.Exit(code)
}

This setup ensures that before running any tests, a clean database state is established with an empty users table, and after running the tests, the database connection is properly closed. This helps in isolating tests and ensuring that they don't interfere with each other's state.

Writing Your First Test

In our first simple case, we create a test for user creation. We assume that if we input correct data, the result should match what we inserted previously.

slqstore/create_user_test.go


func TestCreateUser(t *testing.T) {

sqlStore := NewDB(testDB)

user := &domain.User{
Username: "test",
Password: "password",
}

createdUser, err := sqlStore.CreateUser(user)

assert.Nil(t, err)
assert.NotNil(t, createdUser)
assert.Equal(t, user.Username, createdUser.Username)
assert.Equal(t, user.Password, createdUser.Password)
}

We know this should throw an error because we haven’t created a struct for domain.User and the CreateUser method yet. But it’s okay, let’s run it. After all, this is TDD! 😄

Awesome, we got a message from machine compiler. Now lets fix it!

domain/model.go

type User struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
}

sqlstore/user.go


var (
sqlCreateUser = `INSERT INTO users (username, password) VALUES ($1, $2) RETURNING id`
)


func (d *DB) CreateUser(user *domain.User) (*domain.User, error) {

// Query create user and then scan the id
err := d.db.QueryRow(sqlCreateUser, user.Username, user.Password).
Scan(&user.ID)

// Return error if any
if err != nil {
return nil, err
}

// Return the user
return user, nil
}

Now, let’s run the test again.

Great! The test passed.

If you encounter an error or the test doesn’t pass, you should fix it until it passes. Then, you can proceed to create a new test case.

Second Test Case

I want my API to throw an error if a user submits a username that already exists in the database.

func TestCreateUserDuplicateUsername(t *testing.T) {
sqlStore := NewDB(testDB)

user := &domain.User{
Username: "test",
Password: "password",
}

// Create a user
_, err := sqlStore.CreateUser(user)
assert.Nil(t, err)

// Create another user with the same username
createUserTwo, err := sqlStore.CreateUser(user)

assert.NotNil(t, err)
assert.Nil(t, createUserTwo)
}

Now, let’s run the test.

What! Why no error? Oh, it’s because we created the table with the unique keyword. The database automatically throws an error if we try to insert the same username twice since usernames cannot be duplicated.

But in TDD, we should create a failing test; that test is not valid. Now let’s adjust the test until it fails.


func TestCreateUserDuplicateUsername(t *testing.T) {
// ... your code ...

assert.NotNil(t, err)
// Added this, Check if the error should equal with ErrDuplicateUsername
assert.Equal(t, err, domain.ErrDuplicateUsername)
assert.Nil(t, createUserTwo)
}

Okay, we received the message. Let’s fix it again.

Add custom errors
domain/errors.go

import "errors"

var (
ErrDuplicateUsername = errors.New("duplicate username")
)

Fix the error handle on method CreateUser
sqlstore/user.go

func (d *DB) CreateUser(user *domain.User) (*domain.User, error) {
// your code ..

// Handle unique pqError code with 23505 is unique constraint violation
pgErr, ok := err.(*pq.Error)
if ok && pgErr.Code == "23505" {
// Handle unique constraint violation error here
// For example, you can return a custom error indicating the username is already taken
return nil, domain.ErrDuplicateUsername
}

// your code ...
}

Cool, all passed.

However, if we notice that the coverage is less than 100%, it’s because we haven’t written tests to cover all possible error scenarios, excluding the case of duplicate unique fields.

Now, let’s create the tests to achieve 100% coverage and handle all possible scenarios.


func TestThrowErrorInvalidSQL(t *testing.T) {
oldSql := sqlCreateUser

sqlStore := NewDBTest(testDB)

user := &entity.User{
Username: "bene",
Password: "password",
}

// Inject invalid SQL
sqlCreateUser = `invalid sql query`

createdUser, err := sqlStore.CreateUser(user)
assert.Nil(t, createdUser)
assert.NotNil(t, err)

// Reset the sqlCreateUser
sqlCreateUser = oldSql
}

Let’s run the tests with coverage, and it’s cool to see that we have achieved 100% coverage.

Congratulations! We’ve successfully built a repository for storing data in PostgreSQL using test-driven development. Additionally, we’ve learned how to achieve 100% test coverage in Go.

This article isn’t finished yet as we haven’t built the API.

For part 2, I’ll be working on it soon. Have a great day!

The source code you can access is https://github.com/benebobaa/test-driven-development.

--

--