Build CoreOrdered learning track

Testing Exceptions, Errors, and Edge Cases

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

Practical negative-path testing untuk exceptions, error taxonomy, boundary values, invalid input, rollback safety, idempotent failure, retry classification, dan edge-case discovery di Java.

13 min read2420 words
PrevNext
Lesson 0940 lesson track0922 Build Core
#java#testing#exceptions#edge-cases+3 more

Part 009 — Testing Exceptions, Errors, and Edge Cases

Tujuan bagian ini: membuat negative-path testing menjadi disiplin engineering yang menjaga invariant, bukan sekadar memastikan assertThrows() hijau.

Software yang matang bukan hanya benar ketika input valid, dependency sehat, waktu normal, dan user patuh terhadap workflow.

Software yang matang tetap menjaga invariant ketika:

  • input kosong, rusak, atau ambigu;
  • state sudah berubah sebelum command diproses;
  • dependency gagal sebagian;
  • timeout terjadi setelah sebagian side effect dibuat;
  • retry mengirim command yang sama dua kali;
  • exception terlempar di tengah transaction;
  • data lama tidak lagi sesuai schema terbaru;
  • user melakukan action dari state yang sudah terminal;
  • operasi dibatalkan;
  • resource habis;
  • nilai numerik overflow, rounding, atau kehilangan presisi.

Testing exception dan edge case adalah cara kita membuktikan bahwa sistem menolak hal yang salah dengan aman.

Bukan hanya:

assertThrows(Exception.class, () -> service.doSomething(input));

Melainkan:

ketika command invalid terjadi,
sistem harus menolak dengan error yang tepat,
tidak mengubah state,
tidak mengirim event,
tidak membuat audit yang menyesatkan,
tidak membuka retry infinite,
dan tetap menghasilkan observability yang cukup untuk diagnosis.

1. Mental Model: Failure Is Still a Behavior

Banyak engineer memperlakukan failure sebagai “bukan happy path”. Akibatnya negative-path test sering tipis, sporadis, dan hanya mengecek tipe exception.

Cara berpikir yang lebih kuat:

successful behavior and failed behavior are both part of the contract

Sebuah method yang mature punya dua jenis kontrak:

success contract:
    precondition valid -> postcondition tercapai

failure contract:
    precondition invalid / dependency failed -> invariant tetap aman

Contoh domain operation:

approveCase(caseId, approver)

Happy path:

OPEN + valid approver -> APPROVED + CaseApproved event + audit record

Failure path:

CLOSED + valid approver -> rejected CASE_ALREADY_CLOSED
PENDING + approver without authority -> rejected APPROVER_NOT_AUTHORIZED
OPEN + audit store down -> transaction rolled back or explicit pending-outbox recovery
OPEN + duplicate request id -> return previous result or no-op safely

Yang diuji bukan sekadar “melempar exception”, tetapi apakah sistem tetap berada dalam state yang benar setelah failure.

Failure behavior adalah desain, bukan kecelakaan.


2. Error Taxonomy: Jangan Campur Semua Failure Menjadi RuntimeException

Sebelum menulis test, klasifikasikan error.

Tanpa taxonomy, test negatif akan kacau karena semua failure terlihat sama.

2.1 Taxonomy Praktis Untuk Java Service

KategoriPenyebabContohResponse UmumRetry?Test Fokus
Caller errorRequest/user salahinvalid field, unauthorized transition400/422/domain rejectionnopesan error, no mutation
Authorization erroractor tidak punya hakapprove tanpa role403/domain rejectionnono data leak, no side effect
Not foundentity tidak ada atau tidak visiblecaseId salah404/emptynono creation accidental
Conflictstate berubahoptimistic lock, duplicate decision409maybe by callerinvariant preserved
Idempotency duplicatecommand sama dikirim ulangretry clientprevious response/no-opsafeno double side effect
Transient infrastructuredependency sementara gagaltimeout, broker unavailable503/retryableyes with policyretry classification
Permanent infrastructureconfig/schema salahunknown topic, bad credentialfail fastno until fixedalerting, no silent retry
Programming buginvariant internal rusakimpossible branch500/fail fastnosurfaced loudly
Resource exhaustionlimit tercapaipool exhausted, memory pressurebackpressure/503laterbounded failure
Cancellation/interruptioncaller/system membatalkanrequest cancelledstop workmaybecleanup, interrupt preservation

Taxonomy ini harus masuk ke desain exception, result object, API response, logging, metrics, dan retry policy.

2.2 Domain Rejection Bukan Selalu Exception

Dalam Java, ada dua gaya umum:

  1. exception-based failure;
  2. result-based failure.

Exception cocok ketika failure benar-benar exceptional terhadap flow pemanggil, atau saat kita ingin memaksa call stack keluar.

Result cocok untuk domain rejection yang expected:

sealed interface ApprovalResult permits ApprovalResult.Approved, ApprovalResult.Rejected {
    record Approved(CaseId caseId, Instant approvedAt) implements ApprovalResult {}
    record Rejected(CaseId caseId, ErrorCode code, String reason) implements ApprovalResult {}
}

Exception-based:

public Case approve(CaseId caseId, UserId approver) {
    Case c = repository.getRequired(caseId);

    if (c.isClosed()) {
        throw new DomainRejectedException(ErrorCode.CASE_ALREADY_CLOSED, caseId);
    }

    return c.approve(approver, clock.instant());
}

Result-based:

public ApprovalResult approve(CaseId caseId, UserId approver) {
    Case c = repository.find(caseId)
        .orElseThrow(() -> new NotFoundException("case", caseId));

    if (c.isClosed()) {
        return new ApprovalResult.Rejected(
            caseId,
            ErrorCode.CASE_ALREADY_CLOSED,
            "Closed cases cannot be approved"
        );
    }

    Case approved = c.approve(approver, clock.instant());
    repository.save(approved);
    return new ApprovalResult.Approved(caseId, approved.approvedAt());
}

Tidak ada satu jawaban universal. Yang penting: failure contract eksplisit dan konsisten.

2.3 Smell: Exception Menjadi Protocol

Hindari desain di mana caller harus membaca string message exception untuk mengambil keputusan:

catch (RuntimeException e) {
    if (e.getMessage().contains("already closed")) {
        // fragile
    }
}

Gunakan error code atau tipe yang stabil:

catch (DomainRejectedException e) {
    if (e.code() == ErrorCode.CASE_ALREADY_CLOSED) {
        // stable protocol
    }
}

Test juga harus menguji protocol stabil, bukan wording rapuh.


3. Exception Test yang Benar: Assert Semantics, Not Just Type

JUnit menyediakan assertion untuk memastikan executable melempar exception tertentu. Tetapi assertion type saja sering terlalu lemah.

Buruk:

@Test
void rejectClosedCase() {
    Case closed = Case.closed("C-1");

    assertThrows(RuntimeException.class, () -> closed.approve(UserId.of("u-1")));
}

Masalah:

  • terlalu generik;
  • tidak membuktikan error code;
  • tidak membuktikan state tidak berubah;
  • tidak membuktikan tidak ada event;
  • test tetap hijau jika bug lain melempar NullPointerException.

Lebih baik:

@Test
void closedCaseCannotBeApproved() {
    Case closed = Case.closed(CaseId.of("C-1"));

    DomainRejectedException ex = assertThrows(
        DomainRejectedException.class,
        () -> closed.approve(UserId.of("supervisor-1"), Instant.parse("2026-07-02T10:00:00Z"))
    );

    assertAll(
        () -> assertEquals(ErrorCode.CASE_ALREADY_CLOSED, ex.code()),
        () -> assertEquals(CaseStatus.CLOSED, closed.status()),
        () -> assertTrue(closed.pendingEvents().isEmpty())
    );
}

Kita assert empat hal:

  1. tipe exception benar;
  2. error code benar;
  3. state tidak berubah;
  4. tidak ada event palsu.

3.1 Pattern: Exception Contract Test

Untuk exception domain, buat pattern test yang konsisten:

Given invalid precondition
When operation is invoked
Then a typed failure is returned/thrown
And error code is stable
And state is unchanged
And no external effect is requested
And observability context is present

Template:

@Test
void cannotAssignInvestigatorAfterCaseIsClosed() {
    CaseAggregate before = CaseFixtures.closedCase()
        .withInvestigator(null)
        .build();

    CaseAggregate snapshot = before.copy();

    DomainRejectedException ex = assertThrows(
        DomainRejectedException.class,
        () -> before.assignInvestigator(UserId.of("inv-1"), TestActors.supervisor())
    );

    assertAll(
        () -> assertEquals(ErrorCode.CASE_ALREADY_CLOSED, ex.code()),
        () -> assertEquals(snapshot, before),
        () -> assertThat(before.pendingEvents()).isEmpty()
    );
}

Catatan penting: snapshot comparison hanya aman jika aggregate memang immutable atau memiliki equality yang meaningful. Jika tidak, assert invariant secara eksplisit.

3.2 Jangan Assert Message Kecuali Message Adalah Contract

Rapuh:

assertEquals("Case is closed", ex.getMessage());

Lebih stabil:

assertEquals(ErrorCode.CASE_ALREADY_CLOSED, ex.code());

Message boleh diuji ketika message memang bagian dari API contract, misalnya error response publik. Bahkan di sana, lebih baik assert struktur:

{
  "code": "CASE_ALREADY_CLOSED",
  "message": "Case is already closed",
  "fieldErrors": []
}

Test:

assertThat(response.code()).isEqualTo("CASE_ALREADY_CLOSED");
assertThat(response.fieldErrors()).isEmpty();

Wording manusia bisa berubah. Error code tidak boleh berubah sembarangan.


4. Boundary Testing: Edge Case Itu Bukan Random List

Edge case harus diturunkan dari batas model, bukan dari imajinasi acak.

Sumber edge case:

input domain boundaries
state boundaries
time boundaries
numeric boundaries
collection boundaries
schema boundaries
dependency boundaries
resource boundaries
concurrency boundaries
security boundaries

4.1 Boundary Matrix

