Build CoreOrdered learning track

Integration Testing with Real Infrastructure

Learn Java Formal Methods, Testing, Benchmarking, and Performance Engineering - Part 016

Integration testing Java services dengan real infrastructure: Testcontainers, PostgreSQL, Kafka, migration lifecycle, data isolation, network failure, external service simulation, CI reliability, dan production-grade dependency testing.

9 min read1627 words
PrevNext
Lesson 1640 lesson track0922 Build Core
#java#testing#integration-testing#testcontainers+5 more

Part 016 — Integration Testing with Real Infrastructure

Tujuan bagian ini: membangun kemampuan integration testing Java service dengan dependency nyata seperti PostgreSQL, Kafka, Redis, HTTP services, migration tools, dan containerized infrastructure tanpa membuat test suite lambat, flaky, atau tidak bisa dipercaya.

Part sebelumnya membahas contract testing:

Apakah pesan antar-service masih compatible?

Part ini membahas integration testing:

Apakah kode Java kita benar-benar bekerja dengan dependency nyata?

Unit test bisa membuktikan domain logic benar. Contract test bisa membuktikan message shape kompatibel. Tetapi masih ada kelas bug yang hanya muncul saat bertemu dependency nyata:

SQL syntax berbeda dari H2
PostgreSQL isolation behavior berbeda dari mock repository
Kafka consumer offset salah
transaction boundary bocor
JSONB operator salah
migration gagal
index tidak dipakai
connection pool exhausted
serialization config berbeda
timezone conversion salah
foreign key constraint dilanggar
retry menghasilkan duplicate insert

Integration testing adalah evidence untuk boundary implementasi.


1. Mental Model

Integration test menjawab:

Can our code use a real dependency correctly under controlled conditions?

Bukan:

Does the whole business journey work end to end?

Bukan juga:

Can I mock everything and still feel safe?

Integration test yang baik punya ciri:

  • dependency nyata,
  • scope terbatas,
  • setup deterministic,
  • data isolated,
  • runtime masih layak untuk CI,
  • failure mudah didiagnosis,
  • tidak bergantung shared environment,
  • tidak memerlukan urutan test,
  • tidak menguji hal yang lebih cocok untuk unit/contract/E2E.

Diagram posisi:


2. Why H2/Mocks Are Not Enough

Mock repository:

when(caseRepository.findById("CASE-1001"))
    .thenReturn(Optional.of(caseRecord));

This tests service behavior given repository output. It does not test:

SQL syntax
transaction behavior
constraint behavior
index behavior
JSONB mapping
timestamp precision
numeric scale
locking semantics
connection pool behavior
migration correctness

H2/in-memory database can be useful for some fast tests. But H2 is not PostgreSQL.

Bug examples that H2/mock might miss:

SELECT * FROM cases WHERE metadata @> '{"priority":"HIGH"}'::jsonb;

This is PostgreSQL-specific.

INSERT INTO case_transition(case_id, transition_id)
VALUES (?, ?)
ON CONFLICT (case_id, idempotency_key) DO NOTHING;

This depends on PostgreSQL conflict semantics.

SELECT * FROM cases WHERE status = ANY(?);

Array binding can differ by driver/database.

Integration test should use the same database engine when behavior matters.


3. Testcontainers Mental Model

Testcontainers for Java provides lightweight, throwaway instances of common dependencies for tests.

Think of it as:

JUnit test controls disposable infrastructure.

Lifecycle:

Important idea:

The dependency is real, but the environment is disposable.

This gives realism without shared staging fragility.


4. What Belongs in Integration Tests?

Good integration test targets:

repository SQL
migration scripts
transaction boundaries
message publication/consumption
serialization/deserialization with real config
HTTP adapter behavior
cache adapter behavior
locking/idempotency behavior
outbox/inbox persistence
retry with real persistence
constraint violations
pagination/query correctness

Poor integration test targets:

all domain rule combinations
UI behavior
every service journey
simple getters/setters
mocked repository behavior
performance claims
formal workflow proof

Rule:

Use integration tests where the risk is in the integration surface, not pure logic.

5. Integration Test Taxonomy

5.1 Adapter Integration Test

Tests one adapter against real dependency.

Example:

