Testing and Debugging Plugins

Level: Advanced Module: Plugin Development 16 min read Lesson 33 of 47

Overview

  • What you’ll learn:
    • How to set up JUnit testing in the OSGi environment and write unit tests for iDempiere model classes and business logic
    • How to use the Eclipse debugger effectively with breakpoints, conditional breakpoints, and remote debugging for iDempiere plugins
    • How to leverage iDempiere’s CLogger framework, analyze logs, and diagnose common issues such as PO lifecycle problems, transaction leaks, and cache inconsistencies
  • Prerequisites: Lesson 15 — Building Your First Plugin, Lesson 2 — iDempiere Architecture Overview
  • Estimated reading time: 24 minutes

Introduction

Plugins that work flawlessly in development but fail in production cause real business damage — incorrect invoices, lost inventory records, stalled workflows. The difference between a hobby plugin and a production-grade plugin is rigorous testing and systematic debugging. iDempiere’s OSGi architecture adds complexity to both disciplines: tests must run inside an OSGi container to access iDempiere services, and debugging requires understanding the multi-bundle class loading and event-driven architecture.

This lesson equips you with practical techniques for testing and debugging iDempiere plugins. You will learn how to write JUnit tests that execute within the OSGi environment, use the Eclipse debugger to trace complex issues, configure and analyze logs effectively, and diagnose the most common categories of plugin bugs.

JUnit Testing in the OSGi Environment

Testing iDempiere plugins is not as simple as writing standard JUnit tests. Because your plugin code depends on OSGi services, the Application Dictionary, and database access, your tests must run inside a properly initialized iDempiere environment.

Setting Up a Test Bundle

Create a separate test plugin project (e.g., com.example.plugin.test) that contains your test classes. This keeps test code out of your production bundle:

  1. Create a new Plug-in Project: com.example.plugin.test
  2. Add dependencies in MANIFEST.MF:
Require-Bundle: org.adempiere.base;bundle-version="11.0.0",
 com.example.plugin;bundle-version="1.0.0",
 org.junit;bundle-version="4.13"
Fragment-Host: com.example.plugin

The Fragment-Host directive attaches your test bundle as a fragment of the plugin bundle, giving test classes access to the plugin’s internal (non-exported) packages. Alternatively, if you only test the plugin’s public API, you can use Import-Package instead.

Initializing the Test Environment

iDempiere tests require a database connection and an initialized environment context. Create a base test class that sets this up:

import org.compiere.model.*;
import org.compiere.util.*;
import org.junit.*;

public abstract class AbstractIDempiereTest {

    protected static Properties ctx;
    protected static String trxName;

    @BeforeClass
    public static void setUpClass() {
        // Initialize iDempiere environment
        org.compiere.Adempiere.startup(false); // false = not a client (headless)

        ctx = Env.getCtx();

        // Set context for GardenWorld test data
        Env.setContext(ctx, "#AD_Client_ID", 11);      // GardenWorld
        Env.setContext(ctx, "#AD_Org_ID", 11);          // HQ
        Env.setContext(ctx, "#AD_Role_ID", 102);        // GardenWorld Admin
        Env.setContext(ctx, "#AD_User_ID", 101);        // GardenAdmin
        Env.setContext(ctx, "#M_Warehouse_ID", 103);    // HQ Warehouse
        Env.setContext(ctx, "#AD_Language", "en_US");
    }

    @Before
    public void setUp() {
        // Create a fresh transaction for each test
        trxName = Trx.createTrxName("Test");
        Trx.get(trxName, true);
    }

    @After
    public void tearDown() {
        // Rollback test transaction to leave database clean
        Trx trx = Trx.get(trxName, false);
        if (trx != null) {
            trx.rollback();
            trx.close();
        }
    }
}

The key insight is the @After method that rolls back the transaction. This ensures every test starts with a clean database state, regardless of what the test creates, modifies, or deletes. Tests are isolated from each other and do not leave behind test data.

Unit Testing Model Classes

Test your custom model classes by exercising their business logic within the test transaction:

public class MCustomDocumentTest extends AbstractIDempiereTest {

    @Test
    public void testCreateDocument() {
        MCustomDocument doc = new MCustomDocument(ctx, 0, trxName);
        doc.setAD_Org_ID(11);
        doc.setValue("TEST-001");
        doc.setName("Test Document");
        doc.setDateDoc(new Timestamp(System.currentTimeMillis()));

        assertTrue("Document should save successfully", doc.save());
        assertTrue("Document should have an ID", doc.get_ID() > 0);
        assertEquals("TEST-001", doc.getValue());
    }

    @Test
    public void testDocumentValidation() {
        MCustomDocument doc = new MCustomDocument(ctx, 0, trxName);
        doc.setAD_Org_ID(11);
        // Intentionally omit required field (Value)
        doc.setName("Test Document Without Value");

        assertFalse("Document without Value should fail validation", doc.save());
    }

    @Test
    public void testBusinessLogicCalculation() {
        MCustomDocument doc = createTestDocument();

        // Add lines
        MCustomDocumentLine line1 = new MCustomDocumentLine(doc);
        line1.setQty(new BigDecimal("10"));
        line1.setPrice(new BigDecimal("25.00"));
        assertTrue(line1.save());

        MCustomDocumentLine line2 = new MCustomDocumentLine(doc);
        line2.setQty(new BigDecimal("5"));
        line2.setPrice(new BigDecimal("50.00"));
        assertTrue(line2.save());

        // Test calculation
        doc.calculateTotals();
        assertEquals("Grand total should be 500.00",
            new BigDecimal("500.00"), doc.getGrandTotal());
    }

    @Test
    public void testDocumentCompletion() {
        MCustomDocument doc = createTestDocument();
        addTestLines(doc);

        // Test document processing
        boolean success = doc.processIt(DocAction.ACTION_Complete);
        assertTrue("Document should complete successfully", success);
        assertEquals("CO", doc.getDocStatus());
    }

    @Test(expected = AdempiereException.class)
    public void testCannotCompleteEmptyDocument() {
        MCustomDocument doc = createTestDocument();
        // No lines added
        doc.processIt(DocAction.ACTION_Complete);
        // Should throw AdempiereException
    }

    private MCustomDocument createTestDocument() {
        MCustomDocument doc = new MCustomDocument(ctx, 0, trxName);
        doc.setAD_Org_ID(11);
        doc.setValue("TEST-" + System.currentTimeMillis());
        doc.setName("Test Document");
        doc.setDateDoc(new Timestamp(System.currentTimeMillis()));
        doc.saveEx(); // Use saveEx to throw exception on failure
        return doc;
    }

    private void addTestLines(MCustomDocument doc) {
        MCustomDocumentLine line = new MCustomDocumentLine(doc);
        line.setQty(BigDecimal.TEN);
        line.setPrice(new BigDecimal("100.00"));
        line.saveEx();
    }
}

Mocking iDempiere Services

For pure unit tests that should not touch the database, you can mock iDempiere services. However, this is challenging due to the tight coupling between model classes and the database. Practical strategies include:

  • Extract business logic into plain Java classes that receive data as parameters rather than querying the database directly. These classes can be tested with standard mocking frameworks (Mockito).
  • Use the transaction-rollback pattern (shown above) for integration tests that need real database interaction. This is the most common and reliable approach in the iDempiere ecosystem.
  • Create test helper classes that generate commonly needed test data (business partners, products, orders) to reduce boilerplate in individual tests.
// Extracting testable logic into a plain Java class
public class PricingCalculator {

    /**
     * Calculate line total with discount.
     * Pure business logic — no database dependency.
     */
    public BigDecimal calculateLineTotal(BigDecimal qty, BigDecimal price,
                                          BigDecimal discountPercent) {
        if (qty == null || price == null) return BigDecimal.ZERO;
        BigDecimal gross = qty.multiply(price);
        if (discountPercent != null && discountPercent.compareTo(BigDecimal.ZERO) > 0) {
            BigDecimal discount = gross.multiply(discountPercent)
                .divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
            return gross.subtract(discount);
        }
        return gross;
    }
}

// Test for the extracted logic — no database needed
public class PricingCalculatorTest {

    private PricingCalculator calculator = new PricingCalculator();

    @Test
    public void testSimpleLineTotal() {
        BigDecimal result = calculator.calculateLineTotal(
            new BigDecimal("10"), new BigDecimal("25.00"), null);
        assertEquals(new BigDecimal("250.00"), result);
    }

    @Test
    public void testLineTotalWithDiscount() {
        BigDecimal result = calculator.calculateLineTotal(
            new BigDecimal("10"), new BigDecimal("100.00"), new BigDecimal("10"));
        assertEquals(new BigDecimal("900.00"), result);
    }

    @Test
    public void testNullQuantity() {
        BigDecimal result = calculator.calculateLineTotal(
            null, new BigDecimal("25.00"), null);
        assertEquals(BigDecimal.ZERO, result);
    }
}

Integration Testing Strategies

Integration tests verify that your plugin works correctly within the full iDempiere stack. These tests are slower but catch issues that unit tests miss.

Testing Model Validators

@Test
public void testModelValidatorFiresOnSave() {
    // Create a product that should trigger our custom validator
    MProduct product = new MProduct(ctx, 0, trxName);
    product.setAD_Org_ID(11);
    product.setValue("TEST-PROD-" + System.currentTimeMillis());
    product.setName("Test Product");
    product.setM_Product_Category_ID(105);  // Standard category
    product.setC_UOM_ID(100);               // Each
    product.setC_TaxCategory_ID(107);       // Standard tax
    product.setProductType(MProduct.PRODUCTTYPE_Item);

    // Our validator should set a default value on save
    product.saveEx();

    // Verify the validator's effect
    product.load(trxName);  // Reload from database
    assertNotNull("Validator should have set the custom field",
        product.get_Value("CustomField"));
}

Testing Event Handlers

@Test
public void testEventHandlerTriggersOnComplete() {
    // Create and complete an order
    MOrder order = createTestOrder();
    addTestOrderLine(order);

    boolean completed = order.processIt(DocAction.ACTION_Complete);
    assertTrue("Order should complete", completed);
    order.saveEx();

    // Verify the event handler's side effect
    // (e.g., it should have created a custom log record)
    int logCount = new Query(ctx, "Custom_Log", "Record_ID=? AND TableName=?", trxName)
        .setParameters(order.get_ID(), MOrder.Table_Name)
        .count();
    assertTrue("Event handler should have created a log entry", logCount > 0);
}

Test Data Management

Create a test data factory to reduce duplication across tests:

public class TestDataFactory {

    public static MBPartner createCustomer(Properties ctx, String trxName) {
        MBPartner bp = new MBPartner(ctx, 0, trxName);
        bp.setAD_Org_ID(11);
        bp.setValue("TEST-BP-" + System.currentTimeMillis());
        bp.setName("Test Customer " + System.currentTimeMillis());
        bp.setIsCustomer(true);
        bp.setC_BP_Group_ID(104);
        bp.saveEx();
        return bp;
    }

    public static MProduct createProduct(Properties ctx, String trxName) {
        MProduct product = new MProduct(ctx, 0, trxName);
        product.setAD_Org_ID(11);
        product.setValue("TEST-PROD-" + System.currentTimeMillis());
        product.setName("Test Product " + System.currentTimeMillis());
        product.setM_Product_Category_ID(105);
        product.setC_UOM_ID(100);
        product.setC_TaxCategory_ID(107);
        product.setProductType(MProduct.PRODUCTTYPE_Item);
        product.saveEx();
        return product;
    }

    public static MOrder createSalesOrder(Properties ctx, MBPartner bp, String trxName) {
        MOrder order = new MOrder(ctx, 0, trxName);
        order.setAD_Org_ID(11);
        order.setC_DocTypeTarget_ID(MOrder.DocSubTypeSO_Standard);
        order.setBPartner(bp);
        order.setIsSOTrx(true);
        order.setDateOrdered(new Timestamp(System.currentTimeMillis()));
        order.setM_Warehouse_ID(103);
        order.saveEx();
        return order;
    }
}

The Eclipse Debugger

The Eclipse debugger is your most powerful tool for understanding runtime behavior. When debugging iDempiere plugins, you will use it extensively to trace execution flow, inspect variable values, and identify the root cause of issues.

Setting Breakpoints

Place breakpoints in your plugin code by double-clicking the left margin of the code editor. When iDempiere hits the breakpoint during execution, it pauses and lets you inspect the state.

  • Line breakpoints: Pause execution when a specific line is reached.
  • Method breakpoints: Pause when a method is entered or exited (useful for interface methods where you want to see which implementation is called).
  • Exception breakpoints: Pause when a specific exception is thrown, even if it is caught. Open Run > Add Java Exception Breakpoint and add AdempiereException or DBException to catch business logic and database errors.

Conditional Breakpoints

When a breakpoint fires too frequently (e.g., inside a loop processing thousands of records), add a condition:

  1. Right-click the breakpoint and select Breakpoint Properties.
  2. Check Conditional and enter a Java expression:
// Only break when processing a specific document
getDocumentNo().equals("SO-1234")

// Only break on a specific iteration
i == 500

// Only break when a value is unexpected
getGrandTotal().compareTo(BigDecimal.ZERO) < 0

// Only break for a specific product
getM_Product_ID() == 134

Watch Expressions

In the Variables view, you can add custom watch expressions to evaluate complex conditions or call methods on paused objects:

  • Env.getContext(ctx, "#AD_Client_ID") — check the current client context
  • order.getLines() — inspect order lines without stepping through code
  • CacheMgt.get().getElementCount() — check cache status
  • trx.isActive() — verify transaction state

Stepping Through Code

Use these stepping commands to navigate through execution:

  • Step Into (F5): Enter the method being called. Use this to trace into iDempiere core code when you need to understand how a framework method works.
  • Step Over (F6): Execute the current line and move to the next one. Use this when you trust the method being called and only care about its result.
  • Step Return (F7): Execute until the current method returns. Use this when you have stepped too deep and want to get back to the calling code.
  • Drop to Frame: Re-execute from the beginning of the current method (in the call stack view). Useful for retrying with different variable values during debugging.

Remote Debugging

When debugging issues on a test or staging server, connect the Eclipse debugger remotely:

Configure the Server for Remote Debugging

Add JVM debug arguments to the iDempiere server startup script:

# In idempiere-server.sh or idempiereEnv.properties
IDEMPIERE_JAVA_OPTIONS="$IDEMPIERE_JAVA_OPTIONS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:4444"
  • transport=dt_socket — use socket transport for the debug connection
  • server=y — the JVM listens for debugger connections
  • suspend=n — do not wait for debugger to attach before starting (set to y to debug startup issues)
  • address=*:4444 — listen on port 4444 from any interface (restrict to specific IP in production)

Connect from Eclipse

  1. Open Run > Debug Configurations.
  2. Create a new Remote Java Application configuration.
  3. Set the host and port (e.g., 192.168.1.100, port 4444).
  4. Click Debug. Eclipse connects to the running server, and breakpoints in your plugin source code become active.

Remote debugging has the same capabilities as local debugging — breakpoints, stepping, variable inspection — but operates over the network. Be aware that pausing a production server at a breakpoint stops all request processing, so remote debugging should only be used on test/staging environments.

CLogger and the Logging Framework

iDempiere uses CLogger, a custom logging framework built on java.util.logging. Effective logging is critical for diagnosing issues in production where attaching a debugger is not practical.

