DEV Community

Cover image for Testing Across the Java/.NET Boundary: A Practical Strategy That Actually Works
JNBridge
JNBridge

Posted on • Originally published at jnbridge.com

Testing Across the Java/.NET Boundary: A Practical Strategy That Actually Works

If you've ever stared at a green CI pipeline and then watched your Java/.NET integration explode in production — you know that standard unit tests aren't enough when two runtimes are talking to each other.

The bridge boundary between Java and .NET is a unique layer that most testing strategies completely ignore. Serialization failures, type mapping bugs, classpath issues — none of these show up in your mocked unit tests. Here's a testing strategy designed specifically for polyglot Java/.NET architectures, with code you can actually use.

The Testing Pyramid Gets a New Layer

The standard testing pyramid (unit → integration → E2E) works for single-language apps. But Java/.NET integration needs a distinct bridge test layer:

         ╱╲
        ╱ E2E ╲          Few: Full system tests
       ╱────────╲
      ╱ Bridge    ╲       Medium: Cross-language integration
     ╱──────────────╲
    ╱ Integration     ╲   Medium: Each side's service tests
   ╱────────────────────╲
  ╱ Unit                  ╲ Many: Pure logic tests per language
 ╱──────────────────────────╲
Enter fullscreen mode Exit fullscreen mode
  • Unit tests: Test Java and .NET logic independently. Mock the bridge boundary. Run in milliseconds.
  • Integration tests (per side): Test each side with real dependencies (DBs, file systems) but mock the cross-language boundary.
  • Bridge tests: Test that Java and .NET actually communicate correctly through the bridge. Requires both runtimes running.
  • E2E tests: Full system tests across both platforms.

Unit Testing: Mock the Bridge, Not the Logic

The key is abstracting the bridge behind an interface so your business logic is testable without a JVM:

// ❌ Bad: Tight coupling to Java bridge
public class RiskService
{
    public decimal CalculateRisk(Portfolio portfolio)
    {
        var javaEngine = new com.company.risk.RiskEngine();
        return (decimal)javaEngine.Calculate(portfolio.ToJavaObject());
    }
}

// ✅ Good: Interface abstraction
public interface IRiskEngine
{
    decimal CalculateVaR(Portfolio portfolio, double confidence);
    decimal CalculateExpectedShortfall(Portfolio portfolio);
}

// Production implementation uses the bridge
public class JnBridgeRiskEngine : IRiskEngine
{
    private readonly com.company.risk.RiskEngine _javaEngine;

    public JnBridgeRiskEngine()
    {
        _javaEngine = new com.company.risk.RiskEngine();
    }

    public decimal CalculateVaR(Portfolio portfolio, double confidence)
    {
        var javaPortfolio = PortfolioMapper.ToJava(portfolio);
        return (decimal)_javaEngine.CalculateVaR(javaPortfolio, confidence);
    }

    public decimal CalculateExpectedShortfall(Portfolio portfolio)
    {
        var javaPortfolio = PortfolioMapper.ToJava(portfolio);
        return (decimal)_javaEngine.CalculateES(javaPortfolio);
    }
}

// Unit test with mock — no JVM needed
[TestFixture]
public class PortfolioServiceTests
{
    [Test]
    public void RebalancePortfolio_WhenRiskExceedsThreshold_ReducesExposure()
    {
        var mockRiskEngine = new Mock<IRiskEngine>();
        mockRiskEngine.Setup(r => r.CalculateVaR(It.IsAny<Portfolio>(), 0.99))
            .Returns(1_500_000m);

        var service = new PortfolioService(mockRiskEngine.Object);
        var portfolio = CreateTestPortfolio();

        var result = service.Rebalance(portfolio);

        Assert.That(result.TotalExposure, Is.LessThan(portfolio.TotalExposure));
    }
}
Enter fullscreen mode Exit fullscreen mode

Same pattern on the Java side — if Java calls back into .NET:

public interface DotNetNotificationService {
    void notifyTradeExecuted(String tradeId, double price, int quantity);
    void notifyRiskAlert(String portfolioId, double varValue);
}

public class TradeExecutionService {
    private final DotNetNotificationService notificationService;

    public TradeExecutionService(DotNetNotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public TradeResult executeTrade(TradeOrder order) {
        var result = matchingEngine.submit(order);
        notificationService.notifyTradeExecuted(
            result.getTradeId(), result.getPrice(), result.getQuantity());
        return result;
    }
}

@ExtendWith(MockitoExtension.class)
class TradeExecutionServiceTest {
    @Mock
    private DotNetNotificationService notificationService;

    @InjectMocks
    private TradeExecutionService service;

    @Test
    void executeTrade_notifiesDotNetOnSuccess() {
        var order = new TradeOrder("AAPL", Side.BUY, 100, 185.50);
        var result = service.executeTrade(order);

        verify(notificationService).notifyTradeExecuted(
            eq(result.getTradeId()), eq(185.50), eq(100));
    }
}
Enter fullscreen mode Exit fullscreen mode

Bridge Integration Tests: The Layer Most People Skip

This is where you catch the real bugs — type mapping failures, serialization issues, classpath problems:

[TestFixture]
[Category("BridgeIntegration")]
public class RiskEngineIntegrationTests
{
    private JnBridgeRiskEngine _riskEngine;

