If the accept header is something like text/html
, Spring will try to return an HTML page, which is not what we want from an API - causing errors like this for the client:
com.fasterxml.jackson.core.JsonParseException:
Unexpected character ('<' (code 60)): expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false')
at [Source: (String)"<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Sat May 24 14:11:11 CEST 2025</div><div>There was an unexpected error (type=Method Not Allowed, status=405).</div></body></html>"; line: 1, column: 1]
Check out this branch on how this was detected with property based testing.
Your code should never return a 5xx for any kind of request - a catch-all response code like 5xx should only be reserved for infrastructure issues what you genuinely cannot predict.
But if you send a payload like this, the response will be – quite sadly – a 5xx:
curl -X 'POST' 'http://localhost:9981/meeting' -s \
-H 'Content-Type: application/json' \
-d '{
"userId": 260754,
"name": "Bobby'\''s meeting"
}' | jq .
{
"timestamp": "2025-05-25T07:14:37.890+00:00",
"status": 500,
"error": "Internal Server Error",
"trace": "java.lang.NullPointerException: Cannot invoke \"me.mourjo.quickmeetings.web.dto.MeetingDuration.from()\" because..."
}
Check out this branch on how property based tests found and resolved this bug.
In the following snippet, a meeting creation request specifies the start-time at 02:34 and end-time at 03:04, the error thrown says the start time is after the end time.
Original Sample
---------------
meetingArgs:
MeetingArgs[fromDate=2025-03-30, fromTime=02:34:31, toDate=2025-03-30, toTime=03:04:31, timezone=Europe/Amsterdam]
Original Error
--------------
java.lang.AssertionError:
Expecting actual:
"{"message":"Meeting cannot start (2025-03-30T03:34:31+02:00[Europe/Amsterdam]) after its end time (2025-03-30T03:04:31+02:00[Europe/Amsterdam])"}"
to contain at least one of the following elements:
["Meeting created"]
but none were found
This happens when the start or end time falls in a local timezone which is a “gap” due to daylight savings. On 30 March 2025, at 02:00:00
 clocks in France were turned forward 1 hour to 03:00:00
 - the time between 2 and 3 does not exist and is a gap.
This is caught again by property-based tests in this branch.
The following query has a bug - it is quite hard to catch it at first glance ($1
and $2
are placeholders for the start and end time of a new meeting being created):
SELECT *
FROM
meetings existing_meeting JOIN user_meetings um ON existing_meeting.id = um.meeting_id
WHERE (
(existing_meeting.from_ts <= $1 AND existing_meeting.to_ts >= $1)
OR
(existing_meeting.from_ts <= $2 AND existing_meeting.to_ts >= $2)
)
Property based tests in this branch reports the minimal case when this happens. This test fails when the second meeting is overlapping with the first but only if the second meeting starts beforeand ends after the first - note how the original sample finds a larger time overlap but the shrunk example finds the smallest failing case:
Shrunk Sample (130 steps)
-------------------------
meeting1Start: 2025-01-01T10:00:01
meeting1DurationMins: 1
meeting2Start: 2025-01-01T10:00
meeting2DurationMins: 2
Original Sample
---------------
meeting1Start: 2025-01-01T20:09:29
meeting1DurationMins: 6
meeting2Start: 2025-01-01T19:55:33
meeting2DurationMins: 49
In the previous bug, the fix ensured that the query to check overlapping meeting is correct but it is still possible to have overlapping meetings - as caught by property based tests in this branch.
Complex interleaving of user interactions – creating meetings, inviting others to meetings and accepting meeting invites – breaks the invariant that no person can be in two meetings at the same time. But which sequence of actions?
Property based tests find minimal subset of operations that trigger the failure:
OperationsGenTests.noOperationCausesAnOverlap:63 Invariant failed after the following actions: [
Inputs{action=CREATE, user=alice, from=2025-06-09T10:21Z, to=2025-06-09T10:22Z}
Inputs{action=INVITE, user=charlie, meetingIdx=0}
Inputs{action=CREATE, user=charlie, from=2025-06-09T10:21Z, to=2025-06-09T10:22Z}
Inputs{action=ACCEPT, user=charlie, meetingIdx=0}
]
But there are other more nuanced cases like this which sometimes get reported as well:
OperationsGenTests.noOperationCausesAnOverlap:67 Invariant failed after the following actions: [
Inputs{action=CREATE, user=charlie, from=2025-06-09T10:21Z, to=2025-06-09T10:22Z}
Inputs{action=INVITE, user=bob, meetingIdx=0}
Inputs{action=CREATE, user=alice, from=2025-06-09T10:21Z, to=2025-06-09T10:22Z}
Inputs{action=INVITE, user=bob, meetingIdx=0}
Inputs{action=ACCEPT, user=bob, meetingIdx=0}
Inputs{action=ACCEPT, user=bob, meetingIdx=1}
]
Once we have a way to test interleaving of operations, it is easy to extend that same framework - for example to preserve the invariant of no meeting should be empty.
While this seems like a gross miss, when we work on individual features, it is difficult to keep track of the larger product’s vision or expectations. This branch is the same - once we allow people to reject meeting invites, this bug manifests itself.
The minimal set of operations that fail is simple - this is provided by the test output as well:
OperationsGenTests.noOperationCausesEmptyMeetings:70 Invariant failed after the following actions: [
Inputs{action=CREATE, user=alice, from=2025-06-09T10:21Z, to=2025-06-09T10:22Z}
Inputs{action=REJECT, user=alice, meetingIdx=0}
]
Check this branch for the details.
Testing invariants is different from example based tests. It requires you to think of what the product you are building should do instead of how it should do it. As we saw above, it is very hard to both build a new feature and ensure there are no obscure bugs (like daylight savings) and that it performs as expected in the face of complex interleaving of actions (like accepting meetings which overlap with existing meetings).
Property based testing encourages you to think of your code as a black box of logic - to test its limits with permutations of inputs (both user input and state) - which is simply not possible to enumerate manually.
If you like this topic, I am going to be speaking at TechCamp in Hamburg in June - hope to see you there!
See anything to improve? Edit this post!