Introduction
This tutorial is the second of our testing series, and in case you have missed the previous one, you can find it here. In addition, this repository is the sample app used throughout the series, so if you haven't downloaded it yet, please do. The main goal of this tutorial is to expand into service tests, where we will be talking about functional, and integration tests.
Functional Tests
I tend to use the term functional tests, since it makes more sense to me, but they are also known as Acceptance Tests. People in the field tend to use both terms interchangeably, however, the main objective is to understand the type of bugs that are trying to catch. Functional tests aim to test the capabilities of your service and to ensure that the product meets the acceptance criteria. If your service is an API, then functional tests will be testing the request-response cycle, including payloads, headers and status codes. In the case of a front-end application, you will be testing the views, as well as user journeys.
In addition, it is recommended to stub any external dependencies. Nonetheless, my personal taste is to only stub dependencies for front-end applications, which become overly complex over time.
If you think of the application as a unit, it acquires dependencies either by acting as a consumer or a provider. Based on the type of the application, those dependencies vary. A front-end application often has a dependency graph as below, where it has full knowledge of which services it uses.
In addition, a front-end application sits at the top of the technologies stack, which means that nobody will be consuming it. Thus, the left-side of the dependencies graph will always be empty.
On the other hand, a back-end application is different. It often has a database, if not a third-party dependency, and a message queue to publish events. The role of the service is to be used by others and this number tends to increase. It may know at the beginning which services are using it, but as the system expands and needs to scale it often loses track of them.
Well, how is this related?
Functional tests track problems of dependencies that you know and there is no way to know how someone else is using you, unless if it explicitly tells you. A front-end application knows that it has a high number of dependencies and therefore it makes sense to stub them. However, a back-end application often has at most 3-4 dependencies and I can't see too much benefit of stubbing them. Therefore, I prefer to run the dependencies in containers and erase them when the suite is done. With that way, I get more confidence that my service works as expected, without slowing down the suite too much. The overall overhead will be a few seconds that the containers need to start and some delay on hitting the actual instances.
Now, we are ready to write our functional tests, so let's have a look. The code snippet below is available here and it ensures that the front-end application renders the API responses within the div[data-testid="helloPlain"]
and div[data-testid="helloWithName"]
HTML elements. Pay attention on the beforeEach
block, where we stub the /hello
and/hello/foo
endpoints. We use the Cypress library to run our tests and if you want to run them locally use the npm run test:functional
command. In addition, we use the start-server-and-test libray to run the application in the background, before even Cypress starts to run.
/// <reference types="Cypress" />
import hello from '../fixtures/hello.json';
import helloFoo from '../fixtures/helloFoo.json';
describe('Home page', () => {
beforeEach(() => {
cy.intercept('GET', '/hello/foo', { fixture: 'helloFoo.json' }).as('getHelloFoo');
cy.intercept('GET', '/hello', { fixture: 'hello.json' }).as('getHello');
cy.visit('/');
});
it('should render the responses', () => {
cy.wait(['@getHello', '@getHelloFoo']);
cy.get('div[data-testid="helloPlain"]').contains(JSON.stringify(hello));
cy.get('div[data-testid="helloWithName"]').contains(JSON.stringify(helloFoo));
});
});
As I have already mentioned, I prefer to run functional tests for my back-end services in containers, so I am showing this approach. testcontainers-go
is an SDK available in go that allows to launch and orchestrate containers. In this case, we are running the microservices-in-action/api:prod
container and we check that we get the right responses. Similar to unit tests, we use testify to assert our responses.
The code snippet is available here.
package functional
import (
"github.com/testcontainers/testcontainers-go/wait"
"github.com/docker/go-connections/nat"
"testing"
"context"
"net/http"
"fmt"
"log"
"encoding/json"
"time"
"github.com/stretchr/testify/suite"
"github.com/stretchr/testify/assert"
"github.com/testcontainers/testcontainers-go"
"hello-world/internal"
)
type endpointsSuite struct {
suite.Suite
ctx context.Context
api *testcontainers.Container
localhostPort string
}
func (es *endpointsSuite) SetupTest(){
var ctx = context.Background()
var req = testcontainers.ContainerRequest{
Image: "microservices-in-action/api:prod",
Name: "microservices-in-action-api-functional",
ExposedPorts: []string{
"3000/tcp",
},
AutoRemove: true,
WaitingFor: wait.ForLog("The app listens on port 3000"),
}
var greq = testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
}
var container testcontainers.Container
var e error
if container, e = testcontainers.GenericContainer(ctx, greq); e != nil {
log.Fatalf("Error: %v\n", e)
}
var localhostPort nat.Port
if localhostPort, e = container.MappedPort(ctx, "3000/tcp"); e != nil {
log.Fatalf("Error: %v\n", e)
}
es.api = &container
es.ctx = ctx
es.localhostPort = localhostPort.Port()
}
func (es *endpointsSuite) TestEndpoints(){
defer (*es.api).Terminate(es.ctx)
var baseURL = fmt.Sprintf("http://localhost:%s", es.localhostPort)
var table = []struct{
url string
method string
headers []http.Header
expectedResponseBody internal.ResponseBody
e error
}{
{
url: fmt.Sprintf("%s/hello", baseURL),
method: http.MethodGet,
headers: []http.Header{
http.Header{
"Content-Type": []string{"application/json"},
},
},
expectedResponseBody: internal.ResponseBody{
Status: 200,
Success: true,
Data: "hello",
},
},
{
url: fmt.Sprintf("%s/hello/!---", baseURL),
method: http.MethodGet,
headers: []http.Header{
http.Header{
"Content-Type": []string{"application/json"},
},
},
expectedResponseBody: internal.ResponseBody{
Status: 400,
Success: false,
Message: "Error: Bad Request",
},
},
{
url: fmt.Sprintf("%s/hello/john", baseURL),
method: http.MethodGet,
headers: []http.Header{
http.Header{
"Content-Type": []string{"application/json"},
},
},
expectedResponseBody: internal.ResponseBody{
Status: 200,
Success: true,
Data: "hello John",
},
},
}
var t = es.T()
var assert = assert.New(t)
var httpClient = http.Client{Timeout: time.Second * 10}
for _, row := range table {
var req *http.Request
var res *http.Response
var e error
if req, e = http.NewRequest(row.method, row.url, nil); e != nil {
t.Errorf("Error: %s\n", e.Error())
}
if res, e = httpClient.Do(req); e != nil {
assert.EqualError(e, row.e.Error())
}
for _, header := range row.headers {
for headerKey, _ := range header {
var resHeaderValue = res.Header.Get(headerKey)
assert.Equal(resHeaderValue, header.Get(headerKey))
}
}
var resBody internal.ResponseBody
if e := json.NewDecoder(res.Body).Decode(&resBody); e != nil {
t.Errorf("Error: Failed decoding '%s'\n", e.Error())
}
assert.EqualValues(resBody, row.expectedResponseBody)
}
}
func TestEndpointsSuite(t *testing.T){
suite.Run(t, new(endpointsSuite))
}
One thing to note is that we need to build our docker image before we run the functional tests. To make your life easier, I prepared this docker-compose
file. To run it, use:
docker-compose -f docker-compose.prod.yml build api
Integration Tests
Another type of tests that belong in the services layer of the test pyramid are integration tests. As the name suggests, integration tests aim to check the behavior of your application after it has been integrated with the rest infrastructure. While all the previous tests focused on testing logic locally to the app, integration tests aim to test the links of your application with any external services.
Since in this type of test we need to test all the pieces of our system, the suites become much slower and even fragile than the previous ones. As such, it's good practise to keep the number of tests low, write tests that focus on important user journeys, and avoid to test logic in multiple places.
To demonstrate this, let's have a look on the code snippet below. Similar to functional tests, we keep using Cypress to run the integration tests, however, in this case we don't stub the HTTP requests. We check the whole cycle of the application, starting with the requests to the API, and later rendering the responses in the UI.
The code snippet is available here and you can run the tests with npm run test:integration
.
/// <reference types="Cypress" />
import hello from '../fixtures/hello.json';
import helloFoo from '../fixtures/helloFoo.json';
describe('Home page', () => {
it('should render the responses', () => {
cy.visit('/');
cy.get('div[data-testid="helloPlain"]').contains('div', JSON.stringify(hello));
cy.get('div[data-testid="helloWithName"]').contains('div', JSON.stringify(helloFoo));
});
});
What is missing?
Until now, we have written tests that check the following things:
Check the logic of each individual unit and make sure that it works as expected.
Check that the app works as expected as a whole.
Check how the application performs when it integrates with providers.
Those tests are good enough to keep your application up and catch failures when new features are added. However, there is an edge case that they fail to cover and we will talk about it in the next tutorial. For the moment, I am raising this question:
What will happen if a provider updates the schema of itself without you knowing it?
Summary
That's it for this tutorial, but before we go, let's recap:
Functional and Integration tests are part of the services layer in the test pyramid.
Functional tests ensure that the service meets the business requirements and it's recommended to isolate dependencies by stubbing them.
Integration tests check that your service works well with the rest infrastructure. In this case, stubbing is avoided and you should keep the number of tests low. The tests are much slower than any other type of test.