Testing is a crucial aspect of software development, ensuring reliability, functionality, and performance before deployment. In this blog, we will explore how to test a Golang CRUD application on Bitbucket using Docker for the database and dependencies. We will approach it from both a developer’s and a DevOps perspective, covering unit tests, integration tests, CI/CD automation, and best practices.
Overview of the Application
We will use a Golang CRUD application that utilizes GraphQL, PostgreSQL, and the go-chi router. The app has two main entities:
AuthorPost
The application supports operations like creating, reading, updating, and deleting (CRUD) authors and posts.
Key Technologies Used:
- Golang: The programming language.
- PostgreSQL: The database running via Docker.
- GraphQL: The API query language.
- Docker and Docker Compose: For containerizing the application and its dependencies.
- Bitbucket Pipelines: For automated testing and deployment.
Step 1: Writing Unit Tests
Unit tests ensure that individual components function correctly. We will use Go’s testing package (testing) to write unit tests for our CRUD operations, alongside Keploy UTG.
1.1 Setting Up Testing in Go
1.2 Sample Unit Test for Panic and Error
The two tests below cover the edges rather than the happy path. TestCheckErr confirms that our checkErr helper actually panics when handed an error, using a deferred recover() to catch it. TestMainFunction starts main() in a goroutine and makes sure the application boots without panicking. These are quick sanity checks that catch the kind of failure that takes a whole service down at startup.
package main
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/graphql-go/graphql"
"github.com/graphql-go/handler"
_ "github.com/lib/pq"
)
// Test generated using Keploy
func TestCheckErr(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Errorf("The code did not panic")
}
}()
checkErr(fmt.Errorf("test error"))
}
// Test generated using Keploy
func TestMainFunction(t *testing.T) {
go func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("The code panicked: %v", r)
}
}()
main()
}()
time.Sleep(3 * time.Second)
}
Create Author Test
The next test is the substantial one, and it exercises the full create path end to end. It connects to Postgres, creates an authors table, defines the GraphQL schema with an Author type and a createAuthor mutation, spins up a test server with httptest, fires a real mutation over HTTP, and then asserts on both the response and the database row that should have been written. A deferred cleanup drops the table afterward so each run starts fresh. Reading it top to bottom is a good way to see how a GraphQL resolver, an HTTP handler, and a database all fit together in one test.
// Test generated using Keploy
func TestCreateAuthor(t *testing.T) {
dbInfo := "host=localhost port=5432 user=postgres password=password dbname=postgres sslmode=disable"
db, err := sql.Open("postgres", dbInfo)
if err != nil {
t.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
setupSQL := `
CREATE TABLE IF NOT EXISTS authors (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
);
`
_, err = db.Exec(setupSQL)
if err != nil {
t.Fatalf("Failed to create authors table: %v", err)
}
defer func() {
_, err := db.Exec("DROP TABLE IF EXISTS authors;")
if err != nil {
t.Logf("Failed to drop authors table: %v", err)
}
}()
authorType := graphql.NewObject(graphql.ObjectConfig{
Name: "Author",
Description: "An author",
Fields: graphql.Fields{
"id": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Description: "The identifier of the author.",
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
if author, ok := p.Source.(*Author); ok {
return author.ID, nil
}
return nil, nil
},
},
"name": &graphql.Field{
Type: graphql.NewNonNull(graphql.String),
Description: "The name of the author.",
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
if author, ok := p.Source.(*Author); ok {
return author.Name, nil
}
return nil, nil
},
},
"email": &graphql.Field{
Type: graphql.NewNonNull(graphql.String),
Description: "The email address of the author.",
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
if author, ok := p.Source.(*Author); ok {
return author.Email, nil
}
return nil, nil
},
},
"created_at": &graphql.Field{
Type: graphql.NewNonNull(graphql.String),
Description: "The created_at date of the author.",
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
if author, ok := p.Source.(*Author); ok {
return author.CreatedAt, nil
}
return nil, nil
},
},
},
})
rootQuery := graphql.NewObject(graphql.ObjectConfig{
Name: "RootQuery",
Fields: graphql.Fields{
"placeholder": &graphql.Field{
Type: graphql.String,
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
return "placeholder", nil
},
},
},
})
rootMutation := graphql.NewObject(graphql.ObjectConfig{
Name: "RootMutation",
Fields: graphql.Fields{
"createAuthor": &graphql.Field{
Type: authorType,
Description: "Create new author",
Args: graphql.FieldConfigArgument{
"name": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
"email": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
},
Resolve: func(params graphql.ResolveParams) (interface{}, error) {
name, _ := params.Args["name"].(string)
email, _ := params.Args["email"].(string)
createdAt := time.Now().UTC()
var lastInsertID int
err := db.QueryRowContext(params.Context, "INSERT INTO authors(name, email, created_at) VALUES($1, $2, $3) returning id;", name, email, createdAt).Scan(&lastInsertID)
if err != nil {
return nil, err
}
newAuthor := &Author{
ID: lastInsertID,
Name: name,
Email: email,
CreatedAt: createdAt,
}
return newAuthor, nil
},
},
},
})
schema, err := graphql.NewSchema(graphql.SchemaConfig{
Query: rootQuery,
Mutation: rootMutation,
})
if err != nil {
t.Fatalf("Failed to create schema: %v", err)
}
h := handler.New(&handler.Config{
Schema: &schema,
Pretty: true,
})
r := chi.NewRouter()
r.Handle("/graphql", h)
ts := httptest.NewServer(r)
defer ts.Close()
mutation := `
mutation {
createAuthor(name: "Test Author", email: "test@example.com") {
id
name
email
}
}
`
requestBody := map[string]interface{}{
"query": mutation,
}
jsonBody, err := json.Marshal(requestBody)
if err != nil {
t.Fatalf("Failed to marshal JSON: %v", err)
}
resp, err := http.Post(ts.URL+"/graphql", "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
t.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if errors, exists := result["errors"]; exists {
t.Fatalf("GraphQL errors: %v", errors)
}
data, exists := result["data"].(map[string]interface{})
if !exists {
t.Fatalf("No data in response")
}
authorData, exists := data["createAuthor"].(map[string]interface{})
if !exists {
t.Fatalf("No createAuthor data in response")
}
if authorData["name"] != "Test Author" {
t.Errorf("Expected author name 'Test Author', got %v", authorData["name"])
}
if authorData["email"] != "test@example.com" {
t.Errorf("Expected author email 'test@example.com', got %v", authorData["email"])
}
if _, exists := authorData["id"]; !exists {
t.Errorf("No id returned for created author")
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM authors WHERE email = $1", "test@example.com").Scan(&count)
if err != nil {
t.Fatalf("Failed to query database: %v", err)
}
if count != 1 {
t.Errorf("Expected 1 author in database, found %d", count)
}
}
Similarly, we can write test cases for reading, updating, and deleting authors and posts, following the same pattern of setting up state, exercising the operation, and asserting on both the response and the database.
Step 2: E2E API Testing
Unit tests confirm each piece works. E2E API tests confirm the pieces work together, checking that a real request flows correctly through the router, the resolver, and the database. Rather than writing these by hand, we can record them from real traffic.
2.1 Sample API Test for GraphQL API
We can create our API testing cases with Keploy:
curl -O -L https://keploy.io/install.sh && source install.sh
Once Keploy is installed, we build the binary of our application:
go build -cover
With the binary ready, this command starts recording the API calls using eBPF:
sudo -E keploy record -c "./keploy-gql"
Now make some API calls using Hoppscotch, Postman, or the cURL command. Keploy captures those calls and generates test suites containing test cases and data mocks, similar to below. The key idea is that you exercise the API once, by hand, and Keploy turns that real interaction into a repeatable test.

Run the Testcases
Now let’s run the test mode (in the graphql-sql directory):
sudo -E keploy test -c "./keploy-gql" --delay 10
We can notice that our first test case failed due to a database configuration issue:

Our final test result will look like this:

With a couple of API calls, we reached up to 12.4% code coverage.
Collecting code coverage becomes more complex once an application runs inside a container, particularly when coverage tools need to preserve output and map it correctly back to the source code.
Step 3: Dockerizing the Application
Containerizing the app makes the test environment identical on your laptop and in the pipeline, which removes a whole category of "works on my machine" failures. We will create a Dockerfile and a docker-compose.yml file to containerize the application and its dependencies.
3.1 Dockerfile
FROM golang:1.18
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o main .
EXPOSE 8080
CMD ["./main"]
Creating the Docker Compose Configuration
The compose file brings up Postgres alongside the app and wires them together through environment variables, so a single command gives you a running database and application.
version: '3.8'
services:
db:
image: postgres
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres
ports:
- "5432:5432"
app:
build: .
depends_on:
- db
ports:
- "8080:8080"
environment:
DB_HOST: db
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: password
DB_NAME: postgres
Run the application using:
docker-compose up --build
Step 4: Setting Up Bitbucket Pipelines for Automated Testing
Bitbucket Pipelines automates testing and deployment in CI/CD workflows, so the tests we just wrote run on every push rather than only when someone remembers to run them.
4.1 Configure bitbucket-pipelines.yml
Create a .bitbucket-pipelines.yml file in the project root. The default pipeline runs the tests on every push, and the main branch gets an additional deployment step.
image: golang:1.18
pipelines:
default:
- step:
name: Run Tests
services:
- postgres
caches:
- go
script:
- go test ./...
branches:
main:
- step:
name: Deploy to Production
script:
- echo "Deploying to production..."
4.2 Adding a PostgreSQL Service in Pipelines
Because the tests need a database, we define Postgres as a pipeline service so it is available while the tests run.
definitions:
services:
postgres:
image: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres
This ensures that PostgreSQL is available when running tests.
Step 5: Running Tests in Bitbucket
Push the changes to Bitbucket and trigger a pipeline run:
git add .
git commit -m "Added tests and Bitbucket Pipelines"
git push origin main
Bitbucket will automatically run the tests defined in bitbucket-pipelines.yml, and you will see the results in the pipeline view for that commit.
Conclusion
Testing in Bitbucket Pipelines enhances development by:
- Ensuring Code Quality: Catch bugs before deployment.
- Automating Tests: Run tests automatically on each commit.
- CI/CD Integration: Deploy only tested and validated code.
By leveraging unit tests, integration tests, Docker, and Bitbucket Pipelines, we can efficiently test and deploy our Golang CRUD application with confidence, catching regressions early instead of in production.
FAQs
1. What is Bitbucket Pipelines?
Bitbucket Pipelines is a CI/CD tool built into Bitbucket that automates testing and deployment directly from your repository, running a defined set of steps on every push.
2. Why use Docker in testing?
Docker ensures a consistent environment across development, testing, and production, so tests behave the same way locally and in the pipeline and you avoid environment-specific failures.
3. How do I debug a failing test in Bitbucket?
Use your bitbucket-pipelines.yml to print logs and enable verbose test output, which shows exactly which assertion failed:
script:
- go test -v ./...
4. Can I run pipelines locally before pushing to Bitbucket?
Yes. Use the Bitbucket Pipelines Runner, or simply run the tests locally before committing:
go test ./...
5. How do I integrate coverage reports in Bitbucket?
Modify the pipeline to generate coverage reports:
script:
- go test -cover ./...
This helps track test coverage as part of your CI/CD workflow.

Leave a Reply