---
title: Testing
---
# Testing
This page covers how the IoT DC3 backend layers its tests, when a test is required, and which command runs each layer. By the end you'll know how to pick the right test type, run unit, integration, and E2E tests locally, and tell what kind of test a change needs before it counts as done.
> You are here: you've read the [development overview and conventions](./), and you want to validate a change to mergeable quality. Testing tips specific to writing drivers are in [driver authoring](./driver-authoring).
## Why layer the tests
Heavier is not better. An assertion can catch a logic error in milliseconds — that's no reason to spin up a PostgreSQL container to confirm it. But pure mocks can't exercise cross-service message contracts or time-series persistence, so IoT DC3 splits testing into three layers. Lower layers are faster, more numerous, and more isolated; higher layers are slower, fewer, and closer to the real chain:
- **Unit tests** are the most numerous and the fastest. They check isolated business logic without starting the Spring context.
- **Integration tests** use Testcontainers to spin up real PostgreSQL/TimescaleDB, RabbitMQ, and MQTT, exercising cross-component collaboration — DAL, gRPC, and messaging.
- **E2E tests** are the fewest. They cover end-to-end business chains (command dispatch, event routing, time-series table operations). They're off by default and require an environment variable to run.
The rule for picking a layer: use a unit test if you can avoid external dependencies; reach for integration only when a real container is needed to reproduce the behavior (SQL, messaging, gRPC contracts); reserve E2E for validating the entire chain.
## The three-layer pyramid and its commands
The diagram maps the three layers, each layer's scope and startup mode, and the command that triggers it. The executors (Surefire/Failsafe) and the gate (`DC3_E2E`) decide which layers a single `mvn` run reaches.
```mermaid
flowchart TB
subgraph E2E["E2E · Fewest · Slowest"]
E["dc3-e2e
End-to-end business chain
Command dispatch / Event routing / Time-series tables"]
end
subgraph IT["Integration · Medium · Medium speed"]
I["*IT.java (run by Failsafe)
DAL / gRPC InProcess / Message contracts
Real PostgreSQL / RabbitMQ / MQTT"]
end
subgraph UT["Unit · Most · Fastest"]
U["*Test.java (run by Surefire)
Isolated business logic
No Spring context startup"]
end
U -->|"make test"| I
I -->|"make test-it (needs container runtime)"| E
E -->|"make test-e2e (DC3_E2E=true + stack up)"| Done["Before merge: make coverage aggregates JaCoCo"]
```
::: warning Integration and E2E require a container runtime
Both `make test-it` and `make test-e2e` use Testcontainers to start PostgreSQL/TimescaleDB, RabbitMQ, and other containers at runtime, so a working container runtime (podman or Docker) must be present locally. E2E additionally boots a full set of real dependencies on a shared Docker network. Without a container runtime these two commands fail outright rather than skip — `make test` (pure unit) is unaffected.
:::
The table below compares each layer's goal, executor, and tech stack (reference only; the prose above is authoritative on detail):
| Layer | Goal | Executor / Gate | Technical approach |
|-------|------|-----------------|--------------------|
| Unit tests | Quickly verify isolated business logic | Surefire | JUnit 5, Mockito 5, AssertJ; Reactor `StepVerifier` for reactive verification |
| Integration tests | Verify real infrastructure and cross-module collaboration | Failsafe; `*IT.java` | Testcontainers, gRPC InProcess, RabbitMQ harness |
| E2E tests | Verify end-to-end business chains | `@EnabledIfEnvironmentVariable(named = "DC3_E2E")` | `dc3-e2e`, Testcontainers (shared Docker network) |
## How to run it locally
Backend commands go through the Makefile under `iot-dc3/`. The four most common:
```bash
make test # Unit tests (Surefire)
make test-it # Integration tests (Failsafe + Testcontainers; needs a container runtime)
make test-e2e # E2E: equivalent to DC3_E2E=true mvn -pl dc3-e2e -am -Pe2e verify
make coverage # Aggregated JaCoCo report (dc3-coverage -am verify)
```
`make test-e2e` already sets `DC3_E2E=true` and runs `-Pe2e` only on the `dc3-e2e` module, so you don't need to export the environment variable by hand. To run a single module or a single case, call Maven directly:
```bash
# Unit tests for a specific module
mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-manager
# A single test class / a single test method
mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-manager -Dtest=DeviceControllerTest
mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-public -Dtest="RTest#testOkWithData"
```
After `make coverage` finishes, the aggregated report lands at:
```text
dc3-coverage/target/site/jacoco-aggregate/index.html
```
::: info Why Failsafe points to outputDirectory
The parent POM configures Failsafe with `classesDirectory=${project.build.outputDirectory}`. Spring Boot repackages a module's artifact into an executable fat jar, which can't be loaded directly on the Failsafe classpath; pointing the test run at the unrepackaged, ordinary compiled classes is what lets the classes under test load correctly. Keep this in mind when writing integration tests for executable modules like drivers.
:::
## Frontend test commands
The frontend (`iot-dc3-web/`) uses pnpm + Vitest (unit/api/component/view) and Playwright (E2E), independent of the backend:
::: code-group
```bash [Vitest suites]
pnpm test # All Vitest suites
pnpm test:unit # tests/unit
pnpm test:api # tests/api
pnpm test:component # tests/component
pnpm test:views # tests/views
pnpm test:guard # tests/guardrails (AI coding guardrails)
pnpm test:ci # vitest run --coverage (CI gate)
```
```bash [Playwright E2E]
pnpm test:e2e # headless chromium
pnpm test:e2e:headed # Visible browser (E2E_HEADLESS=false)
```
:::
## When a test is mandatory
The core convention: **for a bug fix, first write a failing test that reproduces it, then fix.** Turn "this bug no longer occurs" into an executable test that's red right now; the loop only closes when the fix turns it green. Other changes get tests in proportion to risk:
| Change type | Requirement |
|-------------|-------------|
| Bug fix | First add a regression test that reproduces the problem, then implement the fix |
| New feature / behavior change | Add unit tests, and integration tests as risk warrants |
| Refactoring | Preserve existing coverage; add contract tests for implicit contracts |
| DAL / SQL change | Add Testcontainers tests and run `make test-it` |
| gRPC proto change | Update the server, client, and contract tests in sync |
| Docs-only / formatting change | No Java tests needed; a docs build or format check suffices |
## Reusable test infrastructure
The `dc3-common-test` module gathers the containers and base classes shared across modules, so each module doesn't build its own. Integration tests reuse these singleton containers and harnesses directly:
| Tool | Purpose |
|------|---------|
| `PgTimescaleContainer` | Singleton `timescale/timescaledb-ha:pg18` container, for database and time-series table tests |
| `RabbitContainer` | RabbitMQ container, for message publish, confirm, and consume tests |
| `MqttContainer` | MQTT container, for MQTT driver tests |
| `GrpcInProcessExtension` | JUnit 5 extension: one in-process gRPC server + managed channel per test |
| `RabbitTestHarness` | Send and receive RabbitMQ within tests, with `awaitTrue()` backed by Awaitility |
| `FixedClockConfig` | `@TestConfiguration` that pins the `Clock` bean to a deterministic instant |
Two contract-test base classes guard cross-cutting conventions:
- `EnumContractTest`: via `@TestFactory`, checks that enum `getIndex()` is unique, `ofIndex()` round-trips, and constant names stay stable.
- `SecretFieldContractTest`: checks that sensitive fields such as `apiKey`, `password`, `secret`, and `token` don't leak through `@ToString` or serialization.
::: tip Time, randomness, and waiting
Inject `java.time.Clock` instead of calling `LocalDateTime.now()` directly; use `FixedClockConfig` when you need fixed time. Use Awaitility for async waits — never bare `Thread.sleep`. Use `WebTestClient` for WebFlux, not `MockMvc`; use an in-process channel for gRPC, and don't open a real socket.
:::
## Coverage gate
`make coverage` aggregates JaCoCo data from every module, gated by the thresholds in `dc3-coverage/pom.xml`:
| Metric | Current threshold |
|--------|-------------------|
| Line coverage | `coverage.line.minimum = 0.20` (20%) |
| Branch coverage | `coverage.branch.minimum = 0.15` (15%) |
The thresholds are deliberately modest for now, to keep progress moving while the test suite is still expanding. The check looks only at the static minimum — any metric below the table above blocks the change, with no comparison against a historical baseline. When raising a threshold, submit tests that support the new bar rather than just bumping the number.
## CI workflows
PRs and pushes run tests across three GitHub Actions workflows, one-to-one with the local commands:
| Workflow | Trigger | Main task |
|----------|---------|-----------|
| `ci.yml` | push / PR to develop, release, main | Fast compile |
| `test.yml` | push / PR to develop, release, main | Unit, integration, coverage |
| `e2e.yml` | push to develop, release, main or manual trigger | E2E |
Before merging, confirm: the unit and integration jobs pass; coverage isn't below the `dc3-coverage/pom.xml` thresholds (below blocks); and for behavior changes, the description states what's been verified and what risks remain.
## Further reading
- [Development overview and conventions](./) — overall conventions and command entry points for secondary development
- [Driver authoring](./driver-authoring) — how to add the protocol layer and integration tests when deriving a new driver
- [Environment variables](../quickstart/environment) — dependency hosts and ports needed to run Java locally