Using CLogger in Your Plugin

import java.util.logging.Level;
import org.compiere.util.CLogger;

public class MyPluginClass {

    // Create a logger for this class
    private static final CLogger log = CLogger.getCLogger(MyPluginClass.class);

    public void processRecord(MOrder order) {
        log.fine("Processing order: " + order.getDocumentNo());

        try {
            // Business logic
            log.config("Order " + order.getDocumentNo() + " has "
                + order.getLines().length + " lines");

            if (order.getGrandTotal().compareTo(new BigDecimal("10000")) > 0) {
                log.info("Large order detected: " + order.getDocumentNo()
                    + " total=" + order.getGrandTotal());
            }

            // Process...
            log.fine("Order " + order.getDocumentNo() + " processed successfully");

        } catch (Exception e) {
            log.log(Level.SEVERE, "Failed to process order: "
                + order.getDocumentNo(), e);
            throw new AdempiereException("Processing failed", e);
        }
    }
}

Logging Levels

Choose the appropriate level for each log message:

Level Purpose Production Default Example
SEVERE Errors that prevent normal operation Always logged Database connection failure, data corruption
WARNING Potential problems that do not stop execution Always logged Deprecated method usage, missing optional config
INFO Significant operational events Usually logged Plugin started, large batch completed
CONFIG Configuration and context information Sometimes logged Connection pool settings, cache sizes
FINE Detailed tracing information Not logged Method entry/exit, record processing details
FINER More detailed tracing Not logged Loop iterations, intermediate calculation values
FINEST Maximum detail Not logged SQL statements, parameter values, raw data

In production, the default log level is typically WARNING or INFO. When investigating an issue, temporarily lower the level to FINE or FINER for the affected class to get detailed trace information without flooding the logs for the entire system.

Changing Log Levels at Runtime

You can change log levels without restarting iDempiere:

  • System Admin > General Rules > System Rules > Trace Level: Change the global trace level or per-class trace level through the iDempiere UI.
  • Programmatically: CLogMgt.setLevel(Level.FINE) sets the global level. CLogger.getCLogger("com.example.plugin").setLevel(Level.FINEST) sets it for a specific class.

Log Analysis Techniques

When diagnosing production issues, follow this systematic log analysis approach:

  1. Identify the time window: Determine when the issue occurred and filter logs to that period.
  2. Search for SEVERE and WARNING: Start with the most critical messages — they often point directly to the root cause.
  3. Trace the request flow: iDempiere logs include thread names and session IDs. Follow a single request through the logs to understand its execution path.
  4. Look for patterns: Repeated errors at specific times may indicate scheduled process failures. Errors correlated with specific users may indicate permission issues.
  5. Check surrounding context: The lines immediately before an error often contain the most useful diagnostic information.
# Useful log search commands
# Find all SEVERE errors in the last 24 hours
grep "SEVERE" /opt/idempiere/log/idempiere.$(date +%Y%m%d).log

# Find all errors related to a specific document
grep "SO-1234" /opt/idempiere/log/idempiere.*.log

# Find all errors from a specific plugin class
grep "com.example.plugin" /opt/idempiere/log/idempiere.$(date +%Y%m%d).log

# Count errors by type
grep "SEVERE" idempiere.log | sort | uniq -c | sort -rn | head -20

Common Debugging Patterns

PO Lifecycle Issues

The Persistent Object (PO) lifecycle is the most common source of plugin bugs:

  • Problem: save() returns false silently. The save() method returns a boolean rather than throwing an exception. If you do not check the return value, data loss occurs silently. Solution: Use saveEx() which throws an exception on failure, or always check the return value of save().
  • Problem: beforeSave/afterSave not firing. Model validators must be properly registered. Solution: Verify the model validator is registered in the OSGi component XML and that the table name matches exactly.
  • Problem: Stale data after save. If you modify a record and then read related data, the related records may not reflect the change. Solution: Call load(trxName) to refresh from the database, or use the same transaction name consistently.

