← Java articles

Java notes

Contract Testing a Legacy-to-gRPC Bridge in JUnit 5

One contract, two implementations, recorded fixtures, and fewer flaky tests

Shubham Anandani··~9 min read

The idea: one contract, many implementations

public interface ProfileApi {
    Optional<Profile> get(String id);
    Map<String, Profile> batchGet(Collection<String> ids);   // missing ids are omitted
}

public record Profile(String id, String headline) { }
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;

public abstract class ProfileApiContract {

    protected abstract ProfileApi api();

    @Test
    void getReturnsEmptyForUnknownId() {
        assertTrue(api().get("does-not-exist").isEmpty());
    }

    @Test
    void batchGetOmitsMissingIdsAndKeepsTheRest() {
        Map<String, Profile> result = api().batchGet(List.of("p1", "missing", "p2"));
        assertEquals(Set.of("p1", "p2"), result.keySet());
    }

    @Test
    void batchGetWithNoIdsReturnsEmptyMap() {
        assertTrue(api().batchGet(List.of()).isEmpty());
    }

    @Test
    void batchGetWithDuplicateIdsReturnsEachOnce() {
        assertEquals(1, api().batchGet(List.of("p1", "p1")).size());
    }
}
class LegacyProfileApiTest extends ProfileApiContract {
    @Override protected ProfileApi api() { return TestFixtures.legacyApi(); }
}

class BridgedProfileApiTest extends ProfileApiContract {
    @Override protected ProfileApi api() { return TestFixtures.grpcBackedApi(); }
}

Make gRPC tests hermetic

import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;

String name = InProcessServerBuilder.generateName();
Server server = InProcessServerBuilder.forName(name)
        .directExecutor()
        .addService(new ProfileGrpcService(fakeBackend))
        .build().start();
ManagedChannel channel = InProcessChannelBuilder.forName(name).directExecutor().build();

Record real interactions, replay them in tests

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

class ReplayTest {

    record Fixture(String name, List<String> ids, Set<String> expectedKeys) { }

    static List<Fixture> fixtures() {
        return FixtureLoader.loadAll("fixtures/");     // JSON files kept in src/test/resources
    }

    @ParameterizedTest(name = "{0}")
    @MethodSource("fixtures")
    void bridgeMatchesRecordedBehavior(Fixture f) {
        Map<String, Profile> result = TestFixtures.grpcBackedApi().batchGet(f.ids());
        assertEquals(f.expectedKeys(), result.keySet());
    }
}

Reducing flakiness

  • No real clocks or randomness. Inject a Clock and seed random generators.
  • No shared state across tests. Build fresh fixtures per test.
  • Wait on conditions, not sleeps. Use Awaitility or latches.
  • Quarantine, then fix. Tag known-flaky tests so they do not block merges, and track them.

Coverage that matters

Failure modes

  • Test wiring that works locally but misses configuration files in CI. Keep test resources under standard source-set paths.
  • Contract tests that only cover happy paths.
  • Fixtures that go stale. Re-record on a schedule.

Takeaways

  • Test the bridge with one shared contract run against both implementations.
  • Use in-process gRPC for hermetic tests, and recorded, scrubbed fixtures for realism.
  • Treat batch behavior and error mapping as first-class contract items.