Java .NET Integration Testing: Unit Tests, Integration Tests, and CI/CD Pipelines
Integration testing across language boundaries is one of the hardest problems in polyglot architecture. When Java and .NET components interact through a bridge, REST API, or message queue, how do you write tests that catch failures at the integration point — not just within each language silo?
This guide covers a practical testing strategy for Java/.NET integration: from unit tests that mock the bridge boundary, to integration tests that exercise the real cross-language path, to CI/CD pipelines that validate everything before deployment. We’ll include code examples for NUnit/.NET and JUnit/Java, Docker-based test environments, and patterns that work with JNBridgePro’s in-process and TCP modes.
The Testing Pyramid for Cross-Language Integration
The standard testing pyramid (unit → integration → E2E) applies to Java/.NET integration, but with a critical addition: the bridge boundary itself is a distinct layer that needs its own test coverage.
╱╲
╱ E2E ╲ Few: Full system tests
╱────────╲
╱ Bridge ╲ Medium: Cross-language integration
╱──────────────╲
╱ Integration ╲ Medium: Each side's service tests
╱────────────────────╲
╱ Unit ╲ Many: Pure logic tests per language
╱──────────────────────────╲- Unit tests (bottom): Test Java logic and .NET logic independently. Mock the bridge boundary. Fast, run in milliseconds.
- Integration tests (per side): Test each side with its real dependencies (databases, file systems) but mock the cross-language boundary.
- Bridge tests (cross-language): Test that Java and .NET actually communicate correctly through the bridge. This is the unique layer. Requires both runtimes.
- E2E tests (top): Full system tests that exercise real user scenarios across both platforms.
Unit Testing: Mocking the Bridge Boundary
.NET Side: Mocking Java Dependencies
The key to testable integration code is abstracting the bridge behind an interface. Never call Java classes directly from business logic:
// ❌ Bad: Tight coupling to Java bridge
public class RiskService
{
public decimal CalculateRisk(Portfolio portfolio)
{
// Direct Java bridge call — untestable without JVM
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()
{
// Arrange
var mockRiskEngine = new Mock<IRiskEngine>();
mockRiskEngine.Setup(r => r.CalculateVaR(It.IsAny<Portfolio>(), 0.99))
.Returns(1_500_000m); // Exceeds threshold
var service = new PortfolioService(mockRiskEngine.Object);
var portfolio = CreateTestPortfolio();
// Act
var result = service.Rebalance(portfolio);
// Assert
Assert.That(result.TotalExposure, Is.LessThan(portfolio.TotalExposure));
}
}Java Side: Mocking .NET Callbacks
If Java code calls back into .NET (common in event-driven architectures), apply the same pattern:
// Java interface for .NET callback
public interface DotNetNotificationService {
void notifyTradeExecuted(String tradeId, double price, int quantity);
void notifyRiskAlert(String portfolioId, double varValue);
}
// Java service with injectable dependency
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;
}
}
// JUnit test with mock
@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));
}
}Bridge Integration Tests: Testing the Real Connection
Unit tests with mocks are fast but don’t catch bridge-specific issues: serialization failures, type mapping errors, version mismatches, or classpath problems. Bridge integration tests exercise the actual cross-language path.
In-Process Bridge Tests
// NUnit integration test — requires JVM available
[TestFixture]
[Category("BridgeIntegration")]
public class RiskEngineIntegrationTests
{
private JnBridgeRiskEngine _riskEngine;
[OneTimeSetUp]
public void SetUp()
{
// Initialize the real bridge — loads JVM
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_WithEmptyPortfolio_ReturnsZero()
{
var portfolio = new Portfolio();
var result = _riskEngine.CalculateVaR(portfolio, 0.99);
Assert.That(result, Is.EqualTo(0));
}
[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();
}
}Testing Type Mapping
One of the most common bridge failure modes is type mapping errors — where a Java type doesn’t convert correctly to its .NET equivalent. Dedicated tests for this:
[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);
// Verify no precision loss across the bridge
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();
// Verify timezone handling across bridge
Assert.That(javaTime, Is.EqualTo(DateTime.UtcNow).Within(TimeSpan.FromSeconds(5)));
}
[Test]
public void JavaArrayList_MapsTo_DotNetList()
{
var javaService = new com.company.DataService();
var results = javaService.GetItems();
// Verify collection type and contents
Assert.That(results, Is.Not.Null);
Assert.That(results.Count, Is.GreaterThan(0));
Assert.That(results[0], Is.TypeOf<string>());
}
[Test]
public void JavaException_PropagatesTo_DotNetException()
{
var javaService = new com.company.DataService();
var ex = Assert.Throws<Exception>(() =>
javaService.MethodThatThrows());
// Verify exception message crosses bridge
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));
}
}Performance Tests for the Bridge
Integration performance should be tested, not assumed. These tests catch regressions when Java libraries are updated, 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");
// Measure
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;
Console.WriteLine($"Average call time: {avgMicroseconds:F2} μs");
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
{
var result = javaService.Process($"item-{i}");
Assert.That(result, Is.Not.Null);
}
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++)
{
var result = javaService.GetSmallObject();
// Don't hold references — let GC collect
}
GC.Collect();
GC.WaitForPendingFinalizers();
var finalMemory = GC.GetTotalMemory(true);
var memoryGrowthMB = (finalMemory - initialMemory) / (1024.0 * 1024.0);
Console.WriteLine($"Memory growth: {memoryGrowthMB:F2} MB");
Assert.That(memoryGrowthMB, Is.LessThan(50),
"Possible memory leak — growth exceeded 50MB over 1M calls");
}
}Docker-Based Test Environment
Bridge integration tests need both the JVM and .NET runtime. Docker makes this reproducible:
# Dockerfile.test — Test environment with both runtimes
FROM mcr.microsoft.com/dotnet/sdk:9.0
# Install JDK for bridge tests
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 . .
# Restore and build
RUN dotnet restore
RUN dotnet build -c Release
# Run tests with filtering
ENTRYPOINT ["dotnet", "test", "-c", "Release", "--logger", "trx"]# 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",
"--logger", "trx;LogFileName=unit-results.trx"]
volumes:
- test-results:/test/TestResults
bridge-tests:
build:
context: .
dockerfile: Dockerfile.test
command: ["dotnet", "test", "-c", "Release",
"--filter", "Category=BridgeIntegration",
"--logger", "trx;LogFileName=bridge-results.trx"]
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",
"--logger", "trx;LogFileName=perf-results.trx"]
volumes:
- test-results:/test/TestResults
depends_on:
bridge-tests:
condition: service_completed_successfully
volumes:
test-results:CI/CD Pipeline Integration
GitHub Actions Pipeline
# .github/workflows/integration-tests.yml
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'
- name: Run .NET unit tests
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'
- name: Run Java unit tests
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'
- name: Run bridge integration tests
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'
- name: Run performance tests
run: dotnet test --filter "Category=Performance"
- name: Upload performance results
uses: actions/upload-artifact@v4
with:
name: performance-results
path: TestResults/*.trxPipeline 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 |
Common Bridge Testing Pitfalls
- Not testing type boundaries. The bridge converts between Java and .NET types. Test edge cases: null values, empty collections, large numbers, Unicode strings, date/time zones. These are the most common production failures.
- Ignoring thread safety. If your bridge calls are concurrent (and they usually are in production), test with concurrent load. Some bridge configurations are not thread-safe by default.
- Skipping classpath tests. A missing JAR on the classpath causes cryptic failures. Write a smoke test that verifies all required Java classes are loadable at bridge initialization.
- Testing only the happy path. What happens when the Java side throws an exception? Does it propagate correctly to .NET? What about timeouts? Out-of-memory? Test failure modes explicitly.
- Not measuring performance baselines. Without baseline measurements, you won’t notice a 10x latency regression introduced by a Java library update. Performance tests should assert thresholds.
- Running bridge tests in parallel by default. Bridge initialization is often not parallelizable. Use
[NonParallelizable](NUnit) or sequential test execution for bridge setup/teardown.
Testing Checklist
Use this checklist when setting up testing for a Java/.NET integration project:
- ☐ Business logic unit tests (both sides) with mocked bridge boundary
- ☐ Type mapping tests for every type that crosses the bridge
- ☐ Null handling tests for all bridge method parameters
- ☐ Exception propagation tests (Java exceptions → .NET)
- ☐ Collection mapping tests (ArrayList → List, HashMap → Dictionary)
- ☐ Unicode/encoding tests for string parameters
- ☐ Date/time/timezone tests across the bridge
- ☐ Concurrent load test (at least 50 parallel calls)
- ☐ Memory stability test (1M+ calls without leak)
- ☐ Latency baseline test with asserted threshold
- ☐ Bridge initialization smoke test (all JARs loadable)
- ☐ Docker-based test environment for CI reproducibility
- ☐ CI pipeline with staged test execution
- ☐ Performance regression gate on main branch
Conclusion
Testing Java/.NET integration requires a deliberate strategy that goes beyond standard unit testing. The bridge boundary is a unique layer that introduces type mapping, serialization, concurrency, and performance concerns that don’t exist in single-language applications.
The approach outlined here — interface abstraction for unit tests, dedicated bridge integration tests, Docker-based reproducible environments, and CI/CD with performance gates — gives you confidence that your Java/.NET integration works correctly and performs well, even as both sides evolve independently.
JNBridgePro supports both in-process and TCP testing modes, making it easy to run bridge tests locally during development and in Docker containers during CI. The direct Java object access means your tests can verify not just return values but the state of Java objects after cross-language operations.
Building a tested Java/.NET integration? Download JNBridgePro and try the testing patterns in this guide with your own codebase.