DimensiBoundaryContoh Test
Stringnull, empty, blank, max length, unicodename = "", name = " ", emoji, combining chars
Numbermin, max, zero, negative, overflowamount = 0, -1, Long.MAX_VALUE
Decimalscale, rounding, currency precision10.005, 0.01, currency mismatch
Date/timeDST, timezone, end-of-day, leap yeardue date at midnight, Feb 29
Collectionempty, single, duplicate, hugeno approvers, duplicate approver
Stateinitial, terminal, transitionalDRAFT, CLOSED, PENDING_APPROVAL
Identitymissing, malformed, duplicatesame request ID twice
Authorizationno role, wrong tenant, cross-orgactor from another organization
Dependencytimeout, partial failure, stale readbroker succeeds but DB fails
Schemaunknown field, missing required, invalid enumstatus = ARCHIEVED typo

4.2 Boundary Test Harus Menjawab: Batas Siapa?

Contoh amount > 0 terlihat sederhana. Tetapi pada sistem payment/pricing, boundary melibatkan beberapa aturan:

amount > 0
amount scale <= currency minor unit
amount currency == account currency
amount <= product limit
amount <= customer risk limit
amount + previous exposure <= portfolio limit
amount rounded using regulatory rounding mode

Maka edge case bukan hanya -1.

Test matrix:

@ParameterizedTest
@MethodSource("invalidAmounts")
void rejectInvalidAmount(Money amount, ErrorCode expectedCode) {
    QuoteRequest request = QuoteRequestFixtures.valid()
        .withAmount(amount)
        .build();

    DomainRejectedException ex = assertThrows(
        DomainRejectedException.class,
        () -> pricingService.quote(request)
    );

    assertEquals(expectedCode, ex.code());
}

static Stream<Arguments> invalidAmounts() {
    return Stream.of(
        arguments(Money.of("USD", new BigDecimal("0.00")), ErrorCode.AMOUNT_MUST_BE_POSITIVE),
        arguments(Money.of("USD", new BigDecimal("-0.01")), ErrorCode.AMOUNT_MUST_BE_POSITIVE),
        arguments(Money.of("USD", new BigDecimal("10.001")), ErrorCode.INVALID_CURRENCY_SCALE),
        arguments(Money.of("EUR", new BigDecimal("10.00")), ErrorCode.CURRENCY_MISMATCH),
        arguments(Money.of("USD", new BigDecimal("1000000000000.00")), ErrorCode.AMOUNT_LIMIT_EXCEEDED)
    );
}

4.3 Boundary Jangan Membuat Test Tidak Terbaca

Jika matrix terlalu besar, jangan taruh semuanya di satu test method.

Pisahkan berdasarkan invariant:

AmountValidationTest
CurrencyValidationTest
TemporalEligibilityTest
AuthorizationBoundaryTest
StateTransitionBoundaryTest

Good test suite bukan yang punya satu test super panjang. Good test suite punya peta risiko yang mudah dibaca.


5. Null, Empty, Blank, Optional: Jangan Mengandalkan Kebetulan

Java memberi banyak bentuk “tidak ada nilai”:

null
Optional.empty()
empty string
blank string
empty collection
zero
unknown enum
sentinel value

Masing-masing punya makna berbeda.

5.1 Null Policy Harus Eksplisit

Contoh constructor value object:

public record CaseId(String value) {
    public CaseId {
        if (value == null) {
            throw new NullPointerException("value");
        }
        if (value.isBlank()) {
            throw new IllegalArgumentException("CaseId cannot be blank");
        }
    }
}

Test:

@Test
void caseIdRejectsNullValue() {
    NullPointerException ex = assertThrows(
        NullPointerException.class,
        () -> new CaseId(null)
    );

    assertEquals("value", ex.getMessage());
}

@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void caseIdRejectsBlankValue(String raw) {
    IllegalArgumentException ex = assertThrows(
        IllegalArgumentException.class,
        () -> new CaseId(raw)
    );

    assertTrue(ex.getMessage().contains("blank"));
}

Di sini message boleh dicek ringan karena constructor exception bukan public API utama. Untuk API publik tetap gunakan error code.

5.2 Jangan Pakai Optional Untuk Field Entity Secara Sembarangan

Optional bagus sebagai return type untuk “mungkin ada”. Tetapi sebagai field entity, sering membuat model lebih ribet.

Lebih baik domain eksplisit:

public final class CaseAssignment {
    private final CaseId caseId;
    private final AssignmentStatus status;
    private final UserId investigator; // only non-null when ASSIGNED
}

Tetapi jika invariant tergantung state, test harus menegaskan:

@Test
void assignedCaseMustHaveInvestigator() {
    IllegalArgumentException ex = assertThrows(
        IllegalArgumentException.class,
        () -> new CaseAssignment(CaseId.of("C-1"), AssignmentStatus.ASSIGNED, null)
    );

    assertThat(ex).hasMessageContaining("investigator");
}

Better model:

sealed interface Assignment permits Unassigned, Assigned {
}

record Unassigned() implements Assignment {
}

record Assigned(UserId investigator) implements Assignment {
    Assigned {
        Objects.requireNonNull(investigator, "investigator");
    }
}

Dengan sealed type, beberapa invalid state menjadi unrepresentable.

Testing terbaik sering dimulai dari desain yang mencegah state ilegal dibuat.


6. Testing Validation: Field Error vs Domain Error

Jangan campur validation syntactic dengan domain rejection.

Contoh request:

{
  "caseId": "C-100",
  "decision": "APPROVE",
  "reason": "ok"
}

Layer validation:

syntactic validation:
    caseId required
    decision must be known enum
    reason max length 500

