Learn how to set up automated testing on Bitbucket for a Golang CRUD app using Docker, Docker Compose, and CI/CD for efficient DevOps workflows.

Automate Testing on BitBucket for Golang CRUD App with Docker

by

in
Table of Contents

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:

  • Author
  • Post

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.

Keploy captured test cases

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:

A failing test case

Our final test result will look like this:

The final test run

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:

  1. Ensuring Code Quality: Catch bugs before deployment.
  2. Automating Tests: Run tests automatically on each commit.
  3. 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.

Author

  • Animesh Pathak

    Animesh Pathak is a developer specializing in backend systems and API-driven architectures. He focuses on improving application performance and building robust, scalable solutions.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *