DEV Community

Aliaksandr Tsviatkou
Aliaksandr Tsviatkou

Posted on

Testing Strategies for SAP Commerce Projects: Unit, Integration, and End-to-End

Testing SAP Commerce code presents unique challenges. The platform's deep dependency injection, tight coupling to the type system, and reliance on database-backed models mean that standard unit testing practices from vanilla Spring Boot don't translate directly. Many teams end up with either no tests or tests that boot the entire platform for every assertion — both approaches fail at scale.

This article covers practical testing strategies that work for real SAP Commerce projects: unit tests with proper mocking, integration tests against the running platform, Solr search testing, ImpEx validation, and end-to-end testing of OCC APIs.

Unit Testing

Unit tests are the foundation. They test individual classes in isolation by mocking all dependencies.

Testing a Populator

Populators are the most unit-testable code in SAP Commerce. They transform model objects into data objects.

public class LoyaltyPointsPopulator implements Populator<CustomerModel, CustomerData> {

    private LoyaltyService loyaltyService;

    @Override
    public void populate(CustomerModel source, CustomerData target) {
        if (source == null || target == null) {
            throw new ConversionException("Source or target is null");
        }

        LoyaltyAccount account = loyaltyService.getAccountForCustomer(source);
        if (account != null) {
            target.setLoyaltyPoints(account.getPoints());
            target.setLoyaltyTier(account.getTier().name());
            target.setPointsToNextTier(calculatePointsToNextTier(account));
        }
    }

    private int calculatePointsToNextTier(LoyaltyAccount account) {
        Map<String, Integer> thresholds = Map.of(
            "BRONZE", 1000, "SILVER", 5000, "GOLD", 20000, "PLATINUM", Integer.MAX_VALUE
        );
        int nextThreshold = thresholds.getOrDefault(account.getTier().name(), Integer.MAX_VALUE);
        return Math.max(0, nextThreshold - account.getPoints());
    }

    // Setter for injection
    public void setLoyaltyService(LoyaltyService loyaltyService) {
        this.loyaltyService = loyaltyService;
    }
}
Enter fullscreen mode Exit fullscreen mode

Test:

@UnitTest
@RunWith(MockitoJUnitRunner.class)
public class LoyaltyPointsPopulatorTest {

    @InjectMocks
    private LoyaltyPointsPopulator populator;

    @Mock
    private LoyaltyService loyaltyService;

    @Mock
    private CustomerModel customerModel;

    private CustomerData customerData;

    @Before
    public void setUp() {
        customerData = new CustomerData();
    }

    @Test
    public void shouldPopulateLoyaltyPoints() {
        // Given
        LoyaltyAccount account = new LoyaltyAccount();
        account.setPoints(3500);
        account.setTier(LoyaltyTier.SILVER);

        when(loyaltyService.getAccountForCustomer(customerModel)).thenReturn(account);

        // When
        populator.populate(customerModel, customerData);

        // Then
        assertEquals(3500, customerData.getLoyaltyPoints());
        assertEquals("SILVER", customerData.getLoyaltyTier());
        assertEquals(1500, customerData.getPointsToNextTier()); // 5000 - 3500
    }

    @Test
    public void shouldHandleNullLoyaltyAccount() {
        when(loyaltyService.getAccountForCustomer(customerModel)).thenReturn(null);

        populator.populate(customerModel, customerData);

        assertEquals(0, customerData.getLoyaltyPoints());
        assertNull(customerData.getLoyaltyTier());
    }

    @Test(expected = ConversionException.class)
    public void shouldThrowOnNullSource() {
        populator.populate(null, customerData);
    }

    @Test(expected = ConversionException.class)
    public void shouldThrowOnNullTarget() {
        populator.populate(customerModel, null);
    }

    @Test
    public void shouldCalculatePointsToNextTierForBronze() {
        LoyaltyAccount account = new LoyaltyAccount();
        account.setPoints(250);
        account.setTier(LoyaltyTier.BRONZE);

        when(loyaltyService.getAccountForCustomer(customerModel)).thenReturn(account);

        populator.populate(customerModel, customerData);

        assertEquals(750, customerData.getPointsToNextTier()); // 1000 - 250
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing a Validator

public class OrderQuantityValidator implements Validator {

    private static final int MAX_QUANTITY = 999;
    private StockService stockService;

    @Override
    public boolean supports(Class<?> clazz) {
        return AddToCartParams.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        AddToCartParams params = (AddToCartParams) target;

        if (params.getQuantity() <= 0) {
            errors.rejectValue("quantity", "cart.quantity.invalid",
                "Quantity must be greater than zero");
        }

        if (params.getQuantity() > MAX_QUANTITY) {
            errors.rejectValue("quantity", "cart.quantity.exceeded",
                new Object[]{MAX_QUANTITY}, "Maximum quantity is {0}");
        }

        // Check stock availability
        Long available = stockService.getAvailableStock(params.getProductCode());
        if (available != null && params.getQuantity() > available) {
            errors.rejectValue("quantity", "cart.quantity.nostock",
                new Object[]{available}, "Only {0} items available");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Test:

@UnitTest
@RunWith(MockitoJUnitRunner.class)
public class OrderQuantityValidatorTest {

    @InjectMocks
    private OrderQuantityValidator validator;

    @Mock
    private StockService stockService;

    private AddToCartParams params;
    private BeanPropertyBindingResult errors;

    @Before
    public void setUp() {
        params = new AddToCartParams();
        params.setProductCode("PROD-001");
        params.setQuantity(1);
        errors = new BeanPropertyBindingResult(params, "params");
    }

    @Test
    public void shouldAcceptValidQuantity() {
        when(stockService.getAvailableStock("PROD-001")).thenReturn(100L);

        validator.validate(params, errors);

        assertFalse(errors.hasErrors());
    }

    @Test
    public void shouldRejectZeroQuantity() {
        params.setQuantity(0);

        validator.validate(params, errors);

        assertTrue(errors.hasFieldErrors("quantity"));
        assertEquals("cart.quantity.invalid", 
            errors.getFieldError("quantity").getCode());
    }

    @Test
    public void shouldRejectNegativeQuantity() {
        params.setQuantity(-5);

        validator.validate(params, errors);

        assertTrue(errors.hasFieldErrors("quantity"));
    }

    @Test
    public void shouldRejectQuantityExceedingMax() {
        params.setQuantity(1000);
        when(stockService.getAvailableStock("PROD-001")).thenReturn(5000L);

        validator.validate(params, errors);

        assertTrue(errors.hasFieldErrors("quantity"));
        assertEquals("cart.quantity.exceeded",
            errors.getFieldError("quantity").getCode());
    }

    @Test
    public void shouldRejectQuantityExceedingStock() {
        params.setQuantity(10);
        when(stockService.getAvailableStock("PROD-001")).thenReturn(5L);

        validator.validate(params, errors);

        assertTrue(errors.hasFieldErrors("quantity"));
        assertEquals("cart.quantity.nostock",
            errors.getFieldError("quantity").getCode());
    }

    @Test
    public void shouldAllowWhenStockIsNull() {
        // Null stock means stock tracking is disabled
        params.setQuantity(50);
        when(stockService.getAvailableStock("PROD-001")).thenReturn(null);

        validator.validate(params, errors);

        assertFalse(errors.hasErrors());
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing FlexibleSearch Queries

FlexibleSearch queries need integration tests because they depend on the type system and database schema.

@IntegrationTest
public class ProductQueryServiceIntegrationTest extends ServicelayerTransactionalTest {

    @Resource
    private ProductQueryService productQueryService;

    @Resource
    private CatalogVersionService catalogVersionService;

    @Before
    public void setUp() throws Exception {
        importCsv("/test/testdata/product-query-test-data.impex", "utf-8");
    }

    @Test
    public void shouldFindProductsByPriceRange() {
        CatalogVersionModel cv = catalogVersionService.getCatalogVersion("testCatalog", "Online");

        List<ProductModel> results = productQueryService.findByPriceRange(
            cv, BigDecimal.valueOf(50), BigDecimal.valueOf(150));

        assertEquals(2, results.size());
        assertTrue(results.stream()
            .allMatch(p -> p.getCode().startsWith("TEST-PROD")));
    }

    @Test
    public void shouldReturnEmptyForNonMatchingRange() {
        CatalogVersionModel cv = catalogVersionService.getCatalogVersion("testCatalog", "Online");

        List<ProductModel> results = productQueryService.findByPriceRange(
            cv, BigDecimal.valueOf(10000), BigDecimal.valueOf(20000));

        assertTrue(results.isEmpty());
    }

    @Test
    public void shouldFindRecentlyModifiedProducts() {
        CatalogVersionModel cv = catalogVersionService.getCatalogVersion("testCatalog", "Online");

        // Modify a product
        ProductModel product = productService.getProductForCode(cv, "TEST-PROD-001");
        product.setDescription("Updated description");
        modelService.save(product);

        // Query for recently modified
        List<ProductModel> results = productQueryService.findModifiedSince(
            cv, Date.from(Instant.now().minus(1, ChronoUnit.HOURS)));

        assertFalse(results.isEmpty());
        assertTrue(results.stream()
            .anyMatch(p -> "TEST-PROD-001".equals(p.getCode())));
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing ImpEx Scripts

ImpEx scripts are code — they should be tested too.

@IntegrationTest
public class ProductImportImpExTest extends ServicelayerTransactionalTest {

    @Resource
    private ProductService productService;

    @Resource
    private CatalogVersionService catalogVersionService;

    @Resource
    private ImportService importService;

    @Test
    public void shouldImportProductsSuccessfully() {
        // Given
        ImportConfig config = new ImportConfig();
        config.setScript(
            new StreamBasedImpExResource(
                getClass().getResourceAsStream("/import/products.impex"),
                "utf-8"
            )
        );
        config.setSynchronous(true);
        config.setFailOnError(true);

        // When
        ImportResult result = importService.importData(config);

        // Then
        assertTrue("Import should succeed", result.isSuccessful());
        assertFalse("Should have no unresolved lines", result.hasUnresolvedLines());

        // Verify imported data
        CatalogVersionModel cv = catalogVersionService
            .getCatalogVersion("myProductCatalog", "Staged");
        ProductModel product = productService.getProductForCode(cv, "CAM-001");

        assertNotNull("Product should exist", product);
        assertEquals("Professional DSLR", product.getName());
    }

    @Test
    public void shouldHandleDuplicateProductCodes() {
        // Import same file twice — INSERT_UPDATE should handle it
        ImportConfig config = new ImportConfig();
        config.setScript(new StreamBasedImpExResource(
            getClass().getResourceAsStream("/import/products.impex"), "utf-8"));
        config.setSynchronous(true);

        // First import
        ImportResult result1 = importService.importData(config);
        assertTrue(result1.isSuccessful());

        // Second import (should update, not fail)
        config.setScript(new StreamBasedImpExResource(
            getClass().getResourceAsStream("/import/products.impex"), "utf-8"));
        ImportResult result2 = importService.importData(config);
        assertTrue("Re-import should succeed with INSERT_UPDATE", result2.isSuccessful());
    }
}
Enter fullscreen mode Exit fullscreen mode

CI/CD Integration

Jenkins Pipeline

pipeline {
    agent any

    stages {
        stage('Build') {
            steps {
                sh 'ant clean all'
            }
        }

        stage('Unit Tests') {
            steps {
                sh 'ant unittests -Dtestclasses.packages=com.mycompany.*'
            }
            post {
                always {
                    junit '**/testresults/junit/*.xml'
                }
            }
        }

        stage('Integration Tests') {
            steps {
                sh 'ant integrationtests -Dtestclasses.packages=com.mycompany.*'
            }
            post {
                always {
                    junit '**/testresults/junit/*.xml'
                }
            }
        }

        stage('API Tests') {
            steps {
                sh 'ant integrationtests -Dtestclasses.packages=com.mycompany.*.api.*'
            }
        }
    }

    post {
        always {
            publishHTML(target: [
                reportDir: 'log/junit',
                reportFiles: 'index.html',
                reportName: 'Test Report'
            ])
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary

A well-tested SAP Commerce project needs tests at every level:

  1. Unit tests for business logic, populators, validators — fast, no platform required
  2. Integration tests for services, FlexibleSearch, and ImpEx — running against the platform with transactional rollback
  3. API tests for OCC endpoints — verifying the full HTTP request/response cycle
  4. ImpEx tests for data import scripts — ensuring imports are idempotent and produce correct data
  5. Automated in CI/CD — unit tests on every commit, integration tests on PRs, full suites before deployment

The teams that invest in testing spend less time debugging production issues and more time building features. The platform's complexity makes testing harder, but it also makes testing more valuable — there are simply more places where things can go wrong.

Top comments (0)