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.
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:
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:
//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:
//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:
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
:
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()
:
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()
:
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
:
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
:
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
:
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:
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()
:
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:
- Initialize your go module
- Install the dependencies
- 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: