Testing Framework Containers

Guide for writing unit and integration tests with JUnit 5 and the ONTIC test base framework.

Guide for writing unit and integration tests with JUnit 5 and the ONTIC test base framework.

Guide: Writing Unit & Integration Tests with JUnit 5 and Ontic Test Base


1. Unit Tests vs Integration Tests — What & Why

Aspect Unit Tests Integration Tests
Scope A single class or method in isolation Multiple modules or components working together, like DB, messaging, or external systems
Dependencies Mocked or stubbed, in-memory or fake Real dependencies, via containers, embedded DBs, and so on
Speed Very fast, milliseconds to tens of milliseconds Slower, hundreds of milliseconds to seconds
Purpose Test internal logic, edge cases, and algorithm correctness Validate integration, configuration, and boundary behavior
Flakiness risk Lower if well isolated Higher because of network, startup, and external state
Ideal usage ratio Many Some, but fewer than unit tests

When to choose which

  • Unit Test

    • The point of unit testing is logic correctness
    • It will not tell you how dependencies are interacting or how your object is getting saved
    • So if your goal is testing business logic, unit testing is best
    • But if your code also involves I/O-bound behavior like:
      • how an object is getting saved
      • database indexing behavior
      • aspect-based proxies
    • then mocking can become misleading
    • If you mock a service that has an aspect-based proxy, you bypass the proxy entirely
  • Integration Testing

    • For system testing, integration tests are best
    • They are slower, but they usually give a much more accurate depiction of how the code actually behaves
    • Of course you can rely only on integration tests, but sometimes you just want to test business logic, and in that case mocks are useful

2. Test Framework — Overview & Concepts

Architecture

Base test infrastructure:

  1. Container support for MongoDB, Elasticsearch, Redis, and planned Kafka
  2. Reusable containers to avoid repeated setup per test run
  3. Scoped setup tasks via TestSetupTask, global, suite, and test
  4. Annotation-driven config through @UseContainers and @WithSetup(...)
  5. Local DB support via @UseLocal
  6. Presets to choose between single-node or cluster topologies to better mirror production
  7. Gradle plugin ontic.test-base for test and integrationTest tasks

Scoped Setup Details

  • Global
    • Setup tasks for container bootstrapping, for example index templates
  • Suite
    • Per-class setup data
  • Test
    • Specific to the individual test method

@WithSetup allows linking setup logic with Spring-managed services. The framework handles lifecycle, wiring, and cleanup.

Container Reuse

  • The framework caches and reuses containers and global setups
  • Unless containers are explicitly killed, restarts are fast

Local Mode

  • Use annotations like @UseLocal to skip containers and run against a local DB
  • This improves speed for local development workflows

3. Guide: Writing Test Cases & Framework Usage

3.1 Unit Tests (JUnit 5)

These live under src/test/java and are executed via the test task.

Best Practices

  • Use descriptive names, for example shouldCalculateDiscount_whenCustomerIsPreferred()
  • Follow Arrange–Act–Assert
    • Collect — Test — Verify
  • Avoid external dependencies
  • Use mocks and fakes for isolation
  • Make tests deterministic and readable
class PromoServiceTest {

    @Mock
    private CustomerRepository customerRepo;

    @InjectMocks
    private PromoService promoService;

    @BeforeEach
    void setup() {
        MockitoAnnotations.openMocks(this);
    }

    @Test
    void shouldApplyDiscount_whenCustomerHasLoyalty() {
        Customer c = new Customer("Alice", 3);
        when(customerRepo.findById("id1")).thenReturn(Optional.of(c));

        double discount = promoService.calculateDiscount("id1", 100.0);

        assertEquals(10.0, discount);
    }

    @Test
    void shouldThrowException_whenCustomerNotFound() {
        when(customerRepo.findById("id2")).thenReturn(Optional.empty());

        assertThrows(NotFoundException.class,
            () -> promoService.calculateDiscount("id2", 50.0));
    }
}

3.2 Integration Tests (Using Framework)

These live under src/test/java and are executed via integrationTest.

Setup

@UseContainers
@WithSetup({ OrderSetupTask.class})
class OrderProcessingIntegrationTest extends ModuleBaseIntegrationTest {
    // Spring context loaded, containers running, setup executed
}

Local Mode

@UseLocal
class LocalDbOrderIntegrationTest extends IntegrationBaseTest {
    // No container startup
}

Example Test

@Test
@WithSetup(RequiresNewUserSetup.class)
@Tag("integration")
void testOrderLifecycle_fullFlow() {
    CreateOrderRequest req = new CreateOrderRequest(...);
    OrderResponse resp = restTemplate.postForObject("/api/orders", req, OrderResponse.class);
    assertNotNull(resp.getOrderId());

    await().untilAsserted(() -> {
        Order order = orderRepository.findById(resp.getOrderId()).orElseThrow();
        assertEquals(OrderStatus.CONFIRMED, order.getStatus());
    });

    List<OrderEvent> events = kafkaConsumer.fetchEvents(...);
    assertTrue(events.stream().anyMatch(e -> e.getType() == ORDER_CONFIRMED));
}

3.3 Reusable and Utility Classes

  • These live under src/testFixtures/java
  • All TestSetupTask classes should reside in testFixtures if you think they are going to be used in other modules

Sample Module Base Integration Test

@SpringJUnitConfig(classes = {CoreTestsConfig.class})
public class CoreNewIntegrationTest extends FwkBaseIntegrationTest {
}

Sample TestSetupTask

@Service
public class RedisDefaultConfigTestSetup implements TestSetupTask {

    private final ResourceConfigService resourceConfigService;

    public RedisDefaultConfigTestSetup(ResourceConfigService resourceConfigService) {
        this.resourceConfigService = resourceConfigService;
    }

    @Override
    public TestSetupTaskType type() {
        return CoreTestSetupTaskType.REDIS_SETUP;
    }

    @Override
    public SetupScope scope() {
        return SetupScope.GLOBAL;
    }

    @Override
    public void setup(TestSetupExecutionContext executionContext) {
        RunningContainer<?> redisContainer = executionContext.getRunningContainerMap().get(ResourceCategory.REDIS.name());
        TestSetupUtils.setupIfChanged(type().name(), ResourceCategory.REDIS.name(), redisContainer, this::ensureRedisConfigs);
    }

    private void ensureRedisConfigs(RunningContainer<?> runningContainer) {
        final List<RedisContainer> redisContainers = (List<RedisContainer>) runningContainer.containers();
        RedisContainer redisContainer = OnticCollectionUtils.firstElement(redisContainers);
        ensureGenericTenantConfig(FwkConstants.Redis.BROADCAST, new Host(redisContainer.getHost(), redisContainer.getFirstMappedPort()));
        ensureGenericTenantConfig(FwkConstants.Redis.CACHE, new Host(redisContainer.getHost(), redisContainer.getFirstMappedPort()));
        ensureGenericTenantConfig(FwkConstants.Redis.KEYSTORE, new Host(redisContainer.getHost(), redisContainer.getFirstMappedPort()));
    }

    private void ensureGenericTenantConfig(String resourceType, Host host) {
        RedisConfig redisConfig1 = new RedisConfig();
        redisConfig1.setOrgId(FwkConstants.GLOBAL_ORG_ID);
        redisConfig1.setResourceType(resourceType);
        redisConfig1.setResourceCategory(ResourceCategory.REDIS.name());
        redisConfig1.setSpaceId(FwkConstants.GLOBAL_SPACE_ID);
        redisConfig1.setClusterMode(false);
        List<Host> hostList = new ArrayList<>();
        hostList = new ArrayList<>();
        hostList.add(host);
        redisConfig1.setHosts(hostList);
        resourceConfigService.createConfig(redisConfig1);
    }
}

3.4 Gradle Configuration

  • Consume fixtures from other modules using the testFixtures configuration to avoid duplication and keep test code DRY
  • Use other modules' test fixtures based on your need
  • Do not copy-paste other Gradle files
  • Consumer module must contain the ontic.test-base plugin in the Gradle file to include standard Gradle tasks
  • Use:
    • ./gradlew test for unit tests
    • ./gradlew integrationTest for integration tests
  • All required dependencies will be included by default by adding other modules' test fixtures dependency
  • No need to add every library explicitly

Gradle

plugins {
    id 'ontic.java-base'
    id 'ontic.test-base'
}

dependencies {
    testFixturesApi(testFixtures(project(':core')))
}

4. Best Practices & Suggestions

  • Use meaningful test names
  • Group related tests into suites
  • Clean up test data properly
  • Avoid global state unless isolated by container instance
  • Prefer singleton containers for reuse across suites
  • For new containers, implement ContainerProvider

5. Good Articles to Read