Summary: Guide to writing IM integration tests -- Spock framework, WireMock for HTTP mocking, test base classes, and common testing patterns.
Test Framework Overview
The Integration Manager uses Spock Framework with Groovy as its testing foundation. Tests are written as Spock specifications (classes extending Specification) and use the given/when/then block structure.
Key dependencies:
|
Dependency |
Purpose |
|---|---|
|
Spock Framework |
BDD-style test framework for Groovy/Java |
|
WireMock |
HTTP mock server for simulating Pricefx API responses |
|
Apache Camel Test |
Camel context lifecycle and route testing utilities |
|
Spring Test |
Spring context bootstrapping via |
|
Testcontainers |
Container-based infrastructure (e.g., SFTP servers) |
All test files are Groovy (.groovy) and live under src/test/groovy/ in their respective modules.
Base Classes
The project provides a hierarchy of base specification classes. Choose the one that matches your test scope.
Class Hierarchy
Specification (Spock)
└── WireMockSpecification (integration-test module)
└── UnitTestSpecification (integration-test module, Spring-based)
└── IntegrationTestSpecification (integration-test module, repo-backed)
└── SftpTestSpecification (integration-test module, SFTP container)
There is also a separate WireMockSpecification inside pricefx-integration for component-level tests that use the @CamelTest annotation instead of Spring.
When to Use Each
|
Base Class |
Module |
Use Case |
|---|---|---|
|
|
|
Tests that only need an HTTP mock server and no Camel/Spring context |
|
|
|
Tests needing a Spring-managed |
|
|
|
Tests that load configuration from a |
|
|
|
Tests that additionally require an SFTP server (via Testcontainers) |
|
|
|
Component-level tests using |
Test Setup Approaches
There are two distinct approaches depending on which module you are writing tests in.
Approach 1: Spring-Based Tests (integration-test module)
Tests that extend UnitTestSpecification or IntegrationTestSpecification use Spring to manage the Camel context.
package net.pricefx.integration.test.definition
import net.pricefx.integration.test.UnitTestSpecification
import org.apache.camel.CamelContext
import org.apache.camel.Exchange
import org.apache.camel.builder.AdviceWith
import org.springframework.beans.factory.annotation.Autowired
class MapperTest extends UnitTestSpecification {
@Autowired
private CamelContext context
def "should test mapper"() {
given:
seedMapper('''<mappers>
<loadMapper id="test">
<body in="age" out="convertedAge" converter="stringToInteger"/>
<body in="test" out="attribute1"/>
</loadMapper>
</mappers>''')
seedRoute('''
<routes xmlns="http://camel.apache.org/schema/spring">
<route id="test-mapper">
<from uri="timer://foo?repeatCount=1"/>
<to uri="pfx-model:transform?mapper=test"/>
</route>
</routes>
''')
AdviceWith.adviceWith(context, "test-mapper", a ->
a.replaceFromWith("direct:start")
)
when:
Exchange exchange = sendBody("direct:start", [["test": "Jerry", "age": "42"]])
then:
exchange.getIn().getBody() == [["convertedAge": 42, "attribute1": "Jerry"]]
}
}
What UnitTestSpecification provides:
-
@ContextConfigurationand@TestPropertySourceannotations pre-configured -
A Spring-managed
CamelContext(autowired) -
@TempDirfor temporary file operations -
Convenience methods:
seedRoute(),seedMapper(),seedBean(),seedClass(),seedProperty(),seedDefaultConnection(),seedFile() -
sendBody(endpointUri, body)to dispatch an exchange -
expectedResponse(source)to load expected output from a file -
Automatic context cleanup in
setup()andcleanup()
What IntegrationTestSpecification adds:
-
A
FileSystemConfigurationRepositoryloaded fromsrc/test/resources/repoorsrc/main/resources/repo -
seedEntities()to bulk-load beans, classes, mappers, and routes from the repository with optional include/exclude filtering by PRN
Approach 2: @CamelTest Annotation (pricefx-integration module)
Component-level tests in pricefx-integration use the custom @CamelTest Spock extension, which manages a standalone DefaultCamelContext outside of Spring.
package net.pricefx.integration.component.api
import net.pricefx.integration.WireMockSpecification
import net.pricefx.integration.camel.CamelTest
// ...
@CamelTest
class PfxApiProducerFetchSpec extends WireMockSpecification {
Exchange exchange
PriceFxClient priceFxClient
def setup() {
cleanContext()
def registry = new DefaultRegistry()
registry.bind("filter", new Filter())
priceFxClient = Mock(PriceFxClient.class)
priceFxClient.generalApi >> Mock(GeneralDatasourceApiCompatible.class)
// ... mock other APIs as needed
registry.bind("pricefx", new PriceFxConnection(client: priceFxClient))
exchange = new DefaultExchange(new DefaultCamelContext(registry))
pfxApiEndpoint = new PfxApiEndpoint('pfx-api', 'fetch', new PfxApiComponent())
}
def "fetch products"() {
given:
pfxApiEndpoint.pfxApiConfiguration = new PfxApiConfiguration(
objectType: ObjectType.P, filter: 'filter'
)
def pfxApiProducer = new PfxApiProducer(pfxApiEndpoint)
when:
pfxApiProducer.process(exchange)
then:
1 * priceFxClient.extendedProductApi.fetchproducts(_,_,_,_) >>
new FetchResponse(response: new FetchResponseData(data: []))
exchange.in.getHeader('totalRows') == 0
}
}
What @CamelTest provides (via CamelTestExtension):
-
A fresh
DefaultCamelContextwith aDefaultRegistry, injected into the spec ascontext,registry, andproducer -
route(xml)-- deploy a route definition from an XML string -
mapper(xml)-- deploy a mapper definition from an XML string -
mock(uri)-- get aMockEndpointby URI -
sendBody(),sendBodyAndHeader(),sendBodyAndHeaders(),send(),asyncSend()-- producer template delegates -
cleanContext()-- stop and remove all routes, reset mock endpoints
Note:
@CamelTestshould be applied at the class level. Each annotation spawns a new Camel context via introspection; applying it to multiple methods in the same class may produce unexpected state.
WireMock Patterns
WireMock is used to simulate the Pricefx API (and other HTTP endpoints) without requiring a live server.
How WireMock Is Initialized
Both WireMockSpecification classes use a shared singleton server on a dynamic port:
@Shared
def wireMockServer = WireMockSingleton.getInstance()
WireMockSingleton creates a WireMockServer with WireMockConfiguration.options().dynamicPort() and starts it immediately.
Automatic JWT Stub
Both WireMock base classes automatically stub the Pricefx JWT authentication endpoint in setup():
wireMockServer.stubFor(
post(urlEqualTo('/pricefx/test-partition/accountmanager.getjsonwebtoken/'))
.willReturn(okJson('{"response":{"node":"pa1","csrfToken":"...","data":["...JWT..."],"status":0}}'))
)
This means any test extending WireMockSpecification can make authenticated Pricefx API calls without additional auth setup.
Stubbing API Responses
Use standard WireMock static imports:
import static com.github.tomakehurst.wiremock.client.WireMock.*
// Stub a POST endpoint returning JSON
wireMockServer.stubFor(
post(urlEqualTo("/pricefx/mvich/datamart.getfcs/1234"))
.withRequestBody(containing('"sortBy":["sku"]'))
.willReturn(okJson('{"response":{"data":[],"status":0}}'))
)
Verifying Requests
The base class provides convenience methods:
// Verify a POST was called exactly N times
verifyPost("/pricefx/mvich/some-endpoint", 2)
// Verify with payload matching
verifyPost("/pricefx/mvich/some-endpoint", 1, '{"expected":"json"}')
Or use WireMock's verify API directly:
wireMockServer.verify(1, postRequestedFor(urlEqualTo("/some-url"))
.withRequestBody(equalToJson('{"key":"value"}')))
Lifecycle
WireMock is reset in setup(), cleanup(), setupSpec(), and cleanupSpec() via wireMockServer.resetAll(), so stubs do not leak between tests.
Mocking Pricefx Clients Directly
For component-level tests (using @CamelTest), you can mock the PriceFxClient and its sub-APIs using Spock mocks instead of (or in addition to) WireMock:
priceFxClient = Mock(PriceFxClient.class)
priceFxClient.generalApi >> Mock(GeneralDatasourceApiCompatible.class)
priceFxClient.lookuptableApi >> Mock(LookuptableApi.class)
priceFxClient.datamartApi >> Mock(DatamartApi.class)
registry.bind("pricefx", new PriceFxConnection(client: priceFxClient))
Then verify interactions in the then block:
then:
1 * priceFxClient.getDatamartApi().datamartGetfcsFCTypeTypedIdOrSourceName("uniqueName", _ as FetchRequest) >> { arguments ->
request = arguments[1]
response
}
request.startRow == 2
request.endRow == 3
Example Test Structures
Pure Unit Test (no Camel context)
For logic that does not involve Camel routes:
class IntegrationMapperMappingSpec extends Specification {
private Exchange exchange
private MapperProcessor mapperProcessor
def setup() {
exchange = new DefaultExchange(new DefaultCamelContext())
exchange.getIn().setBody([["attribute1": "Attribute1Value"]])
mapperProcessor = new MapperProcessor()
}
@Unroll
def "should map properly"() {
when:
mapperProcessor.setMapper(createIntegrationMapper(definition))
mapperProcessor.process(exchange)
then:
expected == exchange.getIn().getBody(List.class)[0][out]
where:
definition || expected || out
{ im -> im.body("attribute1") } || "Attribute1Value" || "attribute1"
}
}
Data-Driven Test with where Block
Spock's where block and @Unroll are used extensively for parameterized tests:
@Unroll
def "sortBy overrides filter"() {
given:
wireMockServer.stubFor(
post(urlEqualTo("/pricefx/mvich/datamart.getfcs/1234"))
.withRequestBody(containing(sortByInCall))
.willReturn(okJson('{"response":{"data":[],"status":0}}'))
)
and:
route('''
<routes xmlns="http://camel.apache.org/schema/spring">
<route>
<from uri="direct:start"/>
<toD uri="pfx-api:fetchFcs?objectType=DM&typedId=1234''' + uriSuffix + '''"/>
<to uri="mock:end"/>
</route>
</routes>
''')
when:
sendBody("direct:start", "")
then:
def endpoint = mock("mock:end")
endpoint.receivedExchanges.size() == 1
where:
uriSuffix | sortByInCall
'' | ''
'&sortBy=sku' | '"sortBy":["sku"],'
'&sortBy=sku,label' | '"sortBy":["sku","label"],'
}
Route Test with AdviceWith
When testing routes that have a timer or other non-direct consumer, use AdviceWith to replace the from endpoint:
AdviceWith.adviceWith(context, "route-id", a ->
a.replaceFromWith("direct:start")
)
Exchange exchange = sendBody("direct:start", inputBody)
Bean Test
class BeansTest extends UnitTestSpecification {
@Autowired
private CamelContext context
def "should test bean"() {
given:
seedBean('''<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" ...>
<util:list id="myList" value-type="java.lang.String">
<value>foo</value>
<value>bar</value>
</util:list>
</beans>''')
seedRoute('''
<routes xmlns="http://camel.apache.org/schema/spring">
<route id="test-bean">
<from uri="timer://foo?repeatCount=1"/>
<setBody>
<method ref="myList" method="toString"/>
</setBody>
</route>
</routes>
''')
AdviceWith.adviceWith(context, "test-bean", a ->
a.replaceFromWith("direct:start")
)
when:
Exchange exchange = sendBody("direct:start", [])
then:
exchange.getIn().getBody() == "[foo, bar]"
}
}
Groovy Class (Processor) Test
Testing a Groovy processor class that transforms data in a route:
class GroovyProcessorTest extends UnitTestSpecification {
@Autowired
private CamelContext context
def "should transform body using groovy processor"() {
given:
seedClass('''package repo.classes
import org.apache.camel.Exchange
import org.apache.camel.Processor
class SkuPrefixProcessor implements Processor {
@Override
void process(Exchange exchange) throws Exception {
def rows = exchange.getIn().getBody(List)
rows.each { row ->
row.sku = "PFX-" + row.sku
}
exchange.getIn().setBody(rows)
}
}''')
seedRoute('''
<routes xmlns="http://camel.apache.org/schema/spring">
<route id="test-groovy-processor">
<from uri="timer://foo?repeatCount=1"/>
<to uri="bean:skuPrefixProcessor"/>
</route>
</routes>
''')
AdviceWith.adviceWith(context, "test-groovy-processor", a ->
a.replaceFromWith("direct:start")
)
when:
Exchange exchange = sendBody("direct:start", [
[sku: "001", label: "Widget A"],
[sku: "002", label: "Widget B"]
])
then:
def result = exchange.getIn().getBody(List)
result[0].sku == "PFX-001"
result[1].sku == "PFX-002"
result[0].label == "Widget A"
}
}
File Import Test (flat directory)
Testing a file-triggered route that loads CSV into Pricefx, with the file directly in the watched directory:
class FileImportTest extends IntegrationTestSpecification {
def "should load data from csv to pricefx partition"() {
given: 'mock pricefx endpoint and seed entities'
mockPost('/pricefx/test-partition/loaddata/P')
seedProperty("data.directory", temporaryFolder.toString())
seedEntities([routePrn("load-products-route.xml"), mapperPrn("product-mapper.xml")])
when: 'csv file is created in data directory'
seedFile("data/products.csv")
then: 'verify that route converts csv to json and sends it to pricefx'
def conditions = new PollingConditions(timeout: 10)
conditions.eventually {
verifyPost("/pricefx/test-partition/loaddata/P", 1,
expectedResponse("requests/load-products-request.json"))
}
}
}
File Import Test (subdirectory)
When the route watches a subdirectory (file:{{data.directory}}/import/products), use seedFile with the target subdirectory parameter:
class FileImportSubdirTest extends IntegrationTestSpecification {
def "should load data from csv in subdirectory"() {
given: 'mock pricefx endpoint and seed entities'
mockPost('/pricefx/test-partition/loaddata/P')
seedProperty("data.directory", temporaryFolder.toString())
seedEntities([routePrn("load-products-subdir-route.xml"), mapperPrn("product-mapper.xml")])
when: 'csv file is placed in the import/products subdirectory'
seedFile("data/products.csv", "import/products")
then: 'verify that route picks up file from subdirectory'
def conditions = new PollingConditions(timeout: 10)
conditions.eventually {
verifyPost("/pricefx/test-partition/loaddata/P", 1,
expectedResponse("requests/load-products-request.json"))
}
}
}
seedFile("data/products.csv", "import/products") reads products.csv from the classpath and places it in temporaryFolder/import/products/products.csv, creating directories automatically.
Quartz/Timer Route Test
For routes triggered by a cron schedule, use seedTriggeredRoute to replace the quartz: consumer with direct:, then triggerRoute to fire it manually:
class QuartzExportTest extends IntegrationTestSpecification {
def "should export products on quartz trigger"() {
given: 'mock pricefx fetch endpoint and seed quartz route'
mockPost('/pricefx/test-partition/productmanager.fetchproducts',
'{"response":{"data":[{"sku":"P-001","label":"BMW"}],"status":0}}')
seedDefaultConnection()
seedTriggeredRoute("export-products-quartz.xml")
when: 'route is triggered manually'
triggerRoute("export-products-quartz")
then: 'products were fetched from pricefx'
verifyPost("/pricefx/test-partition/productmanager.fetchproducts", 1)
}
}
seedTriggeredRoute works with quartz:, timer:, pfx-event:, kafka:, aws2-s3:, and rest: consumers — it replaces any of them with a direct: endpoint.
Quartz Lifecycle Test (stop and restart)
To verify a scheduled route can be paused and resumed via the Camel RouteController, use the same seedTriggeredRoute setup, then exercise the lifecycle API directly:
class QuartzLifecycleTest extends IntegrationTestSpecification {
def "scheduled route can be stopped and restarted"() {
given: 'seed quartz route and mock the downstream API'
mockPost('/pricefx/test-partition/productmanager.fetchproducts',
'{"response":{"data":[],"status":0}}')
seedDefaultConnection()
seedTriggeredRoute("export-products-quartz.xml")
def routeId = "export-products-quartz"
def controller = context.getRouteController()
expect: 'route starts in Started state'
controller.getRouteStatus(routeId) == org.apache.camel.ServiceStatus.Started
when: 'route is stopped'
controller.stopRoute(routeId)
then: 'status reflects stopped — sending to it now fails'
controller.getRouteStatus(routeId) == org.apache.camel.ServiceStatus.Stopped
sendBody("direct://trigger-${routeId}", "ignored").isFailed()
when: 'route is restarted and triggered'
controller.startRoute(routeId)
triggerRoute(routeId)
then: 'pfx-api received the new trigger'
verifyPost("/pricefx/test-partition/productmanager.fetchproducts", 1)
}
}
Useful when partners need to verify their integration's start/stop hooks or guard against double-processing while a route is paused.
REST Inbound Route Test
For routes that expose an HTTP endpoint via rest:get:/path or rest:post:/path, seedTriggeredRoute rewrites the consumer to direct:trigger-{routeId} so the REST DSL servlet is not required at test time. triggerRoute(routeId, body) then plays the role of an inbound HTTP request:
class RestPingTest extends IntegrationTestSpecification {
def "GET /v1/ping returns the health body"() {
given: 'seed REST inbound route — rest: becomes direct:'
seedTriggeredRoute("rest-inbound-ping.xml")
when: 'route is invoked as if the REST DSL servlet received a GET'
def exchange = triggerRoute("rest-inbound-ping")
then: 'route produced the configured JSON body without failing'
!exchange.isFailed()
exchange.getMessage().getBody(String.class) == '{"status":"ok"}'
}
def "POST /v1/products returns 400 for invalid payload"() {
given:
seedTriggeredRoute("rest-inbound-products.xml")
when:
def exchange = triggerRoute("rest-inbound-products", [sku: "P-001"]) // price missing
then: 'doCatch path set CamelHttpResponseCode=400 and a JSON error body'
!exchange.isFailed()
exchange.getMessage().getHeader("CamelHttpResponseCode").toString() == "400"
exchange.getMessage().getBody(String.class).contains("Missing mandatory fields")
}
}
The body passed to triggerRoute(routeId, body) simulates what the REST DSL would unmarshal from the request body (e.g. a Map when binding-mode=json). Set CamelHttpResponseCode in the route (not the literal string Exchange.HTTP_RESPONSE_CODE) — that is the actual constant value Camel uses when serializing the HTTP response.
Event-Driven Route Test
For routes that consume events via pfx-event:fetch, use EventTestSpecification:
class EventHandlerTest extends EventTestSpecification {
def "should route PADATALOAD event to correct handler"() {
given: 'seed event route'
seedEventRoute("event-fetch-route.xml")
when: 'PADATALOAD event is received'
simulateEvent("eventFetch", "PADATALOAD_COMPLETED")
then: 'event was processed and routed to dataload handler'
eventProcessedSuccessfully()
context.getEndpoint("mock:dataload-handler").receivedExchanges.size() == 1
}
def "should pass extra fields to handler"() {
given: 'seed event route'
seedEventRoute("event-fetch-route.xml")
when: 'event with extra fields is received'
simulateEvent("eventFetch", "PADATALOAD_COMPLETED", [
sourceTable: "Products",
recordCount: "1500"
])
then: 'handler receives the extra fields in body'
eventProcessedSuccessfully()
def body = context.getEndpoint("mock:dataload-handler")
.receivedExchanges[0].getIn().getBody(Map)
body.sourceTable == "Products"
}
}
seedEventRoute replaces pfx-event:fetch with direct: before deploying. simulateEvent sends a Map body matching the real event format ([eventType: "...", ...]) with event.typedId and event.name headers.
Groovy Class in a File Import Route
A common partner pattern: a Groovy processor validates or transforms rows during import. Test both the Groovy class and the route together:
class ImportWithGroovyValidationTest extends UnitTestSpecification {
@Autowired
private CamelContext context
def "should skip rows where sku is empty using groovy filter"() {
given: 'seed groovy filter class'
seedClass('''package repo.classes
import org.apache.camel.Exchange
import org.apache.camel.Processor
class SkuValidationProcessor implements Processor {
@Override
void process(Exchange exchange) throws Exception {
def rows = exchange.getIn().getBody(List)
def validRows = rows.findAll { row -> row.sku != null && !row.sku.trim().isEmpty() }
exchange.getIn().setBody(validRows)
}
}''')
and: 'seed mapper and route'
seedMapper('''<mappers>
<loadMapper id="validated-product-mapper">
<body in="sku" out="sku"/>
<body in="label" out="label"/>
</loadMapper>
</mappers>''')
mockPost('/pricefx/test-partition/loaddata/P')
seedDefaultConnection()
seedRoute('''
<routes xmlns="http://camel.apache.org/schema/spring">
<route id="import-with-validation">
<from uri="direct:start"/>
<to uri="pfx-csv:unmarshal?skipHeaderRecord=true"/>
<to uri="bean:skuValidationProcessor"/>
<to uri="pfx-api:loaddata?objectType=P&mapper=validated-product-mapper"/>
</route>
</routes>
''')
when: 'csv with empty sku rows is sent'
Exchange exchange = sendBody("direct:start", "sku,label
PROD-001,Widget A
,Empty Row
PROD-002,Widget B")
then: 'only valid rows are loaded (2 out of 3)'
verifyPost("/pricefx/test-partition/loaddata/P", 1)
}
}
Running Tests
Unit Tests (default)
# Run all unit tests across all modules
mvn test
# Run unit tests in a specific module
mvn test -pl pricefx-integration
# Run a single test class
mvn test -pl pricefx-integration -Dtest="PfxApiProducerFetchSpec"
Integration Tests
The project defines Maven profiles for integration test execution:
# Run integration tests (uses maven-failsafe-plugin)
mvn verify -P integration-tests
# Run Camel-specific integration tests
mvn verify -P integration-camel-tests
The integration-tests profile sets spock.test.category=IntegrationTests and the integration-camel-tests profile sets spock.test.category=IntegrationCamelTests. Both profiles disable the surefire plugin (unit tests) so only failsafe (integration tests) runs.
Java Version Requirement
Tests require Java 17. If your default mvn uses a different JDK, set JAVA_HOME before running:
export JAVA_HOME=/opt/homebrew/Cellar/openjdk@17/17.0.14/libexec/openjdk.jdk/Contents/Home
mvn test
seedEntities and Production Files
seedEntities() loads configuration directly from the project's FileSystemConfigurationRepository — the same files used in production. This is intentional: it tests real mappers and routes, not copies.
class MyIntegrationTest extends IntegrationTestSpecification {
def setup() {
// Load all beans, routes, mappers from src/main/resources/repo/
seedEntities()
// Or load selectively by PRN prefix
seedEntities(include: ["routes/import-products"])
}
}
Limitations of seedFile():
-
seedFile(path, content)only supports flat paths — no subdirectory separators. Files must be placed directly in the category root (e.g.,mappers/my-mapper.xml, notmappers/sub/my-mapper.xml). -
For subdirectory structures, use
IntegrationTestSpecificationwithseedEntities()instead.
Testing Async Routes
For routes triggered by a scheduler or file consumer, use Spock's PollingConditions to wait for async processing:
import spock.util.concurrent.PollingConditions
def conditions = new PollingConditions(timeout: 10, delay: 0.5)
when:
// trigger or drop a file that starts the route
then:
conditions.eventually {
// assert the expected side effect (e.g., WireMock received a request)
wireMockServer.verify(1, postRequestedFor(urlPathMatching("/pricefx/.*/loaddata/.*")))
}
PollingConditions.eventually {} retries the block until it passes or the timeout expires. Prefer this over Thread.sleep().
Key Conventions
-
Test file naming: Spec classes end with
Spec(for Surefire) orTest(for both Surefire and Failsafe). The naming determines which plugin picks them up. -
WireMock partition: The default stubbed partition is
test-partitionin theintegration-testmodule andmvichin thepricefx-integrationmodule. -
Clean state: Both base classes reset the WireMock server and Camel context between tests. Avoid relying on state from a previous test method.
-
Repository-backed tests: Place test configuration files (routes, mappers, beans, classes, properties) under
src/test/resources/repo/when usingIntegrationTestSpecification. For integration tests that use real production config, useseedEntities()directly. -
Use
@Unrollfor parameterized tests: Produces a separate test report entry perwhererow, making failures easier to diagnose. -
Prefer
direct:startendpoints: Replace timer or other consumers withdirect:startusingAdviceWithor by defining routes withdirect:startdirectly. -
Use
PollingConditionsfor async routes: Never sleep — usePollingConditions.eventually {}to wait for side effects in routes triggered by timers or file consumers.