Skip to main content

Test your HTTP server

In this tutorial, you'll create an integration test for a money exchange service that exposes two endpoints:

  • /euro/:amount/:currency
  • /euro-at-date/:amount/:currency/:date

These convert a given amount in a source currency to its equivalent in euros or to its equivalent in euros using the exchange rate at a given date, respectively.

To test this application, one needs to be able to issue calls to both endpoints, and check their results for correctness. Gosoline offers plenty of help with this.

info

This tutorial is about testing, so you won't build the app here. However, if you'd like to learn how to build it, check out the dedicated tutorial.

Before you begin

Before you begin, make sure you have Golang installed on your machine.

You'll also need to download the sample code for the service:

git clone https://github.com/justtrackio/gosoline.git
cp -R gosoline/docs/docs/quickstart/http-server/src/create-a-money-exchange-app money-exchange

Each Gosoline integration test follows the same format:

  • Creates an object which implements TestingSuite
  • Implements the SetupSuite method for that object
  • Has at least one Test... method
  • It calls suite.Run

Set up your file structure

First, in the same directory that you copied in the previous step, you need to set up the following file structure. Most of these files are already defined; you just need to add two more:

money-exchange/
├── server_test.go
├── fixtures.go
├── handler.go
├── definer.go
├── config.dist.yml
└── main.go

For example, in Unix, run:

cd money-exchange
touch server_test.go
touch fixtures.go

Those are all the files you need to write your web service test with gosoline! Next, you'll implement each of these files, starting with fixtures.go.

Implement your fixtures

In fixtures.go, add the following code:

fixtures.go
//go:build integration && fixtures

package apitest

import (
"context"
"fmt"

"github.com/justtrackio/gosoline/pkg/cfg"
"github.com/justtrackio/gosoline/pkg/fixtures"
"github.com/justtrackio/gosoline/pkg/log"
)


var namedFixtures = fixtures.NamedFixtures[*fixtures.KvStoreFixture]{
{
Name: "Currency_current_GBP",
Value: &fixtures.KvStoreFixture{
Key: "GBP",
Value: 1.25,
},
},
{
Name: "Currency_old_GBP",
Value: &fixtures.KvStoreFixture{
Key: "2021-01-03-GBP",
Value: 0.8,
},
},
}


func fixtureSetsFactory(ctx context.Context, config cfg.Config, logger log.Logger, group string) ([]fixtures.FixtureSet, error) {
writer, err := fixtures.NewConfigurableKvStoreFixtureWriter[float64](ctx, config, logger, "currency")
if err != nil {
return nil, fmt.Errorf("failed to create kvstore fixture writer: %w", err)
}

sfs := fixtures.NewSimpleFixtureSet(namedFixtures, writer)

return []fixtures.FixtureSet{
sfs,
}, nil
}

Now, you'll walkthrough this file in detail to learn how it works.

Import your dependencies

At the top of fixtures.go, you declared the package and imported a dependency:

fixtures.go
package apitest

import (
"context"
"fmt"

"github.com/justtrackio/gosoline/pkg/cfg"
"github.com/justtrackio/gosoline/pkg/fixtures"
"github.com/justtrackio/gosoline/pkg/log"
)

Here, you declared the package as main. Then, you imported one gosoline dependency, fixtures.

Create named fixtures

Next, you created named fixtures for your current exchange data:

fixtures.go
//go:build integration && fixtures

package apitest

import (
"context"
"fmt"

"github.com/justtrackio/gosoline/pkg/cfg"
"github.com/justtrackio/gosoline/pkg/fixtures"
"github.com/justtrackio/gosoline/pkg/log"
)


var namedFixtures = fixtures.NamedFixtures[*fixtures.KvStoreFixture]{
{
Name: "Currency_current_GBP",
Value: &fixtures.KvStoreFixture{
Key: "GBP",
Value: 1.25,
},
},
{
Name: "Currency_old_GBP",
Value: &fixtures.KvStoreFixture{
Key: "2021-01-03-GBP",
Value: 0.8,
},
},
}


