How to handle decimal numbers in form params with WireMock
I wrote about WireMock last year. It's a very powerful tool that lets us replace external services with stubs while testing. Since it can be configured on the fly by tests, it allows us to validate virtually any behavior. Here's how I use it:
In this sequence diagram, a test tells WireMock to return a JSON body for every request to /fetch-details
. During execution, the test calls /some-endpoint
to validate it. This endpoint needs data from an external service to produce a response, so it requests /fetch-details
and gets the JSON body that the test fed WireMock at the start. By changing what /fetch-details
returns, we can easily simulate various scenarios.
The stubs can be configured in many ways. Here's an example from the official documentation:
stubFor(any(urlPathEqualTo("/everything"))
.withHeader("Accept", containing("xml"))
.withCookie("session", matching(".*12345.*"))
.withQueryParam("search_term", equalTo("WireMock"))
.withBasicAuth("[email protected]", "jeffteenjefftyjeff")
.withRequestBody(equalToXml("<search-results />"))
.withRequestBody(matchingXPath("//search-results"))
.withMultipartRequestBody(
aMultipart()
.withName("info")
.withHeader("Content-Type", containing("charset"))
.withBody(equalToJson("{}"))
)
.withClientIp(equalTo("127.0.0.1"))
.willReturn(aResponse()));
In this post, I want to cover a case I dealt with recently: form data with decimal numbers.
Often, data goes through several layers within a single server, obtained from other services and databases, and converted between multiple types. Because compilers, runtimes, languages, libraries, and operating systems perform these conversions slightly differently, a value like 10.50
can become 10.5
- and vice versa. To us, it looks the same: 10 euros and 50 cents. But not for tests, because form data, unlike JSON, doesn't have distinctive data types.
Scenario
Consider the following scenario:
- The user opens a webpage.
- A web app makes an API call to retrieve items based on the current category, ordering, filtering, and so on.
- It then calls another service to get details about these items, including their price in the user's currency.
- Finally, the web app renders this data.
If our goal is to validate how the endpoint from step 3 works, we can:
- Add a few items to the database.
- Call the endpoint with several item IDs.
- Validate the response.
Since our service under test only knows an item's price in EUR, it must call an external converter. We can configure WireMock to act as that converter by:
- Adding a few items to the database.
- Configuring WireMock to return
8.2
for requests to/convert
. - Calling the endpoint with several item IDs.
- Validating the response.
- Expect that the price in user currency is equal to 8.2 - the service correctly passed the value back to the user.
@Test
public void multipleRequestsSuccess() {
// Add a stub that always returns 8.2 for requests to /convert
var responseDefinition = WireMock.aResponse()
.withHeader("content-type", "application/json")
.withBody("{\"converted_amount\": 8.2}");
wireMock.stubFor(WireMock.post("/convert").willReturn(responseDefinition));
var userCurrency = "GBP";
var itemsOnPage = List.of(1L, 2L);
// Use fetchItems to call the service under test, which returns List<ItemDto>
var response = fetchItems(userCurrency, itemsOnPage);
Assert.assertEquals(response.size(), 2);
// Validate values for item 1
Assert.assertListContains(
response,
itemDto -> itemDto.id() == 1L
&& itemDto.price().compareTo(itemIdPriceMap.get(1L)) == 0
&& itemDto.priceInUserCurrency().compareTo(BigDecimal.valueOf(8.2)) == 0,
"item 1 not found");
// Validate values for item 2
Assert.assertListContains(
response,
itemDto -> itemDto.id() == 2L
&& itemDto.price().compareTo(itemIdPriceMap.get(2L)) == 0
&& itemDto.priceInUserCurrency().compareTo(BigDecimal.valueOf(8.2)) == 0,
"item 2 not found");
}
However, we can't be sure the service correctly maps converted prices to their respective items if we use a single stub that always returns 8.2
- regardless of the request parameters.
To address the problem, we can add two stubs with distinct return values:
@Test
public void multipleRequestsFailing() {
// Add a stub that always returns 8.2 for requests to /convert
var responseDefinition1 = WireMock.aResponse()
.withHeader("content-type", "application/json")
.withBody("{\"converted_amount\": 8.2}");
wireMock.stubFor(WireMock.post("/convert").willReturn(responseDefinition1));
// Add a stub that always returns 10.2 for requests to /convert
var responseDefinition2 = WireMock.aResponse()
.withHeader("content-type", "application/json")
.withBody("{\"converted_amount\": 10.2}");
wireMock.stubFor(WireMock.post("/convert").willReturn(responseDefinition2));
var userCurrency = "GBP";
var itemsOnPage = List.of(1L, 2L);
var response = fetchItems(userCurrency, itemsOnPage);
Assert.assertEquals(response.size(), 2);
Assert.assertListContains(
response,
itemDto -> itemDto.id() == 1L
&& itemDto.price().compareTo(itemIdPriceMap.get(1L)) == 0
&& itemDto.priceInUserCurrency().compareTo(BigDecimal.valueOf(8.2)) == 0,
"item 1 not found");
Assert.assertListContains(
response,
itemDto -> itemDto.id() == 2L
&& itemDto.price().compareTo(itemIdPriceMap.get(2L)) == 0
// Now we expect different priceInUserCurrency for item 2
&& itemDto.priceInUserCurrency().compareTo(BigDecimal.valueOf(10.2)) == 0,
"item 2 not found");
}
But WireMock will simply respond with a responseDefinition2
to both requests, because the most recently added stub wins.
To fix this, we need to tell WireMock which stub applies to each request. In a test database with distinct or randomized prices, we can rely on the price values:
@Test
public void multipleRequestsStillFailing() {
var responseDefinition1 = WireMock.aResponse()
.withHeader("content-type", "application/json")
.withBody("{\"converted_amount\": 8.2}");
wireMock.stubFor(WireMock.post("/convert")
// Check that in request form params, amount=10.50
// Assuming that in our database that's the price of item 1
.withFormParam("amount", WireMock.equalTo("10.50"))
// And only then return this response definition
.willReturn(responseDefinition1));
var responseDefinition2 = WireMock.aResponse()
.withHeader("content-type", "application/json")
.withBody("{\"converted_amount\": 10.2}");
wireMock.stubFor(WireMock.post("/convert")
// Check that in request form params, amount=12.50
// Assuming that in our database that's the price of item 2
.withFormParam("amount", WireMock.equalTo("12.50"))
// And only then return this response definition
.willReturn(responseDefinition2));
var userCurrency = "GBP";
var itemsOnPage = List.of(1L, 2L);
var response = fetchItems(userCurrency, itemsOnPage);
Assert.assertEquals(response.size(), 2);
Assert.assertListContains(
response,
itemDto -> itemDto.id() == 1L
&& itemDto.price().compareTo(itemIdPriceMap.get(1L)) == 0
&& itemDto.priceInUserCurrency().compareTo(BigDecimal.valueOf(8.2)) == 0,
"item 1 not found");
Assert.assertListContains(
response,
itemDto -> itemDto.id() == 2L
&& itemDto.price().compareTo(itemIdPriceMap.get(2L)) == 0
&& itemDto.priceInUserCurrency().compareTo(BigDecimal.valueOf(10.2)) == 0,
"item 2 not found");
}
This might work on your machine, but then fail in a CI pipeline with an error like this:
| Closest Stub | Request |
|----------------------|------------------|
| POST | POST |
| /convert | /convert |
| Form: amount = 10.50 | amount: 10.5 <<<<< Form data mismatch |
Our stub expected a string value of 10.50
, but the server sent 10.5
- these strings aren't equal. WireMock doesn't know how to handle this request.
Regular expression
We could use a regular expression (source) that covers both formats:
^10\.5[0]?$
But in Java, we typically store monetary values with BigDecimal
. In our tests, we generate random amounts, put them into a database, use them in stubs, in API models, and so on. BigDecimal
is an accurate and reliable class. Generating regexes for random values wouldn't be that straightforward.
Solution
Since form values are strings, WireMock's withFormParam
method accepts a string matcher. We can build on top of it to handle decimal values properly:
public class BigDecimalMatcher extends StringValuePattern {
public BigDecimalMatcher(@JsonProperty("equalToBigDecimal") BigDecimal value) {
super(value.toString());
}
@Override
public MatchResult match(String value) {
var expectedValue = new BigDecimal(getExpected());
var actualValue = new BigDecimal(value);
return expectedValue.compareTo(actualValue) == 0 ? MatchResult.exactMatch() : MatchResult.noMatch();
}
public static BigDecimalMatcher equalTo(BigDecimal value) {
return new BigDecimalMatcher(value);
}
}
This class extends StringValuePattern
, making it compatible with withFormParam
. But instead of comparing strings, its match
method converts both actual and expected values to BigDecimal
and compares them. This is exactly what we need, because:
Two BigDecimal objects that are equal in value but have a different scale (like 2.0 and 2.00) are considered equal by this method. Such values are in the same cohort.
Now, we simply apply this matcher in our test:
@Test
public void fetchItemsNowWorking() {
var responseDefinition1 = WireMock.aResponse()
.withHeader("content-type", "application/json")
.withBody("{\"converted_amount\": 8.2}");
wireMock.stubFor(WireMock.post("/convert")
// Use BigDecimalMatcher to compare two amounts
.withFormParam("amount", BigDecimalMatcher.equalTo(itemIdPriceMap.get(1L)))
.willReturn(responseDefinition1));
var responseDefinition2 = WireMock.aResponse()
.withHeader("content-type", "application/json")
.withBody("{\"converted_amount\": 10.2}");
wireMock.stubFor(WireMock.post("/convert")
// Use BigDecimalMatcher to compare two amounts
.withFormParam("amount", BigDecimalMatcher.equalTo(itemIdPriceMap.get(2L)))
.willReturn(responseDefinition2));
var userCurrency = "GBP";
var itemsOnPage = List.of(1L, 2L);
var response = fetchItems(userCurrency, itemsOnPage);
Assert.assertEquals(response.size(), 2);
Assert.assertListContains(
response,
itemDto -> itemDto.id() == 1L
&& itemDto.price().compareTo(itemIdPriceMap.get(1L)) == 0
&& itemDto.priceInUserCurrency().compareTo(BigDecimal.valueOf(8.2)) == 0,
"item 1 not found");
Assert.assertListContains(
response,
itemDto -> itemDto.id() == 2L
&& itemDto.price().compareTo(itemIdPriceMap.get(2L)) == 0
&& itemDto.priceInUserCurrency().compareTo(BigDecimal.valueOf(10.2)) == 0,
"item 2 not found");
}
Conclusion
That's a lot of text for a rather small issue! Yet, I find step-by-step explanations more useful: both when I'm writing and when reading others' posts.
I did a sanity check on WireMock's Slack: did I miss some easier, built-in way to handle this? Aside from regular expressions which we discussed above, no, there currently isn't anything else.
My solution has a few silly conversions because tests work with BigDecimal
, but StringValuePattern
expects a String
, so BigDecimalMatcher
jumps between two data types. However, the cost of these operations is negligible in regression tests. What's important is that it simplifies test implementation and follows the same pattern of matchers that the stub builder already offers.