In many cases, to optimize or improve the system solution, the business decides to integrate with external systems. External systems have their life cycle, state, and communication protocol. There are different types of communication protocols, in this article, we will only consider HTTP. Microservices also use synchronous HTTP calls for notification or to achieve strong consistency.
You definitely want your integration to have fewer problems and be able to diagnose them easily. In order to implement reliable communication between the internal solution and the external system, it is necessary to cover the communication protocol with reliable integration tests.
Integration tests
There are several options for how to implement integration tests with an external system:
- Use API calls to an external system during the tests
- Unit testing
- Mock of API of an external system over the network
The use of the API of an external system in integration tests has many limitations and disadvantages, such as:
- Increases test execution time, remote HTTP call
- Increases test fragility, temporary service unavailability, configuration change
- The external system has a state that used by other tests or users
- Additional costs for the service of an external system
- Various limitations of the external system that do not allow setting up the test environment
Note
All these disadvantages apply only to integration tests. At the same time, they are an advantage for e2e tests, in which coordination with an external system is necessary. Typically, such e2e tests run on separate environments that do not overlap with users.
Unit testing is very fast but can hide some of the nuances used in the HTTP protocol e.g. headers, cookies, etc.
Simulating the API of an external system via a network eliminates the disadvantages of those approaches. Also, it gives the best result from both worlds, the speed from the unit tests and coverage from the API call. As of this writing, there are two of the most well-known testing frameworks
The Pact is a more advanced consumer-driven framework that allows you to record consumer behaviors and play back scripts on the server-side. But in this article, we will be using WireMock to test the protocol between two services.
Demo example
For the demo purpose, we have a couple of services CatalogueService
and InventoryService
:
CatalogueService
is responsible for the list of the products and it’s detailed descriptions.InventoryService
is responsible for product stock. It can set and get quantity values for the products.
CatalogueService
clients would like to fetch all product and stock quantities via get request. To provide complete data CatalogueService
needs to call InventoryService
to enrich stock quantities for corresponding products. This way client will receive a complete product catalogue with inventory quantities.
CatalogueService
This service provides v1/catalogue/products
implemented in the ProductsCatalogueController
.
ProductsCatalogueController
is using CatalogueProvider
to return products catalogue with stock quantities:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| [ApiController]
[Route("v1/catalogue/products")]
public class ProductsCatalogueController : ControllerBase
{
private readonly ICatalogueProvider _catalogueProvider;
// ...
[HttpGet]
public async Task<IActionResult> GetProducts()
{
var catalogue = await _catalogueProvider.GetAllEntriesAsync();
return Ok(catalogue);
}
}
|
CatalogueProvider
retrieves inventory for each product from the ProductsRepository
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| public class CatalogueProvider : ICatalogueProvider
{
// ...
public async Task<IEnumerable<CatalogueEntry>> GetAllEntriesAsync()
{
var products = _productsRepository.GetProducts();
var httpClient = _httpClientFactory.CreateClient();
var tasks = products.Select(product => GetCatalogueEntryAsync(_settings.InventoryServiceUrl, product, httpClient));
return await Task.WhenAll(tasks);
}
private static async Task<CatalogueEntry> GetCatalogueEntryAsync(string inventoryServiceUrl, Product product, HttpClient httpClient)
{
var url = $"{inventoryServiceUrl}/v1/inventory/{product.ProductId}";
var response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var inventory = await response.Content.ReadFromJsonAsync<GetInventoryResponse>();
return new CatalogueEntry
{
ProductId = product.ProductId,
Description = product.Description,
Quantity = inventory.Quantity
};
}
}
|
InventoryService
It is quite a simple service that exposes get and set endpoints to update in-memory repository:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public class InventoryRepository : IInventoryRepository
{
private Dictionary<string, int> _data = new();
public void SetProductQuantity(string productId, int quantity)
{
_data[productId] = quantity;
}
public int GetProductQuantity(string productId)
{
return _data.TryGetValue(productId, out var currentQuantity) ? currentQuantity : 0;
}
}
|
For integration testing, we need to perform some API calls and validate the response from the CatalogueService
. Microsoft.AspNetCore.Mvc.Testing
package contains WebApplicationFactory which allows us to create http client for our service. You can read more about WebApplicationFactory
at the Integration tests in ASP.NET Core.
WebApplicationTest
creates an instance of the CatalogueService
and allows to create a http client for it to perform API calls:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| public class WebApplicationTest : WebApplicationFactory<Startup>
{
public ITestOutputHelper? TestOutputHelper { get; set; }
protected override IHost CreateHost(IHostBuilder builder)
{
InventoryServiceMock = InventoryServiceMock.Start();
builder.ConfigureLogging((_, logging) =>
{
logging.ClearProviders();
logging.AddXUnit(TestOutputHelper);
});
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
ConfigureBuilder(configurationBuilder));
var host = builder.Build();
host.Start();
return host;
}
private void ConfigureBuilder(IConfigurationBuilder builder)
{
builder.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables();
}
}
|
Test collection allows to share WebApplicationTest
between multiple tests:
1
2
3
4
| [CollectionDefinition(nameof(WebApplicationTestCollection))]
public class WebApplicationTestCollection : ICollectionFixture<WebApplicationTest>
{
}
|
ProductsCatalogueTests
using WebApplicationTest
to create a clint with _factory.CreateClient
and perfrom HTTP call to CatalogueService
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| [Collection(nameof(WebApplicationTestCollection))]
public class ProductsCatalogueTests
{
private readonly WebApplicationTest _factory;
public ProductsCatalogueTests(ITestOutputHelper testOutputHelper, WebApplicationTest factory)
{
_factory = factory;
_factory.TestOutputHelper = testOutputHelper;
}
[Fact]
private async Task ShouldReturnExpectedResult()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("v1/catalogue/products");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var resultCatalogue = await response.Content.ReadFromJsonAsync<IEnumerable<CatalogueEntry>>();
_factory.TestOutputHelper.WriteLine(JsonSerializer.Serialize(resultCatalogue));
var expectedCatalogue = new CatalogueEntry[]
{
new()
{
ProductId = "yellow-wings",
Description = "Yellow wings",
Quantity = 10
},
new()
{
ProductId = "blue-wings",
Description = "Blue wings",
Quantity = 20
},
new()
{
ProductId = "white-wings",
Description = "White wings",
Quantity = 30
},
};
resultCatalogue.Should().BeEquivalentTo(expectedCatalogue);
}
}
|
This setup performs HTTP calls to the InventoryService
. And to make CatalogueService
's tests green, we need to run InventoryService
and set correct stock values for products. It’s fine if we would like to test this behaviour manually but it doesn’t fit into the CI/CD pipeline. We don’t want to run InventoryService
each time when we test CatalogueService
, this will make our test complex and slows down delivery. WireMock to the rescue…
WireMock provides native API to create HTTP call matchers and return response body and code.
InventoryServiceMock
is helper class for configuring mock service behaviour. E.g. by calling method Response_to_GetInventory("yellow-wings", 10)
we instruct mock service to response with { "Quantity": 10 }
if request path matches v1/inventory/yellow-wings
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| public class InventoryServiceMock : WireMockServer
{
// ...
public static InventoryServiceMock Start(int? port = 0, bool ssl = false)
{
return new (new WireMockServerSettings
{
Port = port,
UseSSL = ssl
});
}
public ExpectedResponse Response_to_GetInventory(string productId, int quantity)
{
var path = $"/v1/inventory/{productId}";
Given(Request.Create().UsingGet()
.WithPath(path)
)
.RespondWith(Response.Create()
.WithBodyAsJson(new { Quantity = quantity })
.WithStatusCode(200));
return new ExpectedResponse(this, path, 200);
}
}
|
To let CatalogueService
call mock instead of InventoryService
we need to create Inventory Mock service and update CatalogueService
configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| public class WebApplicationTest : WebApplicationFactory<Startup>
{
public ITestOutputHelper? TestOutputHelper { get; set; }
public InventoryServiceMock? InventoryServiceMock { get; private set; }
protected override IHost CreateHost(IHostBuilder builder)
{
// >>> Create Mock service
InventoryServiceMock = InventoryServiceMock.Start();
// >>> Create Mock service
builder.ConfigureLogging((_, logging) =>
{
logging.ClearProviders();
logging.AddXUnit(TestOutputHelper);
});
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
ConfigureBuilder(configurationBuilder));
var host = builder.Build();
host.Start();
return host;
}
private void ConfigureBuilder(IConfigurationBuilder builder)
{
builder.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables()
// >>> Update configuration
.AddInMemoryCollection(new Dictionary<string, string>
{
{ "Settings:InventoryServiceUrl", $"{InventoryServiceMock.Urls.First()}" },
});
// >>>
}
}
|
And we need to update tests to instruct WireMock how to react to API calls:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| [Collection(nameof(WebApplicationTestCollection))]
public class ProductsCatalogueTests
{
// ...
[Fact]
private async Task ShouldReturnExpectedResult()
{
// Arrange
var client = _factory.CreateClient();
// >>> Mock InventoryService behaviour
var getYellowWingsInventory = _factory.InventoryServiceMock.Response_to_GetInventory("yellow-wings", 10);
var getBlueWingsInventory = _factory.InventoryServiceMock.Response_to_GetInventory("blue-wings", 20);
var getWhiteWingsInventory = _factory.InventoryServiceMock.Response_to_GetInventory("white-wings", 30);
// >>>
// Act
var response = await client.GetAsync("v1/catalogue/products");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var resultCatalogue = await response.Content.ReadFromJsonAsync<IEnumerable<CatalogueEntry>>();
_factory.TestOutputHelper.WriteLine(JsonSerializer.Serialize(resultCatalogue));
var expectedCatalogue = new CatalogueEntry[]
{
new()
{
ProductId = "yellow-wings",
Description = "Yellow wings",
Quantity = 10
},
new()
{
ProductId = "blue-wings",
Description = "Blue wings",
Quantity = 20
},
new()
{
ProductId = "white-wings",
Description = "White wings",
Quantity = 30
},
};
resultCatalogue.Should().BeEquivalentTo(expectedCatalogue);
// >>> Assert that service was called
getYellowWingsInventory.ShouldBeCompleted();
getBlueWingsInventory.ShouldBeCompleted();
getWhiteWingsInventory.ShouldBeCompleted();
// >>>
}
}
|
Results
The complete Integration test with WireMock example can be found in the Git repository blog-wiremock-integration-test.
This integration tests setup has the following advantages:
- Run integration tests with the same speed as Unit tests
- Inbound and outbound API calls executed over the HTTP protocol
- Similar to the black box integration tests
- Do not dependent on external systems, no flakiness, no dependency on environment state, etc
Happy testing!