Crafting a Web app in Golang: Implementing the Service Layer

Crafting a Web app in Golang: Implementing the Service Layer

Tutorial: golang web application development

Hello all, today we will continue the development of our web application by implementing the part of invoice management.

We are going to implement the InvoiceService interface. We will learn also how we can use mocks to unit test our service.

The interface

For managing the invoices we have defined the following interface :

The interface has 3 methods:

  • Create: this method is responsible to create a new invoice

  • Get: returns the invoice with id

  • CreatePDF: Given an invoice id returns a PDF document

Let's get started

Create a new git branch

git checkout -b invoice-svc

Create a new package named invoices

mkdir invoices

and a file invoices/invoices.go .

and add the following contents in the newly created invoices.go .

package invoices

import (
    "context"
    "invoicehub"
)

var _ invoicehub.InvoiceService = (*invoiceSvc)(nil)

type invoiceSvc struct {
    repo invoicehub.InvoiceRepository
}

func New(repo invoicehub.InvoiceRepository) invoicehub.InvoiceService {
    return &invoiceSvc{repo}
}

func (svc *invoiceSvc) Create(ctx context.Context, invoice *invoicehub.Invoice) error {
    return nil
}

func (svc *invoiceSvc) Get(ctx context.Context, id int) (invoicehub.Invoice, error) {
    return invoicehub.Invoice{}, nil
}

func (svc *invoiceSvc) CreatePDF(ctx context.Context, id int) ([]byte, error) {
    return nil, nil
}

We haven't done much here actualy, just created a struct that implements the interface.

Let's now create the a file that we will add our tests:

and let's add placeholder for our tests:

package invoices_test

import "testing"

func Test_New(t *testing.T) {
}

func Test_invoiceSvc_Create(t *testing.T) {
}

func Test_invoiceSvc_Get(t *testing.T) {
}

func Test_invoiceSvc_CreatePDF(t *testing.T) {
}

How to use mocks

In order to write our tests we need to be able to provide a "mock" implementation of the InvoiceRepository . This will help us to test the behavior of our implementation without having to rely on a real database.

When we created our project layout we created a package called mocks that we haven't used so far.

We are going to utilize the package mock and go generate to create mocks for our interfaces.

Setting up mock generator

first install the required package:

go install go.uber.org/mock/mockgen@latest
go get go.uber.org/mock/mockgen/model

and add the following in our Makefile:

Now we can add a special comment line above the definition of the interfaces we want to mock.

The comment has the following format:

//go:generate mockgen -destination=mocks/mock_*.go -package=mocks . InternfaceName

So let's open the file invoices.go and above the definition of the InvoiceRepository:

Now run :

make gen

this will generate a file: mocks/mock_invoice_repo.go

If you open the file you will see that it generated a struct with name MockInvoiceRepository that implments the InvoiceRepository interface .

Each time you add a method in the InvoiceRepository you have to run the make gen command to re-generate the mock implementation.

Now let's open the file invoices/invoices_test.go` and add the following:

the above shows how we were able to initialize the a invoice service using a mock implementation of the InvoiceRepository.

Unit Tests and Implementation

We will now implement our interface's methods one by one.

We start from the Create method.

Our create method should work as following:

we will pass an Invoice struct as an argument and this method will be responsible to :

calculate the InvoiceNumber based on the IssueDate and the last invoice Number of the current year. If an invoice does not exist for the current year we start with invoice number 1001 ๐Ÿ˜„ .

Then we should save the newly created invoice in the database .

Let's do that :

func (svc *invoiceSvc) Create(ctx context.Context, invoice *invoicehub.Invoice) error {
    issueYear := invoice.IssueDate.Year()

    lastInvoice, err := svc.repo.GetLastInvoiceForYear(ctx, issueYear)
    if err != nil {
        return err
    }

    parts := strings.Split(lastInvoice.InvoiceNumber, "/")
    if len(parts) != 2 {
        return errors.New("invalid invoice number")
    }

    lastInvoiceNumber, err := strconv.Atoi(parts[0])
    if err != nil {
        return err
    }

    invoice.InvoiceNumber = fmt.Sprintf("%d/%d", lastInvoiceNumber+1, issueYear)

    if _, err := svc.repo.Create(ctx, invoice); err != nil {
        return err
    }

    return nil
}