domain validation:
    case must exist
    actor must be authorized
    current state must allow APPROVE
    required evidence must be complete

Test syntactic validation:

@Test
void rejectMissingCaseIdAtRequestBoundary() throws Exception {
    String body = """
        {
          "decision": "APPROVE",
          "reason": "ok"
        }
        """;

    mockMvc.perform(post("/cases/decision")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.fieldErrors[0].field").value("caseId"));
}

Test domain validation:

@Test
void rejectApproveWhenEvidenceIsIncomplete() {
    CaseAggregate c = CaseFixtures.openCase()
        .withoutRequiredEvidence()
        .build();

    DomainRejectedException ex = assertThrows(
        DomainRejectedException.class,
        () -> c.approve(TestActors.supervisor(), fixedInstant)
    );

    assertEquals(ErrorCode.REQUIRED_EVIDENCE_INCOMPLETE, ex.code());
}

Kenapa pemisahan ini penting?

Karena error yang sama-sama “invalid” punya owner berbeda:

request boundary invalid -> client/developer integration issue
domain invalid -> legitimate business rejection
system invalid -> internal bug or data corruption

Masing-masing butuh response, logging, metric, dan retry policy yang berbeda.


7. Testing Rollback and No-Side-Effect Guarantees

Salah satu negative-path test paling penting adalah:

when operation fails, what must not happen?

Contoh:

approve case
    persist status APPROVED
    insert audit
    publish event
    notify external party

Jika approval gagal karena unauthorized, tidak boleh ada:

  • status update;
  • audit “approved”;
  • outbox event;
  • notification;
  • cache invalidation palsu.

7.1 Test No Mutation in Domain Layer

@Test
void unauthorizedApprovalDoesNotMutateCase() {
    CaseAggregate original = CaseFixtures.openCase()
        .withRequiredEvidenceComplete()
        .build();

    CaseSnapshot before = CaseSnapshot.from(original);

    DomainRejectedException ex = assertThrows(
        DomainRejectedException.class,
        () -> original.approve(TestActors.viewer(), fixedInstant)
    );

    assertAll(
        () -> assertEquals(ErrorCode.APPROVER_NOT_AUTHORIZED, ex.code()),
        () -> assertEquals(before, CaseSnapshot.from(original)),
        () -> assertThat(original.pendingEvents()).isEmpty()
    );
}

7.2 Test No Side Effect in Application Layer

@Test
void unauthorizedApprovalDoesNotSaveOrPublish() {
    CaseRepository repository = mock(CaseRepository.class);
    EventPublisher publisher = mock(EventPublisher.class);

    CaseAggregate openCase = CaseFixtures.openCase()
        .withRequiredEvidenceComplete()
        .build();

    when(repository.getRequired(CaseId.of("C-1"))).thenReturn(openCase);

    ApproveCaseService service = new ApproveCaseService(repository, publisher, fixedClock);

    DomainRejectedException ex = assertThrows(
        DomainRejectedException.class,
        () -> service.approve(CaseId.of("C-1"), TestActors.viewer())
    );

    assertEquals(ErrorCode.APPROVER_NOT_AUTHORIZED, ex.code());
    verify(repository, never()).save(any());
    verifyNoInteractions(publisher);
}

Test ini bukan “mock fetish”. Ini mengunci invariant side-effect:

invalid command must not leave durable or external traces that imply success

7.3 Transaction Boundary Test

Untuk service dengan database, unit test tidak cukup.

Test integration:

@Test
void databaseChangesRollbackWhenOutboxInsertFails() {
    CaseId caseId = seedOpenCase();
    outboxRepository.failNextInsert();

    assertThrows(
        OutboxWriteException.class,
        () -> approveCaseService.approve(caseId, TestActors.supervisor())
    );

    CaseRecord stored = caseRepository.get(caseId);
    assertEquals(CaseStatus.OPEN, stored.status());

    assertThat(outboxRepository.findByAggregateId(caseId.value())).isEmpty();
}

Pertanyaan desain penting:

Jika DB update sukses tapi event publish gagal, apa guarantee kita?

Jawaban production-grade biasanya bukan “publish langsung di transaction”. Biasanya:

write domain state + write outbox event atomically
publish asynchronously from outbox

Maka test failure harus menyesuaikan architecture.


8. Idempotent Error Handling

Retry adalah mesin pembesar bug.

Jika client retry karena timeout, server bisa menerima command yang sama dua kali.

Tanpa idempotency:

client sends approve request
server approves case and emits event
response lost
client retries
server approves again or rejects with confusing error

Dengan idempotency:

same idempotency key + same semantic request -> same result or safe no-op
same idempotency key + different request -> conflict

8.1 Test Duplicate Command

@Test
void duplicateApproveCommandReturnsPreviousResultWithoutDoubleEvent() {
    IdempotencyKey key = IdempotencyKey.of("req-123");
    CaseId caseId = seedOpenCaseWithCompleteEvidence();

    ApprovalResponse first = approveCaseService.approve(caseId, TestActors.supervisor(), key);
    ApprovalResponse second = approveCaseService.approve(caseId, TestActors.supervisor(), key);

    assertAll(
        () -> assertEquals(first.decisionId(), second.decisionId()),
        () -> assertEquals(1, outboxRepository.countEvents(caseId, "CaseApproved")),
        () -> assertEquals(CaseStatus.APPROVED, caseRepository.get(caseId).status())
    );
}

8.2 Test Same Key Different Payload

@Test
void sameIdempotencyKeyWithDifferentPayloadIsRejected() {
    IdempotencyKey key = IdempotencyKey.of("req-123");
    CaseId caseId = seedOpenCaseWithCompleteEvidence();

    approveCaseService.approve(caseId, TestActors.supervisor(), key);

    IdempotencyConflictException ex = assertThrows(
        IdempotencyConflictException.class,
        () -> approveCaseService.reject(caseId, TestActors.supervisor(), key, "new reason")
    );

    assertEquals(ErrorCode.IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD, ex.code());
    assertEquals(1, outboxRepository.countEvents(caseId));
}

8.3 Idempotency Failure Matrix

ScenarioExpected Behavior
same key, same payload, first successreturn previous success / no duplicate side effect
same key, same payload, first domain rejectionreturn previous rejection if rejection is cached by policy
same key, different payloadconflict
no key on non-idempotent endpointreject or allow only if operation naturally idempotent
key expiredprocess as new or reject depending policy
concurrent same keysingle winner, others observe same result
partial commit before response lostretry reconciles from idempotency store/outbox

Idempotency testing menggabungkan exception testing, state testing, concurrency testing, dan persistence testing.


9. Retryable vs Non-Retryable Exceptions

Salah satu bug produksi yang sering muncul:

system retries error that will never succeed

Atau sebaliknya:

system fails immediately on transient error that should be retried

Klasifikasi harus eksplisit.

public interface FailureClassification {
    boolean retryable();
    ErrorCode code();
}

public final class DownstreamTimeoutException extends RuntimeException implements FailureClassification {
    @Override
    public boolean retryable() {
        return true;
    }

    @Override
    public ErrorCode code() {
        return ErrorCode.DOWNSTREAM_TIMEOUT;
    }
}

Test:

@Test
void downstreamTimeoutIsRetryable() {
    DownstreamTimeoutException ex = new DownstreamTimeoutException("risk-service");

    assertAll(
        () -> assertTrue(ex.retryable()),
        () -> assertEquals(ErrorCode.DOWNSTREAM_TIMEOUT, ex.code())
    );
}

@Test
void schemaViolationIsNotRetryable() {
    RemoteContractViolationException ex = new RemoteContractViolationException("risk-service", "missing field score");

    assertAll(
        () -> assertFalse(ex.retryable()),
        () -> assertEquals(ErrorCode.REMOTE_CONTRACT_VIOLATION, ex.code())
    );
}

9.1 Test Retry Policy Behavior, Not Just Exception Class

@Test
void retriesTransientRiskTimeoutButStopsAfterBudget() {
    RiskClient riskClient = mock(RiskClient.class);
    when(riskClient.score(any()))
        .thenThrow(new DownstreamTimeoutException("risk"))
        .thenThrow(new DownstreamTimeoutException("risk"))
        .thenReturn(RiskScore.low());

    QuoteService service = new QuoteService(riskClient, RetryPolicies.threeAttemptsNoSleepForTest());

    Quote quote = service.quote(validRequest());

    assertEquals(QuoteStatus.QUOTED, quote.status());
    verify(riskClient, times(3)).score(any());
}

Test non-retryable:

@Test
void doesNotRetryRemoteContractViolation() {
    RiskClient riskClient = mock(RiskClient.class);
    when(riskClient.score(any()))
        .thenThrow(new RemoteContractViolationException("risk", "invalid response"));

    QuoteService service = new QuoteService(riskClient, RetryPolicies.threeAttemptsNoSleepForTest());

    assertThrows(RemoteContractViolationException.class, () -> service.quote(validRequest()));

    verify(riskClient, times(1)).score(any());
}

Retry test harus menghindari real sleep. Gunakan fake scheduler atau policy khusus test.


10. Testing Parser, Deserialization, and Schema Edge Cases

Boundary sistem sering berada di parser.

Input dari luar tidak peduli model Java kita rapi.

10.1 JSON Boundary Cases

Test cases:

missing required field
unknown field
field null
wrong type
invalid enum
numeric value as string
very large number
array instead of object
object instead of array
invalid UTF-8
malformed JSON
extra nested object

Contoh:

@ParameterizedTest
@MethodSource("invalidJsonBodies")
void rejectMalformedApproveRequest(String body, String expectedField) throws Exception {
    mockMvc.perform(post("/cases/C-1/approval")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.fieldErrors[*].field", hasItem(expectedField)));
}

static Stream<Arguments> invalidJsonBodies() {
    return Stream.of(
        arguments("{}", "decision"),
        arguments("{\"decision\":null}", "decision"),
        arguments("{\"decision\":\"MAYBE\"}", "decision"),
        arguments("{\"decision\":[]}", "decision")
    );
}

10.2 Unknown Field Policy

Unknown field policy harus sadar compatibility.

Ada dua strategi:

strict boundary:
    unknown field rejected
    useful for internal admin APIs and safety-critical commands

lenient boundary:
    unknown field ignored
    useful for backward/forward compatible public clients

Test strict:

@Test
void rejectUnknownFieldForDecisionCommand() throws Exception {
    String body = """
        {
          "decision": "APPROVE",
          "unexpectedField": "x"
        }
        """;

    mockMvc.perform(post("/cases/C-1/decision")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.code").value("UNKNOWN_FIELD"));
}

Test lenient:

@Test
void ignoreUnknownFieldForBackwardCompatibleQueryFilter() throws Exception {
    String body = """
        {
          "status": "OPEN",
          "futureClientField": "ignored"
        }
        """;

    mockMvc.perform(post("/cases/search")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isOk());
}

Policy ini harus masuk dokumentasi API. Jangan biarkan default serializer menentukan perilaku critical secara diam-diam.


11. Numeric Edge Cases: BigDecimal, Overflow, and Rounding

Pada sistem pricing/payment/regulatory, numeric bug bisa mahal.

11.1 BigDecimal Equality Trap

Java BigDecimal punya dua konsep:

compareTo: numeric equality
equals: numeric + scale equality

Contoh:

new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) == 0
new BigDecimal("1.0").equals(new BigDecimal("1.00")) == false

Test harus jelas apakah scale penting.

Money value object:

public record Money(String currency, BigDecimal amount) {
    public Money {
        Objects.requireNonNull(currency, "currency");
        Objects.requireNonNull(amount, "amount");

        if (amount.scale() > 2) {
            throw new DomainRejectedException(ErrorCode.INVALID_CURRENCY_SCALE);
        }
        if (amount.signum() < 0) {
            throw new DomainRejectedException(ErrorCode.AMOUNT_MUST_NOT_BE_NEGATIVE);
        }
    }
}

Test:

@Test
void rejectAmountWithMoreThanCurrencyMinorUnit() {
    DomainRejectedException ex = assertThrows(
        DomainRejectedException.class,
        () -> new Money("USD", new BigDecimal("10.001"))
    );

    assertEquals(ErrorCode.INVALID_CURRENCY_SCALE, ex.code());
}

11.2 Overflow Test

@Test
void rejectQuantityMultiplicationOverflow() {
    Quantity quantity = Quantity.of(Long.MAX_VALUE);
    UnitPrice price = UnitPrice.of(2);

    ArithmeticException ex = assertThrows(
        ArithmeticException.class,
        () -> PricingMath.total(quantity, price)
    );

    assertThat(ex).hasMessageContaining("overflow");
}

Implementation:

public static long total(Quantity quantity, UnitPrice price) {
    return Math.multiplyExact(quantity.value(), price.cents());
}

Jangan biarkan overflow diam-diam wrap menjadi angka negatif.

11.3 Rounding Contract Test

@Test
void regulatoryFeeUsesHalfUpRoundingAtTwoDecimals() {
    BigDecimal fee = RegulatoryFeeCalculator.calculate(new BigDecimal("10.005"));

    assertEquals(new BigDecimal("10.01"), fee);
}

Test ini penting jika rounding mode adalah bagian dari aturan bisnis atau regulasi.


12. Time Edge Cases

Time edge case akan dibahas lebih dalam di Part 010, tetapi negative-path testing harus mengenal boundary waktu.

Contoh:

before effective date
at exact effective date
after expiry date
end of day by business timezone
DST transition
Feb 29
clock skew
late event
future event

Test temporal eligibility:

@ParameterizedTest
@MethodSource("effectiveDateCases")
void eligibilityDependsOnEffectiveWindow(Instant now, boolean expectedEligible) {
    Policy policy = PolicyFixtures.activeFromTo(
        Instant.parse("2026-07-01T00:00:00Z"),
        Instant.parse("2026-08-01T00:00:00Z")
    );

    assertEquals(expectedEligible, policy.isEffectiveAt(now));
}

static Stream<Arguments> effectiveDateCases() {
    return Stream.of(
        arguments(Instant.parse("2026-06-30T23:59:59Z"), false),
        arguments(Instant.parse("2026-07-01T00:00:00Z"), true),
        arguments(Instant.parse("2026-07-15T12:00:00Z"), true),
        arguments(Instant.parse("2026-08-01T00:00:00Z"), false)
    );
}

Notice the half-open interval:

[startInclusive, endExclusive)

Half-open intervals reduce overlap bugs.


13. Security and Abuse Edge Cases

Testing edge case juga meliputi abuse input.

Tidak perlu menjadikan setiap unit test sebagai security scanner, tetapi boundary service harus punya test untuk kasus umum:

very long string
path traversal-like value
HTML/script-like value
SQL-like value
header injection
newline in identifier
unicode confusables
control characters
repeated nested arrays
large payload

Contoh identifier validation:

@ParameterizedTest
@ValueSource(strings = {
    "../C-1",
    "C-1\nX-Injected: true",
    "<script>alert(1)</script>",
    "C-1;DROP TABLE cases",
    ""
})
void rejectUnsafeCaseId(String raw) {
    DomainRejectedException ex = assertThrows(
        DomainRejectedException.class,
        () -> CaseId.parse(raw)
    );

    assertEquals(ErrorCode.INVALID_CASE_ID, ex.code());
}

Tetapi jangan overfit test ke string tertentu saja. Buat validator dengan invariant jelas:

CaseId must match ^C-[0-9]{1,12}$

Test:

@ParameterizedTest
@ValueSource(strings = {"C-1", "C-999999999999"})
void acceptValidCaseIds(String raw) {
    assertDoesNotThrow(() -> CaseId.parse(raw));
}

@ParameterizedTest
@ValueSource(strings = {"X-1", "C-", "C-0000000000000", "C-ABC", " C-1 ", "C-1\n"})
void rejectInvalidCaseIds(String raw) {
    assertThrows(DomainRejectedException.class, () -> CaseId.parse(raw));
}

14. Resource Exhaustion and Backpressure Failure

Banyak negative-path test berhenti di invalid input. Padahal sistem production sering gagal karena resource pressure.

Resource boundary:

connection pool exhausted
thread pool saturated
queue full
rate limit exceeded
payload too large
file too large
timeout budget exhausted
memory allocation too high

14.1 Queue Full Test

Misal service menerima work item ke bounded queue.

@Test
void rejectWhenWorkQueueIsFull() {
    BoundedWorkQueue queue = new BoundedWorkQueue(1);
    queue.enqueue(new WorkItem("first"));

    QueueFullException ex = assertThrows(
        QueueFullException.class,
        () -> queue.enqueue(new WorkItem("second"))
    );

    assertAll(
        () -> assertEquals(ErrorCode.WORK_QUEUE_FULL, ex.code()),
        () -> assertEquals(1, queue.size())
    );
}

14.2 Timeout Budget Test

@Test
void rejectBeforeCallingDependencyWhenTimeoutBudgetAlreadyExpired() {
    TimeoutBudget budget = TimeoutBudget.expired();
    RiskClient riskClient = mock(RiskClient.class);

    QuoteService service = new QuoteService(riskClient);

    TimeoutBudgetExceededException ex = assertThrows(
        TimeoutBudgetExceededException.class,
        () -> service.quote(validRequest(), budget)
    );

    assertEquals(ErrorCode.TIMEOUT_BUDGET_EXCEEDED, ex.code());
    verifyNoInteractions(riskClient);
}

This protects a critical invariant:

never start expensive downstream work when request budget is already exhausted

15. Cancellation and Interruption

Java cancellation is easy to mishandle.

Common bugs:

InterruptedException swallowed
thread interrupt flag lost
cleanup skipped
partial state committed after cancellation
Future cancellation ignored
blocking operation not bounded

15.1 Preserve Interrupt Status

Bad:

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    log.warn("interrupted");
}

Better:

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new OperationCancelledException(e);
}