PostgresCaseRepositoryTest
KafkaCaseEventPublisherTest
RedisIdempotencyStoreTest
HttpOfficerDirectoryClientTest

Scope is small. Good for fast feedback.

5.2 Component Integration Test

Tests application component with real dependencies.

Example:

CaseTransitionApplicationService + PostgreSQL + Kafka test topic

Scope includes domain orchestration and adapters.

5.3 Slice Integration Test

Tests one vertical slice.

Example:

JAX-RS resource -> application service -> PostgreSQL -> outbox table

No full UI. No unrelated services.

5.4 Multi-Dependency Integration Test

Tests interaction between dependencies.

Example:

transaction writes case transition + outbox
publisher reads outbox and publishes Kafka event
consumer stores projection

Use sparingly.


6. PostgreSQL Integration Testing

PostgreSQL integration tests should cover:

schema migration
repository queries
constraints
unique keys
foreign keys
transaction behavior
locking
pagination
JSONB
array binding
timestamp precision
numeric scale
isolation-sensitive logic

6.1 Basic Testcontainers Shape

@Testcontainers
class PostgresCaseRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("case_test")
        .withUsername("test")
        .withPassword("test");

    private CaseRepository repository;

    @BeforeEach
    void setUp() {
        DataSource dataSource = createDataSource(
            postgres.getJdbcUrl(),
            postgres.getUsername(),
            postgres.getPassword()
        );

        migrate(dataSource);
        truncateTables(dataSource);

        repository = new PostgresCaseRepository(dataSource);
    }

    @Test
    void findsCaseByPublicId() {
        repository.insert(new CaseRecord("CASE-1001", "UNDER_REVIEW", "OFF-7"));

        Optional<CaseRecord> result = repository.findByPublicId("CASE-1001");

        assertThat(result).isPresent();
        assertThat(result.get().status()).isEqualTo("UNDER_REVIEW");
    }
}

Focus on pattern, not exact imports.

Essential elements:

real PostgreSQL container
real DataSource
real migration
real repository
isolated data per test
assertion on semantic result

6.2 Migration Must Run in Tests

Bad:

Test creates tables manually with simplified schema.

Good:

Test runs the same migration scripts used in production deployment.

Why?

Migration itself is an integration boundary.

It can fail because:

invalid SQL
wrong column type
missing index
bad default
incompatible constraint
lock-heavy migration
wrong extension
wrong schema name

Integration tests should catch migration drift early.


7. Data Isolation Strategies

Integration tests often fail because data leaks across tests.

Common strategies:

StrategyBenefitCostUse When
Truncate tables before each testSimple, fast enoughNeed FK ordering/cascadeMost repository tests
Transaction rollback per testFastCan hide commit behaviorPure DB reads/writes without async
Recreate schema per test classStrong isolationSlowerComplex migration/state tests
New container per test classVery isolatedSlowerDifferent DB config needed
New container per test methodMaximum isolationExpensiveRare, high-risk isolation bugs

7.1 Truncation Pattern

TRUNCATE TABLE
    case_transition,
    case_event_outbox,
    cases,
    officers
RESTART IDENTITY CASCADE;

Make it explicit. Do not rely on test order.

7.2 Transaction Rollback Trap

Rollback-per-test is useful but can lie.

If production code uses:

commit transaction -> outbox publisher sees row -> Kafka event emitted

Then wrapping the whole test in rollback may hide commit-dependent behavior.

Rule:

Do not use rollback isolation for tests that verify commit, outbox, async consumers, locks, or transaction visibility.

8. Testing Constraints

Database constraints are executable domain safeguards.

Example migration:

