Testing
This service has two test suites, and the division between them is not "fast" and "slow" — it is what a test can see.
| Unit suite | Integration suite | |
|---|---|---|
Classes |
|
|
Count |
1695 tests |
59 tests in 7 classes |
Needs |
Nothing |
A running Docker |
Runs in |
Seconds |
Roughly 30 s warm, several minutes on a cold image cache |
Picked up by |
surefire ( |
failsafe ( |
Profile |
|
|
The two cannot collide: surefire’s default includes do not match *IT, so ./mvnw test never starts a container, and the integration profile is the only thing that binds failsafe at all.
Running
# The unit suite, with the coverage report. No Docker.
./mvnw -s .mvn/settings.xml -PUnitTests test
# Unit suite, THEN the integration suite. Needs Docker.
./mvnw -s .mvn/settings.xml -PIntegrationTests verify
# The integration suite alone, once the unit suite is known green.
./mvnw -s .mvn/settings.xml -PIntegrationTests verify -DskipUnitTests=true
# One integration class.
./mvnw -s .mvn/settings.xml -PIntegrationTests verify -DskipUnitTests=true \
-Dit.test=FccSchemaMigrationIT
verify and not integration-test: failsafe:verify is the goal that turns a failed IT into a failed build. failsafe:integration-test on its own records the failure and exits 0, which is the classic way an integration suite ends up green while red.
skipUnitTests is declared by the profile and wired into surefire by hand, because Maven has no built-in property for "skip surefire but not failsafe" — -DskipTests and -Dmaven.test.skip stop both, and the latter also stops test compilation.
The profile sets spring-boot.repackage.skip=true: verify runs after package, and building a 100 MB fat jar to then throw it away would be most of the wall clock of a run whose point is the containers.
From the IDE
.run/UnitTests.run.xml and .run/IntegrationTests.run.xml are committed; IntelliJ reads .run/ at the project root by itself, so both appear in the run configuration list on checkout with nothing to set up.
Both are patterns over the fully qualified class name — a full-match java.util.regex — and together they partition the tree exactly as surefire and failsafe do:
| Runner | Pattern |
|---|---|
|
|
|
|
UnitTests was an "All in directory" scope over src/test/java until the integration suite landed beside it. That scope does not know about the *IT rule — it collects every class with test methods under the directory — so it had started booting three containers on a run that is supposed to need nothing and take seconds. The pattern is the same split, spelled the one way the IDE can express it.
IntegrationTests also sets HZ_MEMBERS to the empty string, which is the IDE’s version of the env -u in HZ_MEMBERS in the Environment Would Aim the Suite at Your Own Cluster: IntelliJ passes the parent environment through, an env entry can only set a variable and never unset one, and both HazelcastClientConfig and the guard in AbstractFccIT test the variable with StringUtils.hasText.
What the Integration Suite Is For
It is not coverage. The unit suite already asserts every rule this service has, with the collaborators mocked; a second suite doing the same thing more slowly would be worth nothing. What a unit test structurally cannot see is everything that exists only between the components, and that is the entire remit of these seven classes.
| Class | What only it can see |
|---|---|
|
The Liquibase changelog against the entities: every table and column every |
|
The Flyway migrations against the row records. Each result table is written through its REAL DAO and read back, which covers the migration, the hand-written column list, the binding order and the driver’s |
|
The whole save of a multi-configuration campaign over real HTTP: nested bean validation, the validator’s per-configuration rules, a device resolve per configuration, N fleet snapshots, N settings rows, and the read endpoint projecting it all back. The payload is deliberately the awkward case — two configurations of different diagnostics types over overlapping fleets. |
|
That one run is N activations, one per configuration, with their own idempotency keys — asserted against rows, because the uniqueness of the keys is a uniqueness of rows. And Java serialization of the shared DTO package over a real Hazelcast member, in the outbound direction. |
|
That a campaign survives |
|
What a stop and a pause each leave behind, over real HTTP: a stop lands the campaign in |
|
A measurement in the inbound direction, end to end: a real batch onto the real queue, through the real listener and flush, into real ClickHouse. Its fixture is built so the TIME_BASED and FILE_BASED formulas disagree about the verdict — 90 000 Kbps through the full-loading window, 24 000 Kbps through BOM..EOM, against a 100 000 Kbps tier at 80% — so a test whose formulas agreed could not have caught the wrong denominator. |
The DTO Contract Is the Reason Hazelcast Is Real
com.friendly.fcc.dto is byte-identical in this repository and in the ACS, is Java-serialized onto the queues, and has no handshake and no schema negotiation of any kind (DTO Contract Rules). A field removed, reordered into an incompatible shape, or made non-serializable fails at the far end with an InvalidClassException on a production member, long after the build was green. Every unit test of the publisher and of the listeners mocks the queue, so none of them serializes anything at all.
FccActivationDispatchIT and FccResultIngestIT therefore use a real member in both directions, and between them they round-trip the contract-1.6 additions: the two FccSpeedTargetsDto parameters outbound, and the nested List<FccResultIntervalDto> inbound — the one shape in this contract that a careless change can make unserializable.
The Containers
FccContainers holds three, started once per JVM in a static initialiser and never stopped; Testcontainers' own Ryuk sidecar reaps them when the JVM exits. The failsafe configuration pins forkCount=1 / reuseForks=true so that "once per JVM" really means once per suite.
| Image | Matches | Notes |
|---|---|---|
|
|
One instance, three schemas — |
|
Likewise |
Database |
|
The version the client is pinned to |
The deployment runs an FT-tagged build of the same version; what the suite exercises is the wire protocol and the queue semantics, which the public image carries identically. |
AbstractFccIT points app.ds.{ftfcc,ftacs,iotw,clickhouse}.url / .username / .password and ft.fcc.hazelcast.members at them through @DynamicPropertySource. Those are the final property names the beans bind — not the vars.mysql.* host/port pairs application.yaml composes them from — which is what keeps the rest of the composition honest: the query string, the driver class and every Hikari setting still come from the profile the service ships.
Every property set is identical across the subclasses, so Spring’s context cache hands them one context and the migrations run once. FccSchemaMigrationIT is the single exception, because hbm2ddl=validate is a property and therefore a second cache key.
ftacs and iotw Are Seeded From the Schema Check’s Own List
src/test/resources/ftacs-schema.sql is the other side of FtacsSchemaCheckRunner.requiredColumns(), table for table and column for column; iotw-schema.sql likewise for IotwSchemaCheckRunner. Both schema checks stay enabled in the it profile — unlike localdev, which turns them off — so a column the service reads and the seed does not declare fails the suite at context start-up with the column named, which is the earliest and clearest place that drift can be caught. See the ftacs schema check.
The files are deliberately not the ACS’s real DDL: this repository does not own that schema and must never carry a copy of it. Types are the narrowest the read path accepts, there are no indexes beyond the primary keys, and nothing is seeded by them. What is pinned is the names and the shape the queries depend on.
The service’s own account is granted SELECT on those two schemas and nothing else — so FccFixtures writes its devices through a root connection, exactly as the real ACS is a different, privileged writer.
The it Profile Parks Every Scheduled Tick
src/test/resources/application-it.yaml is deliberately short. The point of the suite is the configuration the service actually ships, so the only things overridden are the ones that would otherwise make a test non-deterministic. No FCC behaviour knob is touched — a suite that ran against its own max-devices-per-test would assert its own numbers.
What it does change: all eight @Scheduled intervals go to 24h. Seven of the eight claim rows and write, so leaving them on would mean every assertion races a poller that may already have advanced the state under test. The ticks are @Scheduled(fixedDelayString = "${…}") to a man, so a long delay parks them; the tick methods are then called directly from the ITs, which is both deterministic and a better test — it asserts the tick’s own effect rather than waiting to observe it.
ClickHouseMigrationRunner is the exception that proves the rule: its startup attempt is not a tick and still runs, which is exactly what FccClickHouseMigrationIT asserts. Only its retry is parked.
Six Traps
"Could not find a valid Docker environment" With a Perfectly Healthy Docker
Testcontainers 1.21.3 bundles a docker-java that asks for /v1.41/… by default. Docker Engine 29 reports MinAPIVersion: 1.44 and answers 400 to every such request, and Testcontainers reports that as being unable to find Docker at all — which sends the reader looking for a socket that is in fact reachable (curl --unix-socket /var/run/docker.sock http://localhost/_ping answers OK throughout).
FccContainers therefore sets the system property api.version to 1.44 before the first container is constructed, and only if it is unset, so -Dapi.version=… still wins. 1.44 rather than the engine’s own 1.53: an engine only refuses a version newer than it supports, so naming the newest would break against an older daemon, while 1.44 is understood by every engine from Docker 25 onwards and is at or above the minimum of the newest.
HZ_MEMBERS in the Environment Would Aim the Suite at Your Own Cluster
HazelcastClientConfig reads System.getenv("HZ_MEMBERS") first and falls back to ft.fcc.hazelcast.members only when that is blank — the docker-compose convention it shares with the ACS and ui-backend. A @DynamicPropertySource cannot win against an environment variable, so a developer who has that variable exported (and anyone who has run the compose stack locally has) would have the suite publish ACTIVATE commands onto their own cluster, where a real ACS would pick them up and run real diagnostics against real devices.
A test cannot unset its own JVM’s environment, so AbstractFccIT refuses to run at all when it is set, and the failure message gives the fix:
env -u HZ_MEMBERS ./mvnw -s .mvn/settings.xml -PIntegrationTests verify
An Empty ClickHouse Database Does Not Exercise baselineOnMigrate
ClickHouseFlywayConfig pins baselineVersion("0") instead of taking the default of 1, because the QoE database is shared and never empty: on a clean FCC install Flyway meets a populated schema with no history table of its own, and with the default it would write a marker at version 1 and consider V1__create-fcc-result-speed-table.sql already applied — leaving the service with nowhere to write results (the M4 baseline fix).
A container database created from CLICKHOUSE_DB is empty, and Flyway does not baseline into an empty schema at all. Without a fixture the suite would pass with baselineVersion set to anything. FccContainers therefore creates QoE’s own diag_speed_test and diag_ip_ping as empty stubs before the context starts, and FccClickHouseMigrationIT asserts the recorded history is exactly 0, 1, 2, 3, 4.
TRUNCATE Between Tests Poisons the Result De-Duplication
FccResultIngestService de-duplicates incoming measurements on (activationId, serial, direction, iteration) with a one-hour TTL, in memory, for the lifetime of the JVM. AbstractFccIT.truncateCampaigns() restarts fcc_activation.id at 1 — so every test in a class that uses it dispatches activation 1 and reports against activation 1, and the first test’s measurement poisons the dedup key for all the others, whose results are then silently and correctly dropped. The symptom is a timeout waiting for a measurement that was never going to arrive.
An IT that puts measurements on the result queue therefore calls deleteCampaigns() instead: DELETE preserves the identity counters, so each test gets an activation id of its own. The de-duplication itself is never disabled or reset for the test — it is the production behaviour, and a suite that worked around it would stop noticing if it broke.
A Second Spring Context Doubles the Queue Consumers
FccSchemaMigrationIT carried a @TestPropertySource of its own, which looked local and harmless. It gave that one class a second Spring context — and a second context is a second HazelcastInstance, joining the same container cluster, with its own FccQueueListenerRunner and its own blocking take() loop on every queue.
Both contexts then race for each message. A measurement published by FccResultIngestIT lands in whichever won; when that is the other context it is buffered in an ingest service whose flush tick is parked, so it is never written and never dead-lettered. The test times out waiting for a row that a context it has no handle on consumed, and which tests fail is a coin flip — one run lost two, the next lost three different ones. The symptom pointed at the TIME_BASED formula, which is the last place the cause was.
AbstractFccIT.refuseASecondContext now counts HazelcastClient.getAllHazelcastClients() before every test and fails with the explanation if there is more than one. Every IT-wide property belongs in application-it.yaml; nothing below AbstractFccIT may carry @TestPropertySource, @SpringBootTest(properties = …), @MockitoBean or @DirtiesContext.
hibernate.hbm2ddl.auto=validate Cannot Be Switched On Here
The obvious way to check the changelog against the entities is hibernate.hbm2ddl.auto=validate, and it does not work in this service however it is spelled: FtfccJpaConfig builds the LocalContainerEntityManagerFactoryBean by hand and then does properties.put("hibernate.hbm2ddl.auto", "none") over whatever was configured. That is deliberate — no property on any installation can point Hibernate’s DDL tooling at a real schema — so a @TestPropertySource, an inlined @SpringBootTest property and an application-it.yaml entry are all silently discarded, and a test that merely sets one passes while checking nothing.
Worse, the property is unreachable in two different ways, and the first attempt at this hit both: application-it.yaml could not even outrank the common document of application.yaml for the same key, because a profile-specific file does not beat a profile-activated document of the main file.
Running Hibernate’s validator on a throwaway factory instead works, but it compares type names, which is a different and noisier question: it refuses this schema over fcc_acs_outbox.payload, where the column is LONGTEXT (the changelog’s ${clob.type}) and the mapping is @Lob String with no length, which MySQLDialect sizes as tinytext. Nothing is wrong — a LONGTEXT column serves a CLOB binding perfectly — and the only ways to silence it change production mapping to satisfy a test. Type names also differ wildly between MySQL and Oracle, which this schema supports.
So FccSchemaMigrationIT scans the entity package reflectively and asserts that every mapped table and column exists, which is the defect class that actually bites and is vendor-neutral. It also pins the convention it depends on — every persistent field carries an explicit @Column(name = …) — and it has been mutation-tested: a field mapped to a column the changelog does not create fails it, by name.
What the Integration Suite Deliberately Does Not Cover
Oracle. The schema supports both vendors and the changelog is written in ${number} / ${date} properties precisely so that it applies to either, but an Oracle container is an order of magnitude heavier than this whole suite and the vendor difference that matters — the changelog — is already asserted against MySQL. Running the same suite against Oracle is a profile away (a sibling of FccContainers and @ActiveProfiles("oracle")) and is deliberately not done on every run.
The ACS itself. Nothing here runs ACS code. What is asserted is that this side of the Hazelcast contract serializes and deserializes; whether the ACS agrees is asserted by the ACS’s own tests against the same byte-identical DTO package, and the deploy order that makes the pair safe is the contract rule, not something a container can check.
A servlet container. The REST ITs use MockMvc. The filter chain, the message converters, the bean validation and the exception handling are all in the MockMvc path — a real connector would add a thread pool and a port to every class and assert nothing new.