Test:

@Test
void preservesInterruptStatusWhenCancelled() throws Exception {
    InterruptibleWorker worker = new InterruptibleWorker();

    Thread.currentThread().interrupt();
    try {
        assertThrows(OperationCancelledException.class, worker::doWork);
        assertTrue(Thread.currentThread().isInterrupted());
    } finally {
        // clear interrupt so it does not pollute the rest of the test JVM
        Thread.interrupted();
    }
}

Be careful: manipulating interrupt flag in unit tests can pollute other tests. Always cleanup.

15.2 Cancellation Must Not Commit Success

@Test
void cancellationBeforeCommitDoesNotPersistSuccess() {
    CaseId caseId = seedOpenCase();
    CancellationToken token = CancellationToken.cancelled();

    assertThrows(
        OperationCancelledException.class,
        () -> approveCaseService.approve(caseId, TestActors.supervisor(), token)
    );

    assertEquals(CaseStatus.OPEN, caseRepository.get(caseId).status());
    assertThat(outboxRepository.findByAggregateId(caseId.value())).isEmpty();
}

16. Golden Rule: Every Failure Test Needs an Oracle

A failure test without oracle is weak.

Weak:

assertThrows(Exception.class, () -> service.execute(command));

Strong:

expected error code
expected state after failure
expected side effects absent/present
expected retry classification
expected audit/metric/log behavior
expected transaction boundary

Oracle matrix:

FailureError CodeStateEventPersistenceRetryObservability
invalid fieldVALIDATION_ERRORunchangednonenonenovalidation metric
illegal transitionILLEGAL_TRANSITIONunchangednonenonenodomain rejection metric
optimistic lockCONFLICTunchanged by losernone by loserno duplicatecaller may retryconflict metric
downstream timeout before commitDOWNSTREAM_TIMEOUTunchangednonerollbackyesdependency timeout metric
outbox insert failureOUTBOX_WRITE_FAILEDrollbacknonerollbackdependscritical alert
duplicate idempotent commandnone / previousstableno duplicatestablesafeidempotency hit metric

17. Test Naming for Negative Paths

Bad names:

testError1
testInvalidInput
testException

Better names:

closedCaseCannotBeApproved
unauthorizedUserCannotAssignInvestigator
sameIdempotencyKeyWithDifferentPayloadIsRejected
outboxFailureRollsBackCaseApproval
expiredPolicyIsNotEligibleAtEndExclusiveBoundary
invalidCurrencyScaleIsRejectedBeforePricing

Good negative test names encode the invariant.


18. A Complete Example: Approval Failure Test Suite

Domain rule:

A case can be approved only when:
- status is PENDING_APPROVAL
- required evidence is complete
- approver has SUPERVISOR role
- approver is not the submitter
- policy is effective at approval time