Now we have to write some tests to see if this works

in your invoices/invoices_test.go :

Here I have added some test cases . This is not 100% covers all the cases but it's good enough for now.

Let's first implement the first case:

    t.Run("when there is no last invoice for the current year", func(t *testing.T) {
        inv := &invoicehub.Invoice{
            IssueDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
        }

        err := svc.Create(context.Background(), inv)
        require.NoError(t, err)
    })

Now let's run the test and see what it will happen:

go test -v ./... -run=Test_invoiceSvc_Create/when_there_is_no_last_invoice_for_the_current_year

and we get:

The error tells us that our method called the method:
*mocks.MockInvoiceRepository.GetLastInvoiceForYear

and this was not expected .

We need to instruct the test what the call to this method will return .

But wait a second...What our repository returns when a record is not found in the database?

It returns a gorm.ErrNotFound error . We could return that BUT this will leak implemetation details to our service layer. We have to define a custom error in our domain that we can utilize.

Open the invoice.go and add on top:

Now open the sqlite/invoice.go and return that error when an invoice is not found.
We have to do that in two places:

Let's also modify the InvoiceRepository's tests to check that when an invoice is not found it returns that error:

In the func Test_invoiceRepository(t *testing.T) {

when an invoice is not found assert that the error is of this type:

and in the bottom:

Now let's run that test to make sure it works:

go test -v ./... -run=Test_invoiceRepository

Now we can proceed with writing the test for the Create method of InvoiceRepository:

    t.Run("when there is no last invoice for the current year", func(t *testing.T) {
        inv := &invoicehub.Invoice{
            IssueDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
        }

        repo.EXPECT().GetLastInvoiceForYear(gomock.Any(), 2024).Return(invoicehub.Invoice{}, invoicehub.ErrInvoiceNotFound)

        err := svc.Create(context.Background(), inv)
        require.NoError(t, err)
    })

But our test still fails:

Why is that?

in our implementation we do that:

    lastInvoice, err := svc.repo.GetLastInvoiceForYear(ctx, issueYear)
    if err != nil {
        return err
    }

But, when there is no invoice in the database we return an ErrInvoiceNotFound.

In our requirements we have that when a invoice for this year does not exist then we should generate a new invoice number starting with 1001.

Let's fix it:

So now we check for that error and create an invoiceNumber as we need.

But again the test still fail ๐Ÿ˜Ÿ

Ok , this is good actually . We know what is going on. We just call the repo.Create method but we don't mock it as above .

The final code for that test case becomes:

    t.Run("when there is no last invoice for the current year", func(t *testing.T) {
        inv := &invoicehub.Invoice{
            IssueDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
        }

        repo.EXPECT().GetLastInvoiceForYear(gomock.Any(), 2024).Return(invoicehub.Invoice{}, invoicehub.ErrInvoiceNotFound)
        repo.EXPECT().Create(gomock.Any(), inv).Return(1, nil)

        err := svc.Create(context.Background(), inv)
        require.NoError(t, err)
    })

Now let's quickly complete the remaining test cases.
Our test now looks like:

func Test_invoiceSvc_Create(t *testing.T) {
    mctrl := gomock.NewController(t)
    defer mctrl.Finish()

    repo := mocks.NewMockInvoiceRepository(mctrl)

    svc := invoices.New(repo)

    t.Run("when there is no last invoice for the current year", func(t *testing.T) {
        inv := &invoicehub.Invoice{
            IssueDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
        }

        repo.EXPECT().GetLastInvoiceForYear(gomock.Any(), 2024).Return(invoicehub.Invoice{}, invoicehub.ErrInvoiceNotFound)
        repo.EXPECT().Create(gomock.Any(), inv).Return(1, nil)

        err := svc.Create(context.Background(), inv)
        require.NoError(t, err)

        require.Equal(t, "1001/2024", inv.InvoiceNumber)
    })

    t.Run("when there is a last invoice for the current year", func(t *testing.T) {
        inv1 := &invoicehub.Invoice{
            IssueDate:     time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
            InvoiceNumber: "1001/2024",
        }

        invNew := &invoicehub.Invoice{
            IssueDate: time.Date(2024, 2, 5, 0, 0, 0, 0, time.UTC),
        }

        repo.EXPECT().GetLastInvoiceForYear(gomock.Any(), 2024).Return(*inv1, nil)

        repo.EXPECT().Create(gomock.Any(), invNew).Return(2, nil)

        err := svc.Create(context.Background(), invNew)
        require.NoError(t, err)

        require.Equal(t, "1002/2024", invNew.InvoiceNumber)
    })

    t.Run("when there is a database error while getting the last invoice", func(t *testing.T) {
        inv := &invoicehub.Invoice{
            IssueDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
        }

        repo.EXPECT().GetLastInvoiceForYear(gomock.Any(), 2024).Return(invoicehub.Invoice{}, errors.New("something went wrong"))

        err := svc.Create(context.Background(), inv)
        require.Error(t, err)
    })

    t.Run("when we cannot save the invoice", func(t *testing.T) {
        invoice := &invoicehub.Invoice{
            IssueDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
        }

        repo.EXPECT().GetLastInvoiceForYear(gomock.Any(), 2024).Return(invoicehub.Invoice{}, invoicehub.ErrInvoiceNotFound)
        repo.EXPECT().Create(gomock.Any(), invoice).Return(0, errors.New("something went wrong"))

        err := svc.Create(context.Background(), invoice)
        require.Error(t, err)
    })
}

Verify that the tests pass:

go test -v ./...

The Get method

We have to implement the Get method of the InvoiceService now.

We start again from the unit tests and then we will proceed with the implementation.

func Test_invoiceSvc_Get(t *testing.T) {
    mctrl := gomock.NewController(t)
    defer mctrl.Finish()

    repo := mocks.NewMockInvoiceRepository(mctrl)

    svc := invoices.New(repo)

    t.Run("when the invoice is found", func(t *testing.T) {
        expected := invoicehub.Invoice{
            ID:            1,
            InvoiceNumber: "1001/2024",
            IssueDate:     time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
            SellerID:      1,
            BuyerID:       2,
            DaysToPay:     14,
        }

        repo.EXPECT().Get(gomock.Any(), 1).Return(expected, nil)

        inv, err := svc.Get(context.Background(), 1)
        require.NoError(t, err)

        require.Equal(t, expected, inv)
    })

    t.Run("when the invoice is not found", func(t *testing.T) {
        repo.EXPECT().Get(gomock.Any(), 1).Return(invoicehub.Invoice{}, invoicehub.ErrInvoiceNotFound)

        _, err := svc.Get(context.Background(), 1)
        require.Error(t, err)
        require.ErrorIs(t, err, invoicehub.ErrInvoiceNotFound)
    })

    t.Run("when there is a database error", func(t *testing.T) {
        repo.EXPECT().Get(gomock.Any(), 1).Return(invoicehub.Invoice{}, errors.New("something went wrong"))

        _, err := svc.Get(context.Background(), 1)
        require.Error(t, err)
    })
}

The implementation is pretty straightforward here we just call the repo Get method.

Let's commit our work:

git add .
git commit -m "partial implementation of the InvoiceService"

Conclusion

In today's blog post we continued the implementation of our Invoice management web application. The new thing that we discussed today is how to use mocks in golang to assist you with unit testing.

Find all the code in the github repo .

In the next blog post we will continue the implementation of the InvoiceService by adding the required function to generate PDF using golang.

โค๏ธ In case you found this article useful hit the Like button and follow me on X .

๐Ÿ’ก
How do you do mock in Go? Please leave a comment
ย