Skip to main content

Testing Framework

The backend uses a comprehensive testing stack:
  • JUnit 5 - Test framework with @Test, @BeforeEach, etc.
  • Spring Boot Test - Integration testing support
  • Mockito - Mocking framework for unit tests
  • AssertJ - Fluent assertions
  • Testcontainers - Dockerized PostgreSQL for integration tests
  • WebTestClient - Reactive HTTP client for E2E tests

Test Structure

Tests are organized by type:
src/test/java/com/productdistribution/backend/
├── unit/                           # Unit tests
│   ├── controllers/               # Controller tests with mocks
│   ├── services/                  # Service tests with mocks
│   ├── mappers/                   # MapStruct mapper tests
│   ├── entities/                  # Entity logic tests
│   └── repositories/              # Repository query tests
├── integration/                    # Integration tests
│   ├── services/                  # Service integration tests
│   └── repositories/              # Repository with real DB
└── e2e/                           # End-to-end tests
    └── controllers/               # Full stack API tests

Running Tests

Run All Tests

mvn test

Run Specific Test Class

mvn test -Dtest=ProductServiceTest

Run Specific Test Method

mvn test -Dtest=ProductServiceTest#getProductById_whenProductExists_shouldReturnProduct

Run Tests by Category

# Unit tests only
mvn test -Dtest="**/unit/**/*Test"

# Integration tests only
mvn test -Dtest="**/integration/**/*Test"

# E2E tests only
mvn test -Dtest="**/e2e/**/*Test"

Run with Coverage

mvn clean test jacoco:report
View report: target/site/jacoco/index.html

Skip Tests During Build

mvn clean package -DskipTests

Unit Testing

Unit tests focus on individual components in isolation using mocks.

Service Unit Test Example

File: unit/services/DistributionServiceUnitTest.java:27
@ExtendWith(MockitoExtension.class)
class DistributionServiceUnitTest {

    @Mock
    private ApplicationContext applicationContext;
    
    @Mock
    private StoreService storeService;
    
    @Mock
    private WarehouseService warehouseService;
    
    @Mock
    private ProductService productService;
    
    @Mock
    private WarehouseSelectionStrategy warehouseSelectionStrategy;
    
    @InjectMocks
    private DistributionService distributionService;
    
    @BeforeEach
    void setUp() {
        ReflectionTestUtils.setField(distributionService, "strategyName", "distanceOnlyStrategy");
    }
    
    @Test
    void distributeProducts_shouldReturnStockAssignments() {
        // Arrange
        Store store = StoreBuilder.store1();
        Warehouse warehouse = WarehouseBuilder.warehouse1();
        List<Store> stores = List.of(store);
        List<Warehouse> warehouses = List.of(warehouse);
        
        when(storeService.refreshStoresFromJson()).thenReturn(stores);
        when(warehouseService.refreshWarehousesFromJson()).thenReturn(warehouses);
        when(warehouseSelectionStrategy.selectWarehouses(any(), any(), any(), any()))
            .thenReturn(List.of(new WarehouseWithDistance(warehouse, 100.0)));
        
        // Act
        List<StockAssignment> result = distributionService.distributeProducts();
        
        // Assert
        assertThat(result).isNotNull().hasSize(2);
        verify(storeService).refreshStoresFromJson();
        verify(warehouseService).refreshWarehousesFromJson();
    }
}

Key Annotations

  • @ExtendWith(MockitoExtension.class) - Enable Mockito
  • @Mock - Create mock object
  • @InjectMocks - Inject mocks into this object
  • @BeforeEach - Run before each test
  • @Test - Mark as test method

Mockito Patterns

Stubbing:
// Return value
when(productRepository.findById("P1")).thenReturn(Optional.of(product));

// Throw exception
when(productRepository.findById("BAD")).thenThrow(new RuntimeException());

// Return different values on subsequent calls
when(service.getValue())
    .thenReturn(1)
    .thenReturn(2)
    .thenReturn(3);
Verification:
// Verify method called
verify(productRepository).save(product);

// Verify method called N times
verify(productRepository, times(2)).findById(anyString());

// Verify never called
verify(productRepository, never()).delete(any());

// Verify with argument matchers
verify(productRepository).save(argThat(p -> p.getId().equals("P1")));

AssertJ Assertions

// Basic assertions
assertThat(result).isNotNull();
assertThat(result).isEqualTo(expected);
assertThat(products).hasSize(3);
assertThat(products).isEmpty();

// Collection assertions
assertThat(products)
    .hasSize(2)
    .contains(product1, product2)
    .extracting(Product::getId)
    .containsExactly("P1", "P2");

// Recursive comparison (ignores specific fields)
assertThat(actual)
    .usingRecursiveComparison()
    .ignoringFields("id", "createdAt")
    .isEqualTo(expected);

// Exception assertions
assertThatThrownBy(() -> service.getProduct("BAD"))
    .isInstanceOf(ResourceNotFoundException.class)
    .hasMessageContaining("not found");