Test suite outline:

class ApproveCaseFailureTest {

    private final Instant now = Instant.parse("2026-07-02T10:00:00Z");

    @Test
    void cannotApproveClosedCase() { }

    @Test
    void cannotApproveDraftCase() { }

    @Test
    void cannotApproveWhenRequiredEvidenceIsMissing() { }

    @Test
    void viewerCannotApprove() { }

    @Test
    void submitterCannotApproveOwnCase() { }

    @Test
    void cannotApproveWhenPolicyIsNotYetEffective() { }

    @Test
    void cannotApproveWhenPolicyExpiredAtEndExclusiveBoundary() { }

    @Test
    void rejectionDoesNotAppendCaseApprovedEvent() { }

    @Test
    void rejectionDoesNotMutateStatus() { }

    @Test
    void applicationServiceDoesNotSaveRejectedApproval() { }
}

One implementation:

@Test
void submitterCannotApproveOwnCase() {
    UserId submitter = UserId.of("u-1");
    CaseAggregate c = CaseFixtures.pendingApproval()
        .submittedBy(submitter)
        .withRequiredEvidenceComplete()
        .build();

    CaseSnapshot before = CaseSnapshot.from(c);

    DomainRejectedException ex = assertThrows(
        DomainRejectedException.class,
        () -> c.approve(TestActors.supervisor(submitter), now)
    );

    assertAll(
        () -> assertEquals(ErrorCode.SUBMITTER_CANNOT_APPROVE_OWN_CASE, ex.code()),
        () -> assertEquals(before, CaseSnapshot.from(c)),
        () -> assertThat(c.pendingEvents()).isEmpty()
    );
}