Transaction Problems

// PROBLEM: Transaction leak — transaction is never closed
Trx trx = Trx.get(Trx.createTrxName(), true);
MOrder order = new MOrder(ctx, 0, trx.getTrxName());
order.saveEx();
// If an exception occurs here, the transaction is never closed!
trx.commit();
// Missing: trx.close() in a finally block

// SOLUTION: Use try-with-resources or try-finally
String trxName = Trx.createTrxName("MyProcess");
Trx trx = Trx.get(trxName, true);
try {
    MOrder order = new MOrder(ctx, 0, trxName);
    order.saveEx();
    trx.commit();
} catch (Exception e) {
    trx.rollback();
    throw e;
} finally {
    trx.close();  // Always close
}

Cache Issues

  • Problem: Cached data does not reflect recent changes. Direct SQL updates bypass the cache invalidation mechanism. Solution: After direct SQL updates, call CacheMgt.get().reset(tableName).
  • Problem: Memory growth from unbounded caches. CCache instances without a max size can grow indefinitely. Solution: Always specify a maximum size and expiration time for CCache instances.
  • Problem: Incorrect cache key. Using the wrong table name in CCache means automatic invalidation does not work. Solution: Always use the actual database table name as the CCache identifier.

Debugging OSGi Class Loading Issues

OSGi bundles have isolated classloaders. Common symptoms include:

  • ClassNotFoundException despite the class existing in another bundle.
  • ClassCastException when two bundles load the same class from different sources.
  • NoClassDefFoundError when a transitive dependency is not imported.

Diagnosis: Check your MANIFEST.MF for missing Import-Package or Require-Bundle entries. Use the OSGi console (ss command) to check bundle states and diag <bundle-id> to see unresolved dependencies.

Profiling Plugin Performance

When your plugin causes performance issues, use these techniques to identify the bottleneck:

Simple Timing

public void processLargeDataSet() {
    long start = System.currentTimeMillis();

    // Phase 1: Data loading
    long phase1Start = System.currentTimeMillis();
    List<MOrder> orders = loadOrders();
    log.info("Phase 1 (load): " + (System.currentTimeMillis() - phase1Start) + "ms, "
        + orders.size() + " orders");

    // Phase 2: Processing
    long phase2Start = System.currentTimeMillis();
    for (MOrder order : orders) {
        processOrder(order);
    }
    log.info("Phase 2 (process): " + (System.currentTimeMillis() - phase2Start) + "ms");

    // Phase 3: Saving
    long phase3Start = System.currentTimeMillis();
    saveResults();
    log.info("Phase 3 (save): " + (System.currentTimeMillis() - phase3Start) + "ms");

    log.info("Total processing time: " + (System.currentTimeMillis() - start) + "ms");
}

JVM Profiling

For deeper analysis, connect a profiler like VisualVM or Java Flight Recorder (JFR):

# Enable Java Flight Recorder
-XX:+FlightRecorder
-XX:StartFlightRecording=duration=60s,filename=/tmp/idempiere-profile.jfr

# Or trigger recording on demand via jcmd
jcmd <pid> JFR.start duration=60s filename=/tmp/idempiere-profile.jfr

Analyze the recording in JDK Mission Control to identify hot methods, excessive object allocation, lock contention, and I/O bottlenecks.

Summary

Rigorous testing and systematic debugging are what separate reliable production plugins from fragile prototypes. You learned how to set up JUnit testing in the OSGi environment with transaction rollback isolation, how to use the Eclipse debugger with conditional breakpoints and remote debugging, how to leverage CLogger for production diagnostics, and how to diagnose the most common categories of iDempiere plugin bugs. Apply these techniques consistently, and your plugins will earn the trust of your users and administrators. In the next lesson, you will learn how to package your tested plugins for distribution using P2 repositories and continuous integration pipelines.

You Missed