    [OneTimeSetUp]
    public void SetUp()
    {
        BridgeConfiguration.Initialize(new BridgeConfig
        {
            JavaHome = Environment.GetEnvironmentVariable("JAVA_HOME"),
            ClassPath = "java-libs/risk-engine.jar;java-libs/dependencies/*"
        });
        _riskEngine = new JnBridgeRiskEngine();
    }

    [Test]
    public void CalculateVaR_WithValidPortfolio_ReturnsPositiveValue()
    {
        var portfolio = new Portfolio();
        portfolio.AddPosition("AAPL", 1000, 185.50m);
        portfolio.AddPosition("MSFT", 500, 420.30m);

        var result = _riskEngine.CalculateVaR(portfolio, 0.99);

        Assert.That(result, Is.GreaterThan(0));
        Assert.That(result, Is.LessThan(portfolio.TotalValue));
    }

    [Test]
    public void CalculateVaR_WithHighConfidence_ReturnsHigherValue()
    {
        var portfolio = CreateLargePortfolio();

        var var95 = _riskEngine.CalculateVaR(portfolio, 0.95);
        var var99 = _riskEngine.CalculateVaR(portfolio, 0.99);

        Assert.That(var99, Is.GreaterThan(var95));
    }

    [OneTimeTearDown]
    public void TearDown() => BridgeConfiguration.Shutdown();
}
Enter fullscreen mode Exit fullscreen mode

Type Mapping Tests — Where the Real Bugs Hide

[TestFixture]
[Category("BridgeIntegration")]
public class TypeMappingTests
{
    [Test]
    public void JavaBigDecimal_MapsTo_DotNetDecimal()
    {
        var javaCalc = new com.company.Calculator();
        var result = javaCalc.PreciseCalculation(1.1, 2.2);
        Assert.That(result, Is.EqualTo(3.3m).Within(0.0001m));
    }

    [Test]
    public void JavaLocalDateTime_MapsTo_DotNetDateTime()
    {
        var javaTimeService = new com.company.TimeService();
        var javaTime = javaTimeService.GetCurrentTime();
        Assert.That(javaTime, Is.EqualTo(DateTime.UtcNow).Within(TimeSpan.FromSeconds(5)));
    }

    [Test]
    public void JavaException_PropagatesTo_DotNetException()
    {
        var javaService = new com.company.DataService();
        var ex = Assert.Throws<Exception>(() => javaService.MethodThatThrows());
        Assert.That(ex.Message, Does.Contain("expected error message"));
    }

    [TestCase(null)]
    [TestCase("")]
    [TestCase("very long string with unicode: こんにちは 🎉")]
    public void JavaString_HandlesEdgeCases(string input)
    {
        var javaService = new com.company.StringService();
        var result = javaService.Echo(input);
        Assert.That(result, Is.EqualTo(input));
    }
}
Enter fullscreen mode Exit fullscreen mode

Performance Tests: Don't Assume, Measure

These catch regressions when Java libs update, bridge versions change, or JVM settings are modified:

[TestFixture]
[Category("Performance")]
public class BridgePerformanceTests
{
    [Test]
    public void InProcessBridge_SimpleCall_Under100Microseconds()
    {
        var javaService = new com.company.EchoService();

        // Warmup
        for (int i = 0; i < 1000; i++)
            javaService.Echo("warmup");

        var sw = Stopwatch.StartNew();
        int iterations = 10_000;
        for (int i = 0; i < iterations; i++)
            javaService.Echo("test");
        sw.Stop();

        var avgMicroseconds = sw.Elapsed.TotalMicroseconds / iterations;
        Assert.That(avgMicroseconds, Is.LessThan(100), 
            "Bridge call latency exceeded 100μs threshold");
    }

    [Test]
    public void Bridge_Under10KConcurrentCalls_NoErrors()
    {
        var javaService = new com.company.ThreadSafeService();
        var errors = new ConcurrentBag<Exception>();

        Parallel.For(0, 10_000, new ParallelOptions { MaxDegreeOfParallelism = 50 }, i =>
        {
            try { javaService.Process($"item-{i}"); }
            catch (Exception ex) { errors.Add(ex); }
        });

        Assert.That(errors, Is.Empty, 
            $"Bridge had {errors.Count} errors under concurrent load");
    }

    [Test]
    public void Bridge_MemoryStable_Over1MillionCalls()
    {
        var javaService = new com.company.DataService();
        var initialMemory = GC.GetTotalMemory(true);

        for (int i = 0; i < 1_000_000; i++)
            javaService.GetSmallObject();

        GC.Collect();
        GC.WaitForPendingFinalizers();
        var finalMemory = GC.GetTotalMemory(true);
        var memoryGrowthMB = (finalMemory - initialMemory) / (1024.0 * 1024.0);

        Assert.That(memoryGrowthMB, Is.LessThan(50), 
            "Possible memory leak — growth exceeded 50MB over 1M calls");
    }
}
Enter fullscreen mode Exit fullscreen mode

Docker-Based Test Environment

Bridge integration tests need both runtimes. Docker makes it reproducible:

# Dockerfile.test — Both runtimes in one container
FROM mcr.microsoft.com/dotnet/sdk:9.0

RUN apt-get update && apt-get install -y \
    openjdk-21-jdk-headless \
    && rm -rf /var/lib/apt/lists/*

ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64

WORKDIR /test
COPY . .
RUN dotnet restore && dotnet build -c Release

ENTRYPOINT ["dotnet", "test", "-c", "Release", "--logger", "trx"]
Enter fullscreen mode Exit fullscreen mode
# docker-compose.test.yml
version: '3.8'
services:
  unit-tests:
    build: { context: ., dockerfile: Dockerfile.test }
    command: ["dotnet", "test", "-c", "Release", 
              "--filter", "Category!=BridgeIntegration&Category!=Performance"]
    volumes: [test-results:/test/TestResults]

  bridge-tests:
    build: { context: ., dockerfile: Dockerfile.test }
    command: ["dotnet", "test", "-c", "Release",
              "--filter", "Category=BridgeIntegration"]
    volumes: [test-results:/test/TestResults]
    depends_on:
      unit-tests: { condition: service_completed_successfully }

  performance-tests:
    build: { context: ., dockerfile: Dockerfile.test }
    command: ["dotnet", "test", "-c", "Release",
              "--filter", "Category=Performance"]
    volumes: [test-results:/test/TestResults]
    depends_on:
      bridge-tests: { condition: service_completed_successfully }

volumes:
  test-results:
Enter fullscreen mode Exit fullscreen mode

CI/CD Pipeline: GitHub Actions

name: Java/.NET Integration Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '9.0.x' }
      - run: dotnet test --filter "Category!=BridgeIntegration&Category!=Performance"

  java-unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin' }
      - run: ./gradlew test

  bridge-tests:
    needs: [unit-tests, java-unit-tests]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '9.0.x' }
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin' }
      - run: dotnet test --filter "Category=BridgeIntegration"
        timeout-minutes: 10

  performance-gate:
    needs: [bridge-tests]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '9.0.x' }
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin' }
      - run: dotnet test --filter "Category=Performance"
Enter fullscreen mode Exit fullscreen mode

Pipeline strategy:

Stage Tests When Time
PR checks Unit tests (both languages) Every PR ~2 min
PR checks Bridge integration tests Every PR ~5 min
Merge to main Performance tests After merge ~10 min
Nightly Full E2E + load tests Scheduled ~30 min
Pre-release All tests + security scan Before deploy ~45 min

6 Pitfalls That Will Bite You

  1. Not testing type boundaries. Java BigDecimal → .NET decimal, LocalDateTime → DateTime, ArrayList → List — test every type that crosses the bridge. These are your #1 production failure source.

  2. Ignoring thread safety. If your bridge calls are concurrent in production (they usually are), test with concurrent load. Some bridge configs aren't thread-safe by default.

  3. Skipping classpath smoke tests. A missing JAR causes cryptic failures. Write a test that verifies all required Java classes are loadable at bridge init.

  4. Testing only the happy path. What happens when Java throws? Does the exception propagate correctly to .NET? Test timeouts, OOM, and failure modes explicitly.

  5. No performance baselines. Without baselines, you won't notice a 10x latency regression from a Java library update. Assert thresholds in your perf tests.

  6. Running bridge tests in parallel. Bridge initialization often isn't parallelizable. Use [NonParallelizable] in NUnit or sequential execution for bridge setup/teardown.

Testing Checklist

Use this when setting up testing for a Java/.NET integration:

  • ☐ Business logic unit tests (both sides) with mocked bridge boundary
  • ☐ Type mapping tests for every type crossing the bridge
  • ☐ Null handling tests for all bridge method parameters
  • ☐ Exception propagation tests (Java → .NET)
  • ☐ Collection mapping tests (ArrayList → List, HashMap → Dictionary)
  • ☐ Unicode/encoding tests for string parameters
  • ☐ Date/time/timezone tests
  • ☐ Concurrent load test (50+ parallel calls)
  • ☐ Memory stability test (1M+ calls)
  • ☐ Latency baseline with asserted threshold
  • ☐ Bridge init smoke test (all JARs loadable)
  • ☐ Docker-based test environment for CI
  • ☐ CI pipeline with staged execution
  • ☐ Performance regression gate on main branch

Testing Java/.NET integration requires a deliberate strategy beyond standard unit testing. The bridge boundary introduces type mapping, serialization, concurrency, and performance concerns that don't exist in single-language apps.

The approach here — interface abstraction for unit tests, dedicated bridge tests, Docker environments, and CI/CD with performance gates — gives you confidence your integration works correctly even as both sides evolve.

JNBridgePro supports both in-process and TCP testing modes, so you can run bridge tests locally during development and in Docker containers during CI.

Building a tested Java/.NET integration? Download JNBridgePro and try these patterns with your own codebase.

Top comments (0)