This is not over-testing. This is the behavioral perimeter of the domain.


19. Anti-Patterns

19.1 Catch-All Exception Assertion

assertThrows(Exception.class, () -> service.execute());

Almost always too weak.

19.2 Testing Implementation Accident

assertThat(ex.getStackTrace()[0].getMethodName()).isEqualTo("validateStatus");

This locks implementation structure, not behavior.

19.3 Negative Test Without State Assertion

assertThrows(DomainRejectedException.class, () -> c.approve(actor));

Better: assert state unchanged.

19.4 Using Real Sleep in Retry/Timeout Tests

Thread.sleep(5000);

Use fake scheduler, zero-delay retry policy, timeout budget abstraction, or Awaitility for async condition tests.

19.5 Snapshotting Everything Blindly

Snapshot assertions are useful, but can become brittle if they compare irrelevant fields.

Prefer invariant-specific assertion:

assertEquals(CaseStatus.OPEN, c.status());
assertThat(c.pendingEvents()).isEmpty();

20. Production-Grade Checklist

Use this checklist when reviewing negative-path test coverage.

20.1 Exception Contract

  • Does the test assert specific exception/result type?
  • Does it assert stable error code?
  • Does it avoid brittle message assertions?
  • Does it check retryable/non-retryable classification when relevant?
  • Does it distinguish domain rejection from infrastructure failure?

20.2 State Safety

  • Does invalid command leave aggregate unchanged?
  • Does failure avoid appending success events?
  • Does application service avoid save/publish on rejection?
  • Does transaction rollback on mid-operation failure?
  • Does cancellation avoid success commit?

20.3 Boundary Coverage

  • Null, empty, blank handled explicitly?
  • Numeric min/max/overflow/scale covered?
  • Date/time boundaries covered?
  • Collection empty/single/duplicate/large covered?
  • Schema missing/unknown/invalid enum/wrong type covered?

20.4 Operational Behavior

  • Retry policy tested without real sleep?
  • Non-retryable errors stop immediately?
  • Timeout budget respected?
  • Resource exhaustion produces bounded failure?
  • Observability exists for rejected/failing paths?

21. Practice Lab

Implement a small CaseApprovalService with these rules:

case status must be PENDING_APPROVAL
actor must have SUPERVISOR role
actor must not be submitter
case must have at least one evidence item
policy must be effective at approval time
approval command must have idempotency key
same idempotency key must not produce duplicate event

Write tests for:

1. each domain rejection
2. no state mutation on each rejection
3. no outbox event on rejection
4. duplicate idempotency key same payload
5. duplicate idempotency key different payload
6. policy effective start inclusive
7. policy effective end exclusive
8. repository save failure rollback behavior
9. outbox failure rollback behavior
10. retryable vs non-retryable infrastructure exception mapping

Your target is not test count. Your target is confidence that failure behavior is specified.


22. Key Takeaways

  • Failure is behavior, not absence of behavior.
  • Negative-path tests must assert more than exception type.
  • Stable error codes are better than brittle message assertions.
  • Edge cases should come from model boundaries, not random guessing.
  • Domain rejection, validation failure, infrastructure failure, and programming bug must not collapse into one generic exception bucket.
  • Every failure test should check state, side effect, retry classification, or observability when relevant.
  • Retry and idempotency make failure semantics part of distributed correctness.
  • The best exception test proves that the system rejected the command and stayed safe.

References

  • JUnit User Guide and Assertions API: https://docs.junit.org/
  • Java Clock and java.time API documentation: https://docs.oracle.com/javase/8/docs/api/java/time/Clock.html
  • Oracle Java SE API documentation for core exception/time classes: https://docs.oracle.com/javase/
Lesson Recap

You just completed lesson 09 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.