Java notes
Contract Testing a Legacy-to-gRPC Bridge in JUnit 5
One contract, two implementations, recorded fixtures, and fewer flaky tests
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
Clockand 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.