chl-wxp commented on code in PR #11212:
URL: https://github.com/apache/seatunnel/pull/11212#discussion_r3502698619
##########
docs/en/developer/test-coding-guide.md:
##########
@@ -0,0 +1,539 @@
+---
+title: Test Coding Guide
+---
+
+# Test Coding Guide
+
+This guide describes how to write a high-quality, stable End-to-End (E2E) or
Unit Test for Apache SeaTunnel.
+It complements the [Coding Guide](coding-guide.md): the Coding Guide covers
general PR quality, while this
+guide focuses specifically on test stability and resource safety.
+
+A good SeaTunnel test is deterministic (the same result on every run),
leak-free (it releases every
+resource it opens), and cheap (it starts the fewest containers possible). The
guidelines below apply these
+principles to the two kinds of tests SeaTunnel contributors write.
+
+SeaTunnel distinguishes two test layers:
+
+- **Unit tests** (`*Test`, run by the Surefire plugin) validate connector or
core logic in isolation,
+ without external systems. See [Unit Test Guidelines](#unit-test-guidelines).
+- **End-to-End (E2E) tests** (`*IT`, run by the Failsafe plugin) validate
source, transform, and sink
+ behavior against real services in Testcontainers. See [E2E Test
Guidelines](#e2e-test-guidelines).
+
+When a pull request changes both logic and integration behavior, add tests in
both layers and run both
+before submitting.
+
+## Unit Test Guidelines
+
+### 1. Test behavior and contract, not implementation details
+
+A unit test should verify input-output behavior and option contracts. Avoid
testing private
+method internals or temporary implementation structure.
+
+Real example from `JdbcSourceFactoryTest` (module: `connector-jdbc`):
+
+```java
+private Map<String, Object> baseConfig() {
+ Map<String, Object> cfg = new HashMap<>();
+ cfg.put("url", "jdbc:mysql://localhost:3306/test");
+ cfg.put("driver", "com.mysql.cj.jdbc.Driver");
+ return cfg;
+}
+
+@Test
+void testValidConfigWithTablePath() {
+ Map<String, Object> cfg = baseConfig();
+ cfg.put("table_path", "test.users");
+ Assertions.assertDoesNotThrow(() -> validate(cfg));
+}
+```
+
+This test validates the external configuration contract and remains stable
even if internal factory
+implementation changes.
+
+### 2. Keep unit tests deterministic and local
+
+Unit tests should run fast and deterministically:
+
+- no `Thread.sleep`
+- no random assertions without fixed seed
+- no dependency on wall-clock timing
+
+If a case introduces cross-process or external dependencies, clearly classify
it in the proper test
+layer and keep assertions deterministic and diagnosable.
+
+### 3. Isolate dependencies with mock, stub, or fake
+
+A unit test should isolate the target behavior from external IO. Use in-memory
or lightweight
+test doubles for collaborators when possible.
+
+Keep one assertion scope per test method, so failures point to a single
behavior.
+
+Real engine-side mock example from `JobInfoServiceNullSafetyTest` (module:
`seatunnel-engine-server`):
+
+```java
+@BeforeEach
+void setUp() {
+ nodeEngine = mock(NodeEngineImpl.class);
+ hazelcastInstance = mock(HazelcastInstance.class);
+ runningJobInfoMap = mock(IMap.class);
+ finishedJobStateMap = mock(IMap.class);
+ finishedJobMetricsMap = mock(IMap.class);
+ finishedJobVertexInfoMap = mock(IMap.class);
+
+ when(nodeEngine.getHazelcastInstance()).thenReturn(hazelcastInstance);
+
when(hazelcastInstance.getMap(Constant.IMAP_RUNNING_JOB_INFO)).thenReturn(runningJobInfoMap);
+
when(hazelcastInstance.getMap(Constant.IMAP_FINISHED_JOB_STATE)).thenReturn(finishedJobStateMap);
+ when(hazelcastInstance.getMap(Constant.IMAP_FINISHED_JOB_METRICS))
+ .thenReturn(finishedJobMetricsMap);
+ when(hazelcastInstance.getMap(Constant.IMAP_FINISHED_JOB_VERTEX_INFO))
+ .thenReturn(finishedJobVertexInfoMap);
+
+ jobInfoService = new JobInfoService(nodeEngine);
+}
+
+@Test
+void shouldReturnJobIdOnlyWhenFinishedMetricsIsMissing() {
+ when(runningJobInfoMap.get(jobId)).thenReturn(null);
+ when(finishedJobStateMap.get(jobId)).thenReturn(jobState);
+ when(finishedJobMetricsMap.get(jobId)).thenReturn(null);
+
+ JsonObject result = jobInfoService.getJobInfoJson(jobId);
+ Assertions.assertEquals(jobId.toString(),
result.getString(RestConstant.JOB_ID, null));
+}
+```
+
+This example illustrates three practices:
+
+- It mocks every external map dependency at the boundary instead of starting
the engine services.
+- It uses `when(...).thenReturn(...)` to shape a single business branch per
test.
+- Its assertions target the observable output (the result JSON contract), not
internal calls.
+
+### 4. Verify exception type and message for invalid input paths
+
+For negative paths, assert both exception class and core message, so the test
protects user-facing
+error quality.
+
+Real example from `MongodbIncrementalSourceFactoryTest` (module:
`connector-cdc-mongodb`):
+
+```java
+Assertions.assertThrows(
+ MongodbConnectorException.class,
+ () ->
+ MongodbSourceConfigProvider.newBuilder()
+ .startupOptions(
+ new StartupConfig(StartupMode.EARLIEST, null,
null, null)));
+```
+
+### 5. Use clear naming and Arrange-Act-Assert structure
+
+- Name classes as `*Test` and methods as explicit behavior statements.
+- Keep each test in Arrange-Act-Assert order.
+- Avoid large helper logic inside test methods; extract reusable setup into
focused helpers.
+
+## Unit Test Checklist
+
+- [ ] Class name follows `*Test`; method names describe behavior or error case
+- [ ] Unit tests keep deterministic execution and avoid non-essential runtime
dependencies
+- [ ] Assertions target behavior and contract, not internal implementation
details
+- [ ] Negative tests assert both exception type and key error message
+- [ ] Test data setup is minimal and reusable
+
+## E2E Test Guidelines
+
+An E2E test extends `TestSuiteBase`, runs the connector inside a
Testcontainers-managed engine container, and
+verifies the result of a real job. The six rules below keep such tests
deterministic, leak-free, and cheap.
+
+### 1. Use Dynamic Ports, Never Hardcode
+
+Testcontainers assigns a random host port at startup. Hardcoding a port causes
conflicts in CI, where the
+port may already be taken or multiple suites run in parallel.
+
+Resolve the host port from the container at runtime:
+
+```java
+// Bad — collides in CI
+String brokerUrl = "tcp://127.0.0.1:61616";
+
+// Good — let Testcontainers assign the host port
+String brokerUrl = "tcp://" + container.getHost() + ":" +
container.getMappedPort(61616);
+config.setPort(container.getFirstMappedPort()); // when only one port is
exposed
+```
+
+Apply the same rule to all external services:
+
+```java
+// Database
+String jdbcUrl = String.format(
+ "jdbc:mysql://%s:%d/%s",
+ mysqlContainer.getHost(), mysqlContainer.getMappedPort(3306),
DATABASE);
+
+// HTTP service
+String endpoint = String.format(
+ "http://%s:%d", serviceContainer.getHost(),
serviceContainer.getMappedPort(8080));
+```
+
+:::caution Job config files are the exception
+
+The SeaTunnel job runs inside the Docker network, so its config (`.conf`) must
reference the container's
+network alias (`withNetworkAliases("activemq-host")`) and the internal port
(e.g. `61616`), not the
+mapped host port.
+
+:::
+
+### 2. Wait on Conditions, Never `Thread.sleep`
+
+`Thread.sleep` is non-deterministic: too short and the test becomes flaky, too
long and CI is slowed down. It
+also produces no useful error when the expected state never arrives. Use
+[Awaitility](https://github.com/awaitility/awaitility) to poll for the actual
condition instead.
+
+```java
+// Bad — flaky, wasteful, and silent on failure
+container.executeJob("/job.conf");
+Thread.sleep(30000);
+Assertions.assertIterableEquals(expected, query());
+
+// Good — returns as soon as the condition holds, fails with the last
assertion error
+Awaitility.await()
+ .atMost(60, TimeUnit.SECONDS)
+ .pollInterval(2, TimeUnit.SECONDS)
+ .pollDelay(Duration.ZERO)
+ .untilAsserted(() -> Assertions.assertIterableEquals(expected,
query()));
+```
+
+Choose the timeout from the scenario rather than picking a round number:
+
+| Scenario | `atMost` | `pollInterval` | Why
|
+|-----------------------------------|------------|----------------|--------------------------------------|
+| Container / client readiness | 2 min | 1 s | Image pull
+ service init is slow |
+| Job reaches `RUNNING` state | 1 min | 2 s | Scheduling
overhead |
+| Batch job result verified | 60 s | 2 s | Small
dataset completion |
+| Kafka / MQ message consumption | 30–60 s | 1 s | Consumer
group rebalance |
+| CDC / schema-change propagation | 60–120 s | 2–5 s | Binlog
latency + snapshot |
+
+When the client throws until the service is ready, add `.ignoreExceptions()`:
+
+```java
+Awaitility.given()
+ .ignoreExceptions()
+ .pollInterval(500, TimeUnit.MILLISECONDS)
+ .atMost(180, TimeUnit.SECONDS)
+ .untilAsserted(this::initProducer);
+```
+
+A reusable helper for the common "wait for job RUNNING" step:
+
+```java
+private void awaitJobRunning(TestContainer container, String jobId) {
+ Awaitility.await()
+ .pollInterval(2, TimeUnit.SECONDS)
+ .atMost(1, TimeUnit.MINUTES)
+ .untilAsserted(
+ () -> Assertions.assertEquals("RUNNING",
container.getJobStatus(jobId)));
+}
+```
+
+### 3. Release Resources Promptly
+
+A leaked connection exhausts container resources and available host ports, and
can cause CI to hang. Every
+resource you open must be closed. Most E2E tests implement the `TestResource`
interface and override its
+`tearDown()` method, which runs once after all tests in the class. Close
resources there in reverse order of
+creation, with a null check on each (the test may have failed before the
resource was created), and stop any
+manually-started container explicitly.
+
+```java
+@AfterAll
+@Override
+public void tearDown() throws Exception {
+ if (producer != null) {
+ producer.close();
+ }
+ if (session != null) {
+ session.close();
+ }
+ if (connection != null) {
+ connection.close();
+ }
+ if (container != null) {
+ container.stop();
+ }
+}
+```
+
+For resources scoped to a single method, prefer try-with-resources over manual
close:
+
+```java
+private void executeDml(String sql) {
+ try (Connection conn = getJdbcConnection();
+ Statement stmt = conn.createStatement()) {
+ stmt.execute(sql);
+ } catch (SQLException e) {
+ throw new RuntimeException("Execute DML failed: " + sql, e);
+ }
+}
+```
+
+Cleanup checklist:
+
+- [ ] JDBC connections / statements closed
+- [ ] Message broker connections, sessions, producers, consumers closed
+- [ ] Custom clients (HTTP, gRPC) shut down
+- [ ] `ExecutorService` shut down with a timeout
+- [ ] Async `CompletableFuture` jobs cancelled
+- [ ] Temporary files / directories deleted
+- [ ] Manually-created Docker networks removed
+
+### 4. Submit Long-Running Jobs Asynchronously
+
+A streaming or CDC job runs until it is cancelled, so calling `executeJob`
inline blocks the test thread
+indefinitely and leaves no opportunity to inject data or assert intermediate
state. Submit such jobs with
+`CompletableFuture.supplyAsync`, wait until the job reaches `RUNNING`, run the
test steps, verify the result,
+then cancel the job.
+
+```java
+String jobId = "streaming-cdc-job";
+
+CompletableFuture<Void> job = CompletableFuture.supplyAsync(
+ () -> {
+ try {
+ container.executeJob("/streaming_job.conf", jobId);
+ } catch (Exception e) {
+ log.error("Job execution failed", e);
+ throw new CompletionException(e); // propagate, never swallow
+ }
+ return null;
+ });
+
+awaitJobRunning(container, jobId); // wait for RUNNING before proceeding
+
+insertCdcData(); // run test steps while the job is running
+
+Awaitility.await()
+ .atMost(60, TimeUnit.SECONDS)
+ .pollInterval(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> verifySinkResults());
+
+job.cancel(true); // cancel the job
Review Comment:
```suggestion
container.cancelJob(jobId); // cancel the job
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]