func fixtureSetsFactory(ctx context.Context, config cfg.Config, logger log.Logger, group string) ([]fixtures.FixtureSet, error) {
writer, err := fixtures.NewConfigurableKvStoreFixtureWriter[float64](ctx, config, logger, "currency")
if err != nil {
return nil, fmt.Errorf("failed to create kvstore fixture writer: %w", err)
}

sfs := fixtures.NewSimpleFixtureSet(namedFixtures, writer)

return []fixtures.FixtureSet{
sfs,
}, nil
}

The money exchange application has an in-memory key-value store for holding exchange rate information. The real app gets this data from an external API call to another service. This fixture loads hard-coded initial values into this data store so the service doesn't make an external request during the test.

There are many reasons why you would want to do this, but some of those reasons are outside the scope of this tutorial. For now, just know that, with this fixture, you are controlling the test conditions.

Now that you've defined fixtures for your test, it's time to implement the test itself.

Implement server_test.go

In server_test.go, add the following code:

server_test.go
//go:build integration && fixtures

package apitest

import (
"net/http"
"strconv"
"testing"
"time"

"github.com/go-resty/resty/v2"
"github.com/justtrackio/gosoline/pkg/clock"
"github.com/justtrackio/gosoline/pkg/httpserver"
"github.com/justtrackio/gosoline/pkg/test/suite"
)


type HttpTestSuite struct {
suite.Suite

clock clock.Clock
}


func (s *HttpTestSuite) SetupSuite() []suite.Option {
return []suite.Option{
// A hard-coded log level.
suite.WithLogLevel("info"),

// Configurations from a config file.
suite.WithConfigFile("./config.dist.yml"),

// The fixture set you created in the last section.
suite.WithFixtureSetFactories(fixtureSetsFactory),

// suite.WithClockProvider(s.clock),
suite.WithClockProvider(s.clock),
}
}


func (s *HttpTestSuite) SetupApiDefinitions() httpserver.Definer {
return ApiDefiner
}


func (s *HttpTestSuite) Test_ToEuro(_ suite.AppUnderTest, client *resty.Client) error {
var result float64

// Make a GET request to /euro/:amount/:currency where :amount = 10 and :currency = GBP
response, err := client.R().
SetResult(&result).
Execute(http.MethodGet, "/euro/10/GBP")

// Check that there is no error.
s.NoError(err)

// Check that the response status code is 200 OK.
s.Equal(http.StatusOK, response.StatusCode())

// Check that the converted amount is 8.0.
s.Equal(8.0, result)

return nil
}


func (s *HttpTestSuite) Test_ToEuroAtDate(_ suite.AppUnderTest, client *resty.Client) error {
var result float64

response, err := client.R().
SetResult(&result).
Execute(http.MethodGet, "/euro-at-date/10/GBP/2021-01-03T00:00:00Z")

s.NoError(err)
s.Equal(http.StatusOK, response.StatusCode())
s.Equal(12.5, result)

return nil
}


func (s *HttpTestSuite) Test_Euro() *suite.HttpserverTestCase {
return &suite.HttpserverTestCase{
Method: http.MethodGet,
Url: "/euro/10/GBP",
Headers: map[string]string{},
ExpectedStatusCode: http.StatusOK,
Assert: func(response *resty.Response) error {
result, err := strconv.ParseFloat(string(response.Body()), 64)
s.NoError(err)
s.Equal(8.0, result)

return nil
},
}
}


func TestHttpTestSuite(t *testing.T) {
suite.Run(t, &HttpTestSuite{
clock: clock.NewFakeClockAt(time.Now().UTC()),
})
}

Now, you'll walkthrough this file in detail to learn how it works.

Tag your test

At the top of server_test.go, you designated the file as an integration test and loaded the fixtures file:

server_test.go
//go:build integration && fixtures

This is important because, without it, your test won't use the fixtures and, therefore, will fail.

Import your dependencies

Next, you declared the package and imported some dependencies:

server_test.go
package apitest

import (
"net/http"
"strconv"
"testing"
"time"

"github.com/go-resty/resty/v2"
"github.com/justtrackio/gosoline/pkg/clock"
"github.com/justtrackio/gosoline/pkg/httpserver"
"github.com/justtrackio/gosoline/pkg/test/suite"
)

Here, you declared the package as main. Then, you imported several standard modules and gosoline dependencies.