CREATE TABLE cases (
    id BIGSERIAL PRIMARY KEY,
    public_id TEXT NOT NULL UNIQUE,
    status TEXT NOT NULL,
    assigned_officer_id TEXT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE cases
ADD CONSTRAINT cases_status_check
CHECK (status IN ('DRAFT', 'UNDER_REVIEW', 'ESCALATED', 'CLOSED'));

Test the constraint intentionally:

@Test
void rejectsInvalidStatusAtDatabaseBoundary() {
    assertThatThrownBy(() -> jdbc.update("""
        INSERT INTO cases(public_id, status)
        VALUES (?, ?)
        """, "CASE-1001", "BROKEN"))
        .hasMessageContaining("cases_status_check");
}

Why test database constraint if domain code already validates?

Because defense in depth.

Domain validation prevents normal invalid writes.
Database constraints prevent bypass/corruption.

9. Testing Transaction Boundaries

Transaction bugs are common in Java service code.

Scenarios:

case updated but audit row missing
outbox row inserted but domain update rolled back
exception swallowed and transaction committed
retry duplicates transition
read-your-own-write assumption false
lock timeout not handled

Test transaction semantics directly.

Example expected rollback:

@Test
void rollsBackCaseTransitionWhenOutboxInsertFails() {
    insertCase("CASE-1001", "UNDER_REVIEW");
    makeOutboxInsertFailByConstraint();

    assertThatThrownBy(() -> service.escalate("CASE-1001", "SLA_BREACH"))
        .isInstanceOf(OutboxWriteException.class);

    assertCaseStatus("CASE-1001", "UNDER_REVIEW");
    assertNoTransitionRecorded("CASE-1001");
    assertNoOutboxEvent("CASE-1001");
}

This test belongs at integration/component layer because transaction behavior depends on real persistence.


10. Testing Idempotency with Real Database

Idempotency is often incorrectly tested with mocks.

Real test:

@Test
void repeatedCommandWithSameIdempotencyKeyDoesNotDuplicateTransition() {
    insertCase("CASE-1001", "UNDER_REVIEW");

    TransitionResult first = service.escalate(
        new EscalateCase("CASE-1001", "SLA_BREACH", "OFF-7", "idem-1")
    );

    TransitionResult second = service.escalate(
        new EscalateCase("CASE-1001", "SLA_BREACH", "OFF-7", "idem-1")
    );

    assertThat(second.transitionId()).isEqualTo(first.transitionId());
    assertTransitionCount("CASE-1001", 1);
    assertOutboxEventCount("CASE-1001", "CaseEscalated", 1);
}

This catches:

unique index missing
conflict handling wrong
transaction retry duplicates outbox
service returns different transitionId

Idempotency is a database + application boundary property.


11. Kafka Integration Testing

Kafka integration tests should cover:

producer serialization
message key
headers
topic config assumption
consumer deserialization
offset commit behavior
idempotent processing
dead-letter behavior
ordering assumptions
outbox publisher integration

Testcontainers provides Kafka modules for running Kafka containers in tests.

11.1 Producer Test Shape

@Testcontainers
class KafkaCaseEventPublisherTest {

    @Container
    static KafkaContainer kafka = new KafkaContainer("apache/kafka-native:3.8.0");

    private CaseEventPublisher publisher;
    private KafkaConsumer<String, String> consumer;

    @BeforeEach
    void setUp() {
        String bootstrapServers = kafka.getBootstrapServers();
        createTopic(bootstrapServers, "case-events");
        publisher = new KafkaCaseEventPublisher(bootstrapServers, "case-events");
        consumer = createConsumer(bootstrapServers, "test-group");
        consumer.subscribe(List.of("case-events"));
    }

    @Test
    void publishesCaseEscalatedWithCaseIdAsKey() {
        publisher.publish(new CaseEscalated("CASE-1001", "UNDER_REVIEW", "ESCALATED"));

        ConsumerRecord<String, String> record = pollOne(consumer);

        assertThat(record.key()).isEqualTo("CASE-1001");
        assertThat(record.value()).contains("CaseEscalated");
        assertThat(record.headers().lastHeader("eventType").value())
            .isEqualTo("CaseEscalated".getBytes(UTF_8));
    }
}

The point is not syntax. The point is evidence:

real Kafka producer config
real serialization
real topic
real key/header behavior

11.2 Consumer Test Shape

@Test
void consumesCaseEscalatedAndUpdatesProjection() {
    publishToKafka("case-events", "CASE-1001", caseEscalatedJson());

    await().untilAsserted(() -> {
        CaseProjection projection = projectionRepository.find("CASE-1001").orElseThrow();
        assertThat(projection.status()).isEqualTo("ESCALATED");
    });
}

Async integration tests need waiting based on condition, not sleep.

Bad:

Thread.sleep(5000);

Better:

await().atMost(Duration.ofSeconds(10)).untilAsserted(...);

Sleep makes tests slow and flaky. Condition waiting makes intent explicit.


12. Kafka Offset and Idempotent Consumer Testing

Consumer bugs often hide until duplicate messages or restart.

Test duplicate handling:

@Test
void duplicateEventDoesNotDuplicateProjectionHistory() {
    String event = caseEscalatedEvent("evt-1001", "CASE-1001");

    publish(event);
    publish(event);

    await().untilAsserted(() -> {
        assertProjectionStatus("CASE-1001", "ESCALATED");
        assertProjectionHistoryCount("CASE-1001", 1);
        assertProcessedEventExists("evt-1001");
    });
}

Test poison message behavior:

@Test
void malformedMessageGoesToDeadLetterTopic() {
    publishRaw("case-events", "CASE-1001", "{not-json");

    ConsumerRecord<String, String> dlqRecord = pollFrom("case-events.dlq");

    assertThat(dlqRecord.key()).isEqualTo("CASE-1001");
    assertThat(dlqRecord.value()).contains("not-json");
}

These are integration tests because real consumer runtime behavior matters.


13. Testing Outbox Pattern

Outbox pattern:

write domain state and event record in same database transaction
publisher later reads outbox and publishes to Kafka
mark event as published

Why used:

avoid state committed but event lost
avoid event emitted but state rolled back

Integration test should cover the atomic write:

@Test
void transitionWritesCaseStateAndOutboxAtomically() {
    insertCase("CASE-1001", "UNDER_REVIEW");

    service.escalate("CASE-1001", "SLA_BREACH", "idem-1");

    assertCaseStatus("CASE-1001", "ESCALATED");
    assertOutboxContains("CASE-1001", "CaseEscalated");
}

And publisher behavior:

@Test
void outboxPublisherPublishesAndMarksEvent() {
    insertOutboxEvent("evt-1001", "CaseEscalated", "CASE-1001");

    publisher.publishPending();

    ConsumerRecord<String, String> record = pollOne(kafkaConsumer);
    assertThat(record.key()).isEqualTo("CASE-1001");
    assertOutboxMarkedPublished("evt-1001");
}

And failure behavior:

@Test
void failedPublishLeavesOutboxPendingForRetry() {
    insertOutboxEvent("evt-1001", "CaseEscalated", "CASE-1001");
    makeKafkaUnavailable();

    assertThatThrownBy(() -> publisher.publishPending())
        .isInstanceOf(EventPublishException.class);

    assertOutboxStillPending("evt-1001");
}

This last test may require controlled failure injection.


14. External HTTP Service Integration

If your Java service calls another HTTP service, integration options:

mock server with real HTTP
contract provider/consumer test
sandbox dependency
real local fake service container

Avoid pure Java mocks for HTTP adapter tests.

Adapter should be tested with real HTTP semantics:

status code
headers
timeouts
body parsing
connection failure
malformed response
retryable response
non-retryable response

Example using a mock HTTP server conceptually:

@Test
void maps404FromOfficerDirectoryToOfficerNotFound() {
    officerDirectoryServer.stubForGet("/officers/OFF-404")
        .respond(404, "application/json", """
            {"code":"OFFICER_NOT_FOUND","message":"Officer not found"}
        """);

    assertThatThrownBy(() -> client.getOfficer("OFF-404"))
        .isInstanceOf(OfficerNotFoundException.class);
}

This is integration testing for adapter behavior.

It should verify:

URL path encoding
query params
headers
timeout config
error mapping
JSON parsing

15. Network Failure Testing

Real distributed systems fail through network behavior:

connection refused
connection reset
timeout
slow response
partial response
DNS failure
TLS failure
429 throttling
503 overload

Integration tests should cover at least:

timeout is bounded
retry policy does not retry non-idempotent operation blindly
circuit breaker opens after repeated failure
fallback does not corrupt state
error is observable

Example:

@Test
void officerLookupTimeoutFailsWithoutChangingCaseState() {
    officerDirectoryServer.respondSlowly(Duration.ofSeconds(30));
    insertCase("CASE-1001", "UNDER_REVIEW");

    assertThatThrownBy(() -> service.assignOfficer("CASE-1001", "OFF-7"))
        .isInstanceOf(OfficerDirectoryTimeoutException.class);

    assertCaseStillUnassigned("CASE-1001");
}

The invariant is not just timeout.

The invariant is:

Failure to verify officer must not partially assign case.

16. Redis/Cache Integration Testing

Cache tests should verify:

key format
TTL
serialization
cache miss behavior
cache invalidation
idempotency storage
lock behavior if used

Example:

@Test
void idempotencyRecordExpiresAfterTtl() {
    IdempotencyStore store = new RedisIdempotencyStore(redisClient, Duration.ofSeconds(2));

    store.save("idem-1", "TRN-1");

    assertThat(store.find("idem-1")).contains("TRN-1");

    await().atMost(Duration.ofSeconds(5))
        .untilAsserted(() -> assertThat(store.find("idem-1")).isEmpty());
}

This test is slower but valuable if TTL correctness matters.

Be careful:

TTL tests are time-based.
Keep them few and isolate them from fast suites.

17. Test Runtime Budget

Integration tests are more expensive than unit tests.

Use suite classification:

unit: every local run, every PR
component: every PR
integration-fast: every PR
integration-full: pre-merge or main branch
soak/load: scheduled or release gate

JUnit tags:

@Tag("integration")
class PostgresCaseRepositoryTest {}

Maven/Gradle can split suites:

mvn test -Dgroups=unit
mvn verify -Dgroups=integration

Or with naming:

*Test.java        unit/component
*IT.java          integration
*E2E.java         end-to-end

Naming is not enough. CI must enforce suite boundaries.


18. Container Lifecycle Trade-Off

Static container per class

@Container
static PostgreSQLContainer<?> postgres = ...;

Pros:

faster startup
shared within class

Cons:

must isolate data manually
class-level shared state risk

Container per test method

@Container
PostgreSQLContainer<?> postgres = ...;

Pros:

strong isolation

Cons:

slow
resource-heavy

Practical default:

Use one container per test class or suite.
Clean data explicitly before each test.

Use per-method container only when configuration/state isolation is more important than speed.


19. Parallel Test Execution Risks

Integration tests can run in parallel only if they isolate:

database schema/table data
topics
consumer groups
ports
files
external mock server state
cache keys

Bad parallel setup:

all tests use topic case-events and group test-group

They will steal messages from each other.

Better:

String topic = "case-events-" + testRunId();
String consumerGroup = "test-group-" + testRunId();

For database:

schema per test class
or truncate + no parallel DB tests
or unique IDs per test

Parallelism is not free.


20. Deterministic Test Data

Use test data builders from Part 006.

Bad:

insertRandomCase();

Better:

CaseRecord caseRecord = aCase()
    .withPublicId("CASE-1001")
    .withStatus("UNDER_REVIEW")
    .withAssignedOfficer("OFF-7")
    .build();

Deterministic IDs help diagnose failures.

Use randomization only when testing robustness/property behavior, and always log seed.


21. Diagnosability

Integration test failure should answer:

what dependency failed?
what data was present?
what SQL/message/request happened?
what invariant failed?

Improve diagnostics with:

container logs on failure
SQL logging for failed tests
test scenario names
assertions on semantic state
correlation IDs
unique test IDs
captured Kafka records
DB snapshot helper

Bad assertion:

assertThat(result).isTrue();

Good assertion:

assertThat(findCase("CASE-1001"))
    .as("case should remain UNDER_REVIEW after outbox failure")
    .extracting(CaseRecord::status)
    .isEqualTo("UNDER_REVIEW");

22. Integration Tests and Performance

Do not claim performance from normal integration tests.

Integration tests are not benchmarks because:

container startup noise
shared CI hardware
small datasets
non-representative workload
assertion overhead
JIT warmup not controlled

But integration tests can catch performance-adjacent problems:

N+1 queries visible in query count
missing index discovered by explain plan smoke test
connection leak
unbounded query result
slow timeout config

Example query count assertion:

Fetching first page of cases should not execute one query per case.

This is not a throughput benchmark. It is a structural performance guard.


23. Testing SQL Query Plans Carefully

For critical queries, add explain-plan smoke tests.

EXPLAIN (FORMAT JSON)
SELECT *
FROM cases
WHERE status = 'UNDER_REVIEW'
ORDER BY created_at DESC
LIMIT 50;

Test may check:

query uses expected index
query does not do sequential scan for large table shape
query remains under structural plan expectation

Be cautious:

query plans depend on data statistics
small test data can mislead
PostgreSQL version changes can alter plans

Use this only for critical queries and with representative seed volume.


24. CI Reliability

Integration tests in CI need infrastructure discipline.

Checklist:

Docker available
container image versions pinned
resource limits known
no dependency on external internet during test
no fixed host ports unless necessary
logs captured on failure
test timeout enforced
parallelism controlled
flaky tests quarantined with owner
images cached where possible

Avoid:

latest image tags
shared staging database
fixed topic names across builds
sleep-based async waits
tests that pass only on developer machine

Pin images:

new PostgreSQLContainer<>("postgres:16.3-alpine")

Using latest makes test environment change without code review.


25. Real Infrastructure Does Not Mean Real Everything

A good integration test may use:

real PostgreSQL
real Kafka
fake external HTTP service
in-memory clock
fake email sender

Because the test target is specific.

Example:

Testing case transition persistence + event outbox.

Use real:

PostgreSQL
transaction manager
outbox repository

Fake:

officer directory
email notification
current time
random ID generator

This keeps scope controlled.

Rule:

Make real the dependency whose behavior is the risk.
Fake the dependencies that are irrelevant to the assertion.

26. Integration Test Architecture

Recommended package structure:

src/test/java
  unit/
  component/
  integration/
    postgres/
      PostgresCaseRepositoryIT.java
      PostgresOutboxRepositoryIT.java
    kafka/
      KafkaCaseEventPublisherIT.java
      CaseEventConsumerIT.java
    http/
      OfficerDirectoryClientIT.java
    slices/
      CaseTransitionSliceIT.java

Shared support:

src/test/java/support/
  containers/
    PostgresTestContainer.java
    KafkaTestContainer.java
  db/
    DatabaseCleaner.java
    MigrationRunner.java
    TestDataInserter.java
  kafka/
    KafkaTestClient.java
  http/
    MockServerSupport.java
  assertions/
    CaseAssertions.java

Keep support code boring. Complex test infrastructure becomes its own bug factory.


27. Case Study: Regulatory Case Transition Slice

System slice:

JAX-RS endpoint
  -> CaseTransitionApplicationService
  -> PostgreSQL case tables
  -> outbox table
  -> Kafka publisher job

Invariant:

A successful escalation must atomically update case status, record transition history, create outbox event, and eventually publish exactly one CaseEscalated message keyed by caseId.

27.1 Slice Test

@Test
void escalateCasePersistsTransitionAndCreatesOutboxEvent() {
    insertOfficer("OFF-7");
    insertCase("CASE-1001", "UNDER_REVIEW", "OFF-7");

    Response response = http.post(
        "/cases/CASE-1001/transitions",
        headers("Idempotency-Key", "idem-1"),
        json("""
          {
            "action": "ESCALATE",
            "reasonCode": "SLA_BREACH",
            "actorId": "OFF-7"
          }
        """)
    );

    assertThat(response.status()).isEqualTo(200);
    assertCaseStatus("CASE-1001", "ESCALATED");
    assertTransitionHistory("CASE-1001", "UNDER_REVIEW", "ESCALATED");
    assertOutboxContains("CaseEscalated", "CASE-1001");
}

27.2 Publisher Test

@Test
void publisherEmitsOutboxEventToKafkaAndMarksPublished() {
    insertOutboxEvent("evt-1001", "CaseEscalated", "CASE-1001");

    outboxPublisher.publishPending();

    ConsumerRecord<String, String> record = pollOne("case-events");

    assertThat(record.key()).isEqualTo("CASE-1001");
    assertThat(record.value()).contains("CaseEscalated");
    assertOutboxPublished("evt-1001");
}

27.3 End-to-End-ish Slice

Use sparingly:

@Test
void escalationEventuallyAppearsInProjection() {
    postEscalateCase("CASE-1001", "idem-1");
    outboxPublisher.publishPending();

    await().untilAsserted(() -> {
        assertProjectionStatus("CASE-1001", "ESCALATED");
    });
}

This test crosses more boundaries. Keep it few.


28. Failure Modeling in Integration Tests

From Part 002, failure model should drive tests.

For each dependency, define failure modes:

DependencyFailure ModeExpected Behavior
PostgreSQLunique violationreturn idempotent result or domain conflict
PostgreSQLdeadlockretry if safe, otherwise fail bounded
PostgreSQLunavailableno partial side effect
Kafkapublish failsoutbox remains pending
Kafkaduplicate messageconsumer idempotent
HTTP servicetimeoutbounded failure, no state corruption
Redisunavailabledegrade or fail according to policy

Then write only high-value tests.

Do not test every possible infrastructure failure. Test the failures that would corrupt state, duplicate side effects, or break recovery.


29. Common Integration Testing Anti-Patterns

29.1 Shared Staging as Integration Test Dependency

Bad:

Tests depend on dev/staging database.

Problems:

non-deterministic data
slow network
team interference
credential/security risk
cannot reproduce locally

29.2 Sleep-Based Async Test

Bad:

Thread.sleep(10000);
assertProjectionUpdated();

Problems:

slow when system fast
flaky when system slow
no diagnostic detail

29.3 Testing Too Much in One Test

Bad:

create user, login, create case, assign officer, escalate, close, export report, send email

That is E2E flow, not integration test.

29.4 Mocking the Dependency Under Test

Bad:

Postgres repository integration test mocks DataSource.

Then it is not integration testing.

29.5 Hidden Test Order Dependency

Bad:

Test B expects data created by Test A.

JUnit does not guarantee this as a reliable engineering strategy.


30. What to Put in Pull Request Review

For integration test PRs, review:

What real dependency behavior is being tested?
Could this be a unit test instead?
Is setup deterministic?
Is data isolated?
Does the test run in CI reliably?
Is async waiting condition-based?
Are image versions pinned?
Is failure diagnostic good?
Does it avoid testing unrelated behavior?

For repository tests, ask:

Does it run migrations?
Does it use real PostgreSQL feature if production uses PostgreSQL?
Does it test constraints intentionally?
Does it cover important query branches?

For Kafka tests, ask:

Does it verify key/header/payload?
Does it use unique topics/groups?
Does it test duplicate or malformed messages where relevant?

31. Operating Model

A mature Java codebase often has this testing portfolio:

Many unit tests for domain rules.
Moderate component tests for application services.
Focused integration tests for repositories, Kafka, HTTP clients, and critical slices.
Few E2E tests for top business journeys.
Contract tests for service boundaries.
Performance tests separated from correctness integration tests.

Integration testing should not become dumping ground.

The discipline is:

Every integration test must justify which real dependency behavior it protects.

32. Practical Exercise

Pick one service with PostgreSQL and Kafka.

Create these tests:

1. Repository IT:
   insert, query, constraint violation, pagination.

2. Transaction IT:
   successful command writes state + outbox atomically.

3. Idempotency IT:
   duplicate command with same idempotency key creates one transition.

4. Producer IT:
   outbox publisher emits Kafka message with correct key/header/payload.

5. Consumer IT:
   duplicate Kafka event is processed once.

6. Failure IT:
   publish failure leaves outbox pending.

Then classify each:

runs every PR?
runs nightly?
requires Docker?
parallel safe?
owner?

This turns integration testing from random tests into a system.


33. Checklist

Before you consider this part mastered, you should be able to:

  • explain what integration tests prove and do not prove,
  • choose real dependency only where behavior matters,
  • use Testcontainers for disposable PostgreSQL/Kafka tests,
  • run production migrations in tests,
  • isolate test data deterministically,
  • test transaction rollback/commit behavior,
  • test idempotency with real constraints,
  • test Kafka producer/consumer behavior with real broker,
  • avoid sleep-based async tests,
  • structure integration suites for CI reliability,
  • diagnose failures with logs/state/correlation IDs,
  • avoid turning integration tests into slow E2E tests.

34. Key Takeaways

Mocked dependencies test your assumptions.
Real dependencies test the integration surface.
Integration tests should be realistic where it matters and fake where it does not.
Run real migrations in tests, or you are not testing the deployed database contract.
The hardest part of integration testing is not starting containers.
It is isolation, scope control, diagnostics, and CI discipline.

35. References

Lesson Recap

You just completed lesson 16 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.