Integration Testing

Integration tests use a real database (Testcontainers) and Spring context.

Repository Integration Test

File: integration/repositories/ProductRepositoryIntegrationTest.java:20
@DataJpaTest
@ActiveProfiles("test")
class ProductRepositoryIntegrationTest {

    @Autowired
    private ProductRepository productRepository;

    @Test
    void save_shouldPersistProduct() {
        // Arrange
        Product product = ProductBuilder.product1();

        // Act
        Product saved = productRepository.save(product);

        // Assert
        Optional<Product> found = productRepository.findById(saved.getId());
        assertThat(found).isPresent();
        assertThat(found.get())
            .usingRecursiveComparison()
            .isEqualTo(product);
    }
    
    @Test
    void findAll_whenProductsExist_shouldReturnAllProducts() {
        // Arrange
        List<Product> products = List.of(
            ProductBuilder.product1(), 
            ProductBuilder.product2()
        );
        productRepository.saveAll(products);

        // Act
        List<Product> found = productRepository.findAll();

        // Assert
        assertThat(found)
            .usingRecursiveComparison()
            .isEqualTo(products);
    }
}

Key Annotations

  • @DataJpaTest - Configure JPA test slice
  • @ActiveProfiles("test") - Use test configuration
  • @Autowired - Inject real Spring beans
  • @Transactional - Automatic rollback after each test

Test Configuration

File: src/test/resources/application-test.properties:13
# Testcontainers PostgreSQL
spring.datasource.url=jdbc:tc:postgresql:16-alpine:///testdb

# Disable cache in tests
spring.cache.type=none

# Disable schedulers
spring.task.scheduling.enabled=false

# Disable actuator endpoints
management.endpoints.enabled-by-default=false
The jdbc:tc: prefix automatically starts a PostgreSQL container.

Testcontainers

Testcontainers provides lightweight, throwaway PostgreSQL instances.

Configuration

Dependency in pom.xml:
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.21.4</version>
    <scope>test</scope>
</dependency>

How It Works

  1. Test starts → Testcontainers detects jdbc:tc: URL
  2. Container launches → PostgreSQL 16 Alpine image pulled/started
  3. Flyway runs → Database schema created from migrations
  4. Tests execute → Each test runs in a transaction (auto-rollback)
  5. Test completes → Container stopped and removed

Benefits

  • Real database - Tests against actual PostgreSQL
  • Isolated - Each test run uses fresh database
  • Fast - Containers reused within test run
  • No manual setup - No local PostgreSQL required
  • CI/CD friendly - Works anywhere Docker is available

Custom Container Configuration

For more control, create a custom container:
@Testcontainers
class CustomRepositoryTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
}

End-to-End Testing

E2E tests validate full request/response cycles through REST API.

Controller E2E Test

File: e2e/controllers/ProductControllerE2ETest.java:22
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles({"test", "e2e"})
@AutoConfigureWebTestClient
@Transactional
class ProductControllerE2ETest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void getAllProducts_shouldReturn200WithProductList() {
        webTestClient.get()
            .uri("/api/products")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().contentType("application/json")
            .expectBodyList(ProductDTO.class)
            .value(products -> {
                assertThat(products).hasSize(3);
                assertThat(products)
                    .usingRecursiveComparison()
                    .ignoringCollectionOrder()
                    .isEqualTo(expectedProducts);
            });
    }
    
    @Test
    void getProductById_whenProductNotFound_shouldReturn404() {
        webTestClient.get()
            .uri("/api/products/{id}", "NON_EXISTENT")
            .exchange()
            .expectStatus().isNotFound()
            .expectBody()
            .jsonPath("$.statusCode").isEqualTo(404)
            .jsonPath("$.message").isEqualTo("Product with id 'NON_EXISTENT' not found")
            .jsonPath("$.path").isEqualTo("/api/products/NON_EXISTENT");
    }
}

Key Annotations

  • @SpringBootTest(webEnvironment = RANDOM_PORT) - Start full server on random port
  • @AutoConfigureWebTestClient - Configure WebTestClient
  • @Transactional - Rollback database changes after test

WebTestClient Patterns

GET Request:
webTestClient.get()
    .uri("/api/products/{id}", "P1")
    .header("Authorization", "Bearer token")
    .exchange()
    .expectStatus().isOk()
    .expectBody(ProductDTO.class)
    .value(product -> assertThat(product.id()).isEqualTo("P1"));
POST Request:
webTestClient.post()
    .uri("/api/products")
    .contentType(MediaType.APPLICATION_JSON)
    .bodyValue(newProduct)
    .exchange()
    .expectStatus().isCreated()
    .expectHeader().exists("Location");
JSON Path Assertions:
webTestClient.get()
    .uri("/api/metrics")
    .exchange()
    .expectBody()
    .jsonPath("$.totalShipments").isEqualTo(100)
    .jsonPath("$.averageDistance").isNumber()
    .jsonPath("$.warehouses[0].id").isEqualTo("W1")
    .jsonPath("$.warehouses[*].id").value(hasSize(5));

Code Coverage with JaCoCo

JaCoCo tracks test coverage and generates reports.

Maven Configuration

In pom.xml:
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Generate Coverage Report

mvn clean test jacoco:report

View Report

Open in browser:
open target/site/jacoco/index.html

Coverage Metrics

  • Instructions - Bytecode instructions executed
  • Branches - If/else branches covered
  • Lines - Source code lines executed
  • Methods - Methods invoked
  • Classes - Classes loaded

SonarQube Integration

Project configured for SonarCloud analysis:
<properties>
    <sonar.host.url>https://sonarcloud.io</sonar.host.url>
    <sonar.organization>raulherediahorcajo</sonar.organization>
    <sonar.projectKey>raulHerediaHorcajo_product-distribution</sonar.projectKey>
    <sonar.coverage.jacoco.xmlReportPaths>target/site/jacoco/jacoco.xml</sonar.coverage.jacoco.xmlReportPaths>
</properties>

Test Data Builders

Test builders create consistent test data.

Builder Pattern Example

public class ProductBuilder {
    
    public static Product product1() {
        return builder()
            .withId("P1")
            .withBrandId("B1")
            .withSizes(List.of("S", "M", "L"))
            .build();
    }
    
    public static Builder builder() {
        return new Builder();
    }
    
    public static class Builder {
        private String id = "P1";
        private String brandId = "B1";
        private List<String> sizes = List.of("M");
        
        public Builder withId(String id) {
            this.id = id;
            return this;
        }
        
        public Builder withBrandId(String brandId) {
            this.brandId = brandId;
            return this;
        }
        
        public Builder withSizes(List<String> sizes) {
            this.sizes = sizes;
            return this;
        }
        
        public Product build() {
            Product product = new Product();
            product.setId(id);
            product.setBrandId(brandId);
            product.setSizes(sizes);
            return product;
        }
    }
}

Usage

// Use predefined test data
Product product = ProductBuilder.product1();

// Customize specific fields
Product customProduct = ProductBuilder.builder()
    .withId("CUSTOM")
    .withSizes(List.of("XL", "XXL"))
    .build();

Best Practices

Test Naming

Use descriptive names that express intent:
// Good
void getProductById_whenProductExists_shouldReturnProduct()
void getProductById_whenProductNotFound_shouldThrowException()

// Bad
void testGetProduct()
void test1()

Arrange-Act-Assert Pattern

@Test
void exampleTest() {
    // Arrange - Set up test data and mocks
    Product product = ProductBuilder.product1();
    when(repository.findById("P1")).thenReturn(Optional.of(product));
    
    // Act - Execute the method under test
    Product result = service.getProductById("P1");
    
    // Assert - Verify the outcome
    assertThat(result).isEqualTo(product);
}

Test Independence

Each test should be independent and repeatable:
// Good - Test has its own data
@Test
void test1() {
    Product product = ProductBuilder.product1();
    repository.save(product);
    // ...
}

// Bad - Tests share state
private Product sharedProduct;

@BeforeEach
void setUp() {
    sharedProduct = ProductBuilder.product1();
}

Test One Thing

Each test should verify a single behavior:
// Good - Focused test
@Test
void save_shouldPersistProduct() {
    Product product = ProductBuilder.product1();
    Product saved = repository.save(product);
    assertThat(saved.getId()).isNotNull();
}

// Bad - Testing too much
@Test
void testEverything() {
    // save, update, delete, query all in one test
}

Mock External Dependencies Only

Unit tests should mock external dependencies, not internal logic:
// Good - Mock repository (external dependency)
@Mock
private ProductRepository productRepository;

// Bad - Don't mock the class under test
@Mock
private ProductService productService;

Common Patterns

Testing Exceptions

@Test
void getProduct_whenNotFound_shouldThrowException() {
    when(repository.findById("BAD")).thenReturn(Optional.empty());
    
    assertThatThrownBy(() -> service.getProductById("BAD"))
        .isInstanceOf(ResourceNotFoundException.class)
        .hasMessageContaining("Product with id 'BAD' not found");
}

Testing Void Methods

@Test
void deleteProduct_shouldCallRepository() {
    service.deleteProduct("P1");
    
    verify(repository).deleteById("P1");
}

Testing Collections

@Test
void getAllProducts_shouldReturnSortedList() {
    List<Product> products = service.getAllProducts();
    
    assertThat(products)
        .isSortedAccordingTo(Comparator.comparing(Product::getId))
        .extracting(Product::getId)
        .containsExactly("P1", "P2", "P3");
}

Continuous Integration

Tests run automatically in CI/CD pipelines:
# GitHub Actions example
name: Build
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-java@v3
        with:
          java-version: '17'
      - name: Run tests
        run: mvn test
      - name: Generate coverage
        run: mvn jacoco:report

Build docs developers (and LLMs) love