Define your test suite

Next, you declared an HttpTestSuite:

server_test.go
type HttpTestSuite struct {
suite.Suite

clock clock.Clock
}

You implement the TestingSuite interface with the functions presented in the next sections.

Implement your setup method

Next, you implemented SetupSuite():

server_test.go
func (s *HttpTestSuite) SetupSuite() []suite.Option {
return []suite.Option{
// A hard-coded log level.
suite.WithLogLevel("info"),

// Configurations from a config file.
suite.WithConfigFile("./config.dist.yml"),

// The fixture set you created in the last section.
suite.WithFixtureSetFactories(fixtureSetsFactory),

// suite.WithClockProvider(s.clock),
suite.WithClockProvider(s.clock),
}
}

Set up your API definitions

Implement SetupApiDefinitions():

server_test.go
func (s *HttpTestSuite) SetupApiDefinitions() httpserver.Definer {
return ApiDefiner
}

SetupApiDefinitions() is needed if you want to run resty.Client or HttpserverTestCase functions. It simply returns the ApiDefiner, which is part of the money exchange web service.

Write your test cases

The first test in your file is Test_ToEuro:

server_test.go
func (s *HttpTestSuite) Test_ToEuro(_ suite.AppUnderTest, client *resty.Client) error {
var result float64

// Make a GET request to /euro/:amount/:currency where :amount = 10 and :currency = GBP
response, err := client.R().
SetResult(&result).
Execute(http.MethodGet, "/euro/10/GBP")

// Check that there is no error.
s.NoError(err)

// Check that the response status code is 200 OK.
s.Equal(http.StatusOK, response.StatusCode())

// Check that the converted amount is 8.0.
s.Equal(8.0, result)

return nil
}

The second test is Test_ToEuroAtDate:

server_test.go
func (s *HttpTestSuite) Test_ToEuroAtDate(_ suite.AppUnderTest, client *resty.Client) error {
var result float64

response, err := client.R().
SetResult(&result).
Execute(http.MethodGet, "/euro-at-date/10/GBP/2021-01-03T00:00:00Z")

s.NoError(err)
s.Equal(http.StatusOK, response.StatusCode())
s.Equal(12.5, result)

return nil
}

This test is very similar to the first text, except that it checks the other endpoint (/euro-at-date/:amount/:currency/:date). This time, you check that the converted amount is 12.5.

The third test is Test_Euro:

server_test.go
func (s *HttpTestSuite) Test_Euro() *suite.HttpserverTestCase {
return &suite.HttpserverTestCase{
Method: http.MethodGet,
Url: "/euro/10/GBP",
Headers: map[string]string{},
ExpectedStatusCode: http.StatusOK,
Assert: func(response *resty.Response) error {
result, err := strconv.ParseFloat(string(response.Body()), 64)
s.NoError(err)
s.Equal(8.0, result)

return nil
},
}
}

This is almost the same as the first test, but it uses HttpserverTestCase, instead.

Implement your test suite

Finally, you declare a single, normal unit test:

server_test.go
func TestHttpTestSuite(t *testing.T) {
suite.Run(t, &HttpTestSuite{
clock: clock.NewFakeClockAt(time.Now().UTC()),
})
}

This unit test makes use of the HttpTestSuite struct and calls suite.Run():

Technical Detail

In this test, you use clock.NewFakeClockAt(). When testing the same code multiple times, you want the test results to be identical and, therefore, predictable. For code that makes calls to time.Now() this won't be true. Using a fake clock, which always returns a predefined time, allows you to ensure calls to time.Now() always result in the same time.

Now that you've written your tests, it's time to run them.

Test your service

From inside your money-exchange directory, run your tests:

go mod init money-exchange-test/m
go mod tidy
go test . --tags integration,fixtures -v

Here, you:

  1. Initialize your go module
  2. Install the dependencies
  3. Run the integration test with the fixtures. These tags are important because of the designation at the top of your test file: //go:build integration && fixtures.

Conclusion

Gosoline's suite package is meant to make writing integration tests easier and faster. For a web application composed out of many microservices, aim to have at least one integration test for each microservice, ideally one test for every use case.

Check out these resources to learn more about creating and testing HTTP services with gosoline: