Testing and Debugging Plugins
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:
- Create a new Plug-in Project:
com.example.plugin.test - 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
AdempiereExceptionorDBExceptionto 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:
- Right-click the breakpoint and select Breakpoint Properties.
- 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 contextorder.getLines()— inspect order lines without stepping through codeCacheMgt.get().getElementCount()— check cache statustrx.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 connectionserver=y— the JVM listens for debugger connectionssuspend=n— do not wait for debugger to attach before starting (set toyto debug startup issues)address=*:4444— listen on port 4444 from any interface (restrict to specific IP in production)
Connect from Eclipse
- Open Run > Debug Configurations.
- Create a new Remote Java Application configuration.
- Set the host and port (e.g.,
192.168.1.100, port4444). - 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:
- Identify the time window: Determine when the issue occurred and filter logs to that period.
- Search for SEVERE and WARNING: Start with the most critical messages — they often point directly to the root cause.
- Trace the request flow: iDempiere logs include thread names and session IDs. Follow a single request through the logs to understand its execution path.
- Look for patterns: Repeated errors at specific times may indicate scheduled process failures. Errors correlated with specific users may indicate permission issues.
- 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: UsesaveEx()which throws an exception on failure, or always check the return value ofsave(). - 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:
ClassNotFoundExceptiondespite the class existing in another bundle.ClassCastExceptionwhen two bundles load the same class from different sources.NoClassDefFoundErrorwhen 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.
繁體中文
概覽
- 您將學到:
- 如何在 OSGi 環境中設定 JUnit 測試,並為 iDempiere Model 類別與業務邏輯撰寫單元測試
- 如何有效使用 Eclipse 除錯器,包括中斷點、條件中斷點,以及對 iDempiere Plugin 進行遠端除錯
- 如何善用 iDempiere 的 CLogger 框架、分析日誌,並診斷常見問題,例如 PO 生命週期問題、交易洩漏和快取不一致
- 先備知識:第 15 課 — 建構您的第一個 Plugin、第 2 課 — iDempiere 架構概覽
- 預估閱讀時間:24 分鐘
簡介
在開發環境中完美運作但在正式環境中失敗的 Plugin,會造成實際的業務損害 — 不正確的發票、遺失的庫存記錄、停滯的工作流程。業餘 Plugin 和正式等級 Plugin 之間的差異,在於嚴格的測試和系統性的除錯。iDempiere 的 OSGi 架構為這兩門學科增加了複雜性:測試必須在 OSGi 容器內執行以存取 iDempiere 服務,而除錯需要理解多 Bundle 類別載入和事件驅動架構。
本課為您提供測試和除錯 iDempiere Plugin 的實用技術。您將學習如何撰寫在 OSGi 環境中執行的 JUnit 測試、使用 Eclipse 除錯器追蹤複雜問題、有效地設定和分析日誌,以及診斷最常見的 Plugin 錯誤類別。
OSGi 環境中的 JUnit 測試
測試 iDempiere Plugin 不像撰寫標準 JUnit 測試那麼簡單。因為您的 Plugin 程式碼依賴 OSGi 服務、Application Dictionary 和資料庫存取,所以您的測試必須在正確初始化的 iDempiere 環境中執行。
設定測試 Bundle
建立一個單獨的測試 Plugin 專案(例如 com.example.plugin.test),用以包含您的測試類別。這可以將測試程式碼與正式 Bundle 分開:
- 建立新的 Plug-in Project:
com.example.plugin.test - 在
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
Fragment-Host 指令將您的測試 Bundle 作為 Plugin Bundle 的 Fragment 附加,讓測試類別可以存取 Plugin 的內部(未匯出的)套件。或者,如果您只測試 Plugin 的公開 API,可以改用 Import-Package。
初始化測試環境
iDempiere 測試需要資料庫連線和已初始化的環境上下文。建立一個基礎測試類別來進行設定:
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() {
// 初始化 iDempiere 環境
org.compiere.Adempiere.startup(false); // false = 非客戶端(無介面模式)
ctx = Env.getCtx();
// 設定 GardenWorld 測試資料的上下文
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() {
// 為每個測試建立新的交易
trxName = Trx.createTrxName("Test");
Trx.get(trxName, true);
}
@After
public void tearDown() {
// 回滾測試交易以保持資料庫乾淨
Trx trx = Trx.get(trxName, false);
if (trx != null) {
trx.rollback();
trx.close();
}
}
}
關鍵在於 @After 方法會回滾交易。這確保每個測試都以乾淨的資料庫狀態開始,無論測試建立、修改或刪除了什麼。測試之間彼此隔離,不會留下測試資料。
單元測試 Model 類別
透過在測試交易中執行業務邏輯,來測試您的自訂 Model 類別:
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);
// 故意省略必填欄位(Value)
doc.setName("Test Document Without Value");
assertFalse("Document without Value should fail validation", doc.save());
}
@Test
public void testBusinessLogicCalculation() {
MCustomDocument doc = createTestDocument();
// 新增明細行
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());
// 測試計算
doc.calculateTotals();
assertEquals("Grand total should be 500.00",
new BigDecimal("500.00"), doc.getGrandTotal());
}
@Test
public void testDocumentCompletion() {
MCustomDocument doc = createTestDocument();
addTestLines(doc);
// 測試文件處理
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();
// 未新增明細行
doc.processIt(DocAction.ACTION_Complete);
// 應拋出 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(); // 使用 saveEx 在失敗時拋出例外
return doc;
}
private void addTestLines(MCustomDocument doc) {
MCustomDocumentLine line = new MCustomDocumentLine(doc);
line.setQty(BigDecimal.TEN);
line.setPrice(new BigDecimal("100.00"));
line.saveEx();
}
}
模擬 iDempiere 服務
對於不應接觸資料庫的純單元測試,您可以模擬 iDempiere 服務。然而,由於 Model 類別與資料庫之間的緊密耦合,這具有挑戰性。實用策略包括:
- 將業務邏輯擷取到純 Java 類別中,以參數接收資料,而非直接查詢資料庫。這些類別可以使用標準模擬框架(Mockito)進行測試。
- 使用交易回滾模式(如上所示),用於需要實際資料庫互動的整合測試。這是 iDempiere 生態系統中最常見且可靠的方法。
- 建立測試輔助類別,產生常用的測試資料(業務夥伴、產品、訂單),以減少個別測試中的重複程式碼。
// 將可測試的邏輯擷取到純 Java 類別中
public class PricingCalculator {
/**
* 計算含折扣的明細行合計。
* 純業務邏輯 — 無資料庫相依性。
*/
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;
}
}
// 擷取邏輯的測試 — 無需資料庫
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);
}
}
整合測試策略
整合測試驗證您的 Plugin 在完整 iDempiere 堆疊中是否正確運作。這些測試較慢,但能捕捉到單元測試遺漏的問題。
測試 Model Validator
@Test
public void testModelValidatorFiresOnSave() {
// 建立一個應觸發自訂驗證器的產品
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); // 標準類別
product.setC_UOM_ID(100); // Each
product.setC_TaxCategory_ID(107); // 標準稅務
product.setProductType(MProduct.PRODUCTTYPE_Item);
// 我們的驗證器應在儲存時設定預設值
product.saveEx();
// 驗證驗證器的效果
product.load(trxName); // 從資料庫重新載入
assertNotNull("Validator should have set the custom field",
product.get_Value("CustomField"));
}
測試 Event Handler
@Test
public void testEventHandlerTriggersOnComplete() {
// 建立並完成一張訂單
MOrder order = createTestOrder();
addTestOrderLine(order);
boolean completed = order.processIt(DocAction.ACTION_Complete);
assertTrue("Order should complete", completed);
order.saveEx();
// 驗證事件處理器的副作用
//(例如,它應該已建立一筆自訂日誌記錄)
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);
}
測試資料管理
建立測試資料工廠以減少測試之間的重複:
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;
}
}
Eclipse 除錯器
Eclipse 除錯器是您理解執行時行為的最強大工具。在除錯 iDempiere Plugin 時,您會廣泛使用它來追蹤執行流程、檢查變數值和識別問題的根本原因。
設定中斷點
在程式碼編輯器的左側邊距雙擊,即可在 Plugin 程式碼中放置中斷點。當 iDempiere 在執行期間命中中斷點時,它會暫停並讓您檢查狀態。
- 行中斷點:當到達特定行時暫停執行。
- 方法中斷點:當方法被進入或退出時暫停(對於介面方法很有用,您可以看到哪個實作被呼叫)。
- 例外中斷點:當特定例外被拋出時暫停,即使它被捕獲。開啟 Run > Add Java Exception Breakpoint 並加入
AdempiereException或DBException以捕捉業務邏輯和資料庫錯誤。
條件中斷點
當中斷點過於頻繁觸發時(例如在處理數千筆記錄的迴圈中),新增條件:
- 右鍵點擊中斷點並選擇 Breakpoint Properties。
- 勾選 Conditional 並輸入 Java 表達式:
// 僅在處理特定文件時中斷
getDocumentNo().equals("SO-1234")
// 僅在特定迭代時中斷
i == 500
// 僅在值異常時中斷
getGrandTotal().compareTo(BigDecimal.ZERO) < 0
// 僅在特定產品時中斷
getM_Product_ID() == 134
監看表達式
在 Variables 檢視中,您可以新增自訂監看表達式來評估複雜條件或呼叫暫停物件的方法:
Env.getContext(ctx, "#AD_Client_ID")— 檢查目前的客戶端上下文order.getLines()— 無需逐步執行程式碼即可檢查訂單明細行CacheMgt.get().getElementCount()— 檢查快取狀態trx.isActive()— 驗證交易狀態
逐步執行程式碼
使用以下逐步命令來瀏覽執行流程:
- Step Into(F5):進入正在呼叫的方法。當您需要理解框架方法的運作方式時,使用此功能追蹤到 iDempiere 核心程式碼。
- Step Over(F6):執行目前行並移至下一行。當您信任正在呼叫的方法且只關心其結果時使用。
- Step Return(F7):執行直到目前方法返回。當您進入太深,想要回到呼叫程式碼時使用。
- Drop to Frame:從目前方法的開頭重新執行(在呼叫堆疊檢視中)。在除錯期間以不同的變數值重試時很有用。
遠端除錯
在測試或預備伺服器上除錯問題時,可遠端連接 Eclipse 除錯器:
設定伺服器進行遠端除錯
在 iDempiere 伺服器啟動腳本中加入 JVM 除錯參數:
# 在 idempiere-server.sh 或 idempiereEnv.properties 中
IDEMPIERE_JAVA_OPTIONS="$IDEMPIERE_JAVA_OPTIONS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:4444"
transport=dt_socket— 使用 Socket 傳輸進行除錯連線server=y— JVM 監聽除錯器連線suspend=n— 啟動時不等待除錯器附加(設為y以除錯啟動問題)address=*:4444— 在任何介面的 4444 埠監聽(在正式環境中限制為特定 IP)
從 Eclipse 連線
- 開啟 Run > Debug Configurations。
- 建立新的 Remote Java Application 設定。
- 設定主機和埠(例如
192.168.1.100,埠4444)。 - 點擊 Debug。Eclipse 連線到執行中的伺服器,您 Plugin 原始碼中的中斷點將變為啟用狀態。
遠端除錯具有與本機除錯相同的功能 — 中斷點、逐步執行、變數檢查 — 但透過網路運作。請注意,在中斷點處暫停正式伺服器會停止所有請求處理,因此遠端除錯應僅用於測試/預備環境。
CLogger 和日誌框架
iDempiere 使用 CLogger,這是建構在 java.util.logging 之上的自訂日誌框架。有效的日誌記錄對於在無法附加除錯器的正式環境中診斷問題至關重要。
在 Plugin 中使用 CLogger
import java.util.logging.Level;
import org.compiere.util.CLogger;
public class MyPluginClass {
// 為此類別建立日誌記錄器
private static final CLogger log = CLogger.getCLogger(MyPluginClass.class);
public void processRecord(MOrder order) {
log.fine("Processing order: " + order.getDocumentNo());
try {
// 業務邏輯
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());
}
// 處理...
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);
}
}
}
日誌等級
為每則日誌訊息選擇適當的等級:
| 等級 | 用途 | 正式環境預設 | 範例 |
|---|---|---|---|
SEVERE |
阻止正常運作的錯誤 | 始終記錄 | 資料庫連線失敗、資料損壞 |
WARNING |
不會停止執行的潛在問題 | 始終記錄 | 已棄用的方法使用、缺少選用設定 |
INFO |
重要的營運事件 | 通常記錄 | Plugin 已啟動、大型批次已完成 |
CONFIG |
設定和上下文資訊 | 有時記錄 | 連線池設定、快取大小 |
FINE |
詳細的追蹤資訊 | 不記錄 | 方法進入/退出、記錄處理詳情 |
FINER |
更詳細的追蹤 | 不記錄 | 迴圈迭代、中間計算值 |
FINEST |
最大詳細程度 | 不記錄 | SQL 語句、參數值、原始資料 |
在正式環境中,預設日誌等級通常為 WARNING 或 INFO。在調查問題時,暫時將受影響類別的等級降低到 FINE 或 FINER,以獲取詳細的追蹤資訊,而不會淹沒整個系統的日誌。
在執行時變更日誌等級
您可以在不重新啟動 iDempiere 的情況下變更日誌等級:
- System Admin > General Rules > System Rules > Trace Level:透過 iDempiere UI 變更全域追蹤等級或按類別的追蹤等級。
- 程式化方式:
CLogMgt.setLevel(Level.FINE)設定全域等級。CLogger.getCLogger("com.example.plugin").setLevel(Level.FINEST)為特定類別設定。
日誌分析技術
在診斷正式環境問題時,遵循以下系統性日誌分析方法:
- 確定時間範圍:判斷問題發生的時間,並將日誌篩選到該時段。
- 搜尋 SEVERE 和 WARNING:從最關鍵的訊息開始 — 它們通常直接指向根本原因。
- 追蹤請求流程:iDempiere 日誌包含執行緒名稱和工作階段 ID。追蹤單一請求在日誌中的執行路徑。
- 尋找模式:在特定時間重複出現的錯誤可能表示排程流程失敗。與特定使用者相關的錯誤可能表示權限問題。
- 檢查周圍上下文:錯誤之前的行通常包含最有用的診斷資訊。
# 實用的日誌搜尋指令
# 查找過去 24 小時的所有 SEVERE 錯誤
grep "SEVERE" /opt/idempiere/log/idempiere.$(date +%Y%m%d).log
# 查找與特定文件相關的所有錯誤
grep "SO-1234" /opt/idempiere/log/idempiere.*.log
# 查找來自特定 Plugin 類別的所有錯誤
grep "com.example.plugin" /opt/idempiere/log/idempiere.$(date +%Y%m%d).log
# 按類型計算錯誤數量
grep "SEVERE" idempiere.log | sort | uniq -c | sort -rn | head -20
常見除錯模式
PO 生命週期問題
持久化物件(PO)生命週期是 Plugin 錯誤最常見的來源:
- 問題:save() 靜默傳回 false。
save()方法傳回布林值而非拋出例外。如果您不檢查傳回值,資料遺失會靜默發生。解決方案:使用saveEx(),它會在失敗時拋出例外,或始終檢查save()的傳回值。 - 問題:beforeSave/afterSave 未觸發。Model Validator 必須正確註冊。解決方案:驗證 Model Validator 已在 OSGi Component XML 中註冊,且資料表名稱完全匹配。
- 問題:儲存後資料過時。如果您修改了一筆記錄然後讀取相關資料,相關記錄可能不會反映變更。解決方案:呼叫
load(trxName)從資料庫重新整理,或一致地使用相同的交易名稱。
交易問題
// 問題:交易洩漏 — 交易從未關閉
Trx trx = Trx.get(Trx.createTrxName(), true);
MOrder order = new MOrder(ctx, 0, trx.getTrxName());
order.saveEx();
// 如果此處發生例外,交易永遠不會關閉!
trx.commit();
// 缺少:finally 區塊中的 trx.close()
// 解決方案:使用 try-with-resources 或 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(); // 始終關閉
}
快取問題
- 問題:快取資料未反映最近的變更。直接 SQL 更新會繞過快取失效機制。解決方案:在直接 SQL 更新後,呼叫
CacheMgt.get().reset(tableName)。 - 問題:無界快取導致記憶體成長。沒有最大大小的 CCache 實例可以無限成長。解決方案:始終為 CCache 實例指定最大大小和過期時間。
- 問題:不正確的快取鍵。在 CCache 中使用錯誤的資料表名稱意味著自動失效不會運作。解決方案:始終使用實際的資料庫資料表名稱作為 CCache 識別符。
除錯 OSGi 類別載入問題
OSGi Bundle 具有隔離的類別載入器。常見症狀包括:
ClassNotFoundException,儘管該類別存在於另一個 Bundle 中。ClassCastException,當兩個 Bundle 從不同來源載入相同類別時。NoClassDefFoundError,當遞移性相依性未被匯入時。
診斷:檢查您的 MANIFEST.MF 是否缺少 Import-Package 或 Require-Bundle 項目。使用 OSGi 主控台(ss 指令)檢查 Bundle 狀態,並使用 diag <bundle-id> 查看未解決的相依性。
Plugin 效能分析
當您的 Plugin 造成效能問題時,使用以下技術來識別瓶頸:
簡單計時
public void processLargeDataSet() {
long start = System.currentTimeMillis();
// 階段 1:資料載入
long phase1Start = System.currentTimeMillis();
List<MOrder> orders = loadOrders();
log.info("Phase 1 (load): " + (System.currentTimeMillis() - phase1Start) + "ms, "
+ orders.size() + " orders");
// 階段 2:處理
long phase2Start = System.currentTimeMillis();
for (MOrder order : orders) {
processOrder(order);
}
log.info("Phase 2 (process): " + (System.currentTimeMillis() - phase2Start) + "ms");
// 階段 3:儲存
long phase3Start = System.currentTimeMillis();
saveResults();
log.info("Phase 3 (save): " + (System.currentTimeMillis() - phase3Start) + "ms");
log.info("Total processing time: " + (System.currentTimeMillis() - start) + "ms");
}
JVM 效能分析
如需更深入的分析,連接 VisualVM 或 Java Flight Recorder(JFR)等分析器:
# 啟用 Java Flight Recorder
-XX:+FlightRecorder
-XX:StartFlightRecording=duration=60s,filename=/tmp/idempiere-profile.jfr
# 或透過 jcmd 按需觸發錄製
jcmd <pid> JFR.start duration=60s filename=/tmp/idempiere-profile.jfr
在 JDK Mission Control 中分析錄製結果,以識別熱點方法、過度的物件配置、鎖爭用和 I/O 瓶頸。
總結
嚴格的測試和系統性的除錯,是將可靠的正式 Plugin 與脆弱的原型區分開來的關鍵。您學習了如何在 OSGi 環境中使用交易回滾隔離設定 JUnit 測試、如何使用帶有條件中斷點和遠端除錯的 Eclipse 除錯器、如何善用 CLogger 進行正式環境診斷,以及如何診斷最常見的 iDempiere Plugin 錯誤類別。持續應用這些技術,您的 Plugin 將贏得使用者和管理員的信任。在下一課中,您將學習如何使用 P2 儲存庫和持續整合管線,打包經過測試的 Plugin 以進行分發。
日本語
概要
- 学習内容:
- OSGi 環境で JUnit テストをセットアップし、iDempiere の Model クラスとビジネスロジックのユニットテストを作成する方法
- Eclipse デバッガーをブレークポイント、条件付きブレークポイント、リモートデバッグで効果的に使用して iDempiere Plugin をデバッグする方法
- iDempiere の CLogger フレームワークを活用し、ログを分析し、PO ライフサイクルの問題、トランザクションリーク、キャッシュの不整合などの一般的な問題を診断する方法
- 前提条件:レッスン 15 — 最初の Plugin の構築、レッスン 2 — iDempiere アーキテクチャ概要
- 推定読了時間:24 分
はじめに
開発環境では完璧に動作するが本番環境で失敗する Plugin は、実際のビジネスに損害を与えます — 不正確な請求書、紛失した在庫レコード、停止したワークフロー。趣味の Plugin と本番品質の Plugin の違いは、厳密なテストと体系的なデバッグにあります。iDempiere の OSGi アーキテクチャは両方の分野に複雑さを加えます:テストは iDempiere サービスにアクセスするために OSGi コンテナ内で実行する必要があり、デバッグにはマルチ Bundle のクラスローディングとイベント駆動アーキテクチャの理解が必要です。
このレッスンでは、iDempiere Plugin のテストとデバッグの実践的な技術を身につけます。OSGi 環境内で実行される JUnit テストの作成方法、Eclipse デバッガーを使用した複雑な問題の追跡方法、効果的なログの設定と分析方法、最も一般的な Plugin バグの診断方法を学びます。
OSGi 環境での JUnit テスト
iDempiere Plugin のテストは、標準的な JUnit テストを書くほど簡単ではありません。Plugin コードは OSGi サービス、Application Dictionary、データベースアクセスに依存するため、テストは適切に初期化された iDempiere 環境内で実行する必要があります。
テスト Bundle のセットアップ
テストクラスを含む別のテスト Plugin プロジェクト(例:com.example.plugin.test)を作成します。これにより、テストコードを本番 Bundle から分離できます:
- 新しい Plug-in Project を作成:
com.example.plugin.test 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
Fragment-Host ディレクティブは、テスト Bundle を Plugin Bundle のフラグメントとしてアタッチし、テストクラスが Plugin の内部(エクスポートされていない)パッケージにアクセスできるようにします。または、Plugin のパブリック API のみをテストする場合は、代わりに Import-Package を使用できます。
テスト環境の初期化
iDempiere テストにはデータベース接続と初期化された環境コンテキストが必要です。これをセットアップするベーステストクラスを作成します:
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() {
// iDempiere 環境を初期化
org.compiere.Adempiere.startup(false); // false = クライアントではない(ヘッドレス)
ctx = Env.getCtx();
// GardenWorld テストデータのコンテキストを設定
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() {
// 各テスト用に新しいトランザクションを作成
trxName = Trx.createTrxName("Test");
Trx.get(trxName, true);
}
@After
public void tearDown() {
// テストトランザクションをロールバックしてデータベースをクリーンに保つ
Trx trx = Trx.get(trxName, false);
if (trx != null) {
trx.rollback();
trx.close();
}
}
}
重要なのは、@After メソッドがトランザクションをロールバックすることです。これにより、テストが何を作成、変更、削除しても、各テストはクリーンなデータベース状態で開始されます。テストは互いに分離されており、テストデータを残しません。
Model クラスのユニットテスト
テストトランザクション内でビジネスロジックを実行して、カスタム Model クラスをテストします:
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);
// 意図的に必須フィールド(Value)を省略
doc.setName("Test Document Without Value");
assertFalse("Document without Value should fail validation", doc.save());
}
@Test
public void testBusinessLogicCalculation() {
MCustomDocument doc = createTestDocument();
// 明細行を追加
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());
// 計算をテスト
doc.calculateTotals();
assertEquals("Grand total should be 500.00",
new BigDecimal("500.00"), doc.getGrandTotal());
}
@Test
public void testDocumentCompletion() {
MCustomDocument doc = createTestDocument();
addTestLines(doc);
// ドキュメント処理をテスト
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();
// 明細行を追加しない
doc.processIt(DocAction.ACTION_Complete);
// 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(); // 失敗時に例外をスローする saveEx を使用
return doc;
}
private void addTestLines(MCustomDocument doc) {
MCustomDocumentLine line = new MCustomDocumentLine(doc);
line.setQty(BigDecimal.TEN);
line.setPrice(new BigDecimal("100.00"));
line.saveEx();
}
}
iDempiere サービスのモック
データベースに触れるべきでない純粋なユニットテストでは、iDempiere サービスをモックできます。ただし、Model クラスとデータベースの密結合により、これは困難です。実践的な戦略には以下が含まれます:
- ビジネスロジックをプレーンな Java クラスに抽出し、データベースを直接クエリするのではなく、パラメータとしてデータを受け取ります。これらのクラスは標準的なモックフレームワーク(Mockito)でテストできます。
- トランザクションロールバックパターンを使用(上記参照)して、実際のデータベースとの対話が必要な統合テストに使用します。これは iDempiere エコシステムで最も一般的で信頼性の高いアプローチです。
- テストヘルパークラスを作成し、よく必要とされるテストデータ(ビジネスパートナー、製品、注文)を生成して、個々のテストのボイラープレートを削減します。
// テスト可能なロジックをプレーンな Java クラスに抽出
public class PricingCalculator {
/**
* 割引付きの明細行合計を計算。
* 純粋なビジネスロジック — データベース依存なし。
*/
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;
}
}
// 抽出されたロジックのテスト — データベース不要
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);
}
}
統合テスト戦略
統合テストは、Plugin が完全な iDempiere スタック内で正しく動作することを検証します。これらのテストは遅いですが、ユニットテストでは見逃す問題を検出します。
Model Validator のテスト
@Test
public void testModelValidatorFiresOnSave() {
// カスタムバリデーターをトリガーする製品を作成
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); // 標準カテゴリ
product.setC_UOM_ID(100); // Each
product.setC_TaxCategory_ID(107); // 標準税
product.setProductType(MProduct.PRODUCTTYPE_Item);
// バリデーターは保存時にデフォルト値を設定すべき
product.saveEx();
// バリデーターの効果を検証
product.load(trxName); // データベースから再読み込み
assertNotNull("Validator should have set the custom field",
product.get_Value("CustomField"));
}
Event Handler のテスト
@Test
public void testEventHandlerTriggersOnComplete() {
// 注文を作成して完了する
MOrder order = createTestOrder();
addTestOrderLine(order);
boolean completed = order.processIt(DocAction.ACTION_Complete);
assertTrue("Order should complete", completed);
order.saveEx();
// イベントハンドラーの副作用を検証
//(例:カスタムログレコードが作成されているはず)
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);
}
テストデータ管理
テスト間の重複を削減するためにテストデータファクトリーを作成します:
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;
}
}
Eclipse デバッガー
Eclipse デバッガーは、ランタイムの動作を理解するための最も強力なツールです。iDempiere Plugin のデバッグ時に、実行フローの追跡、変数値の検査、問題の根本原因の特定に広く使用します。
ブレークポイントの設定
コードエディターの左マージンをダブルクリックして、Plugin コードにブレークポイントを配置します。iDempiere が実行中にブレークポイントに到達すると、一時停止して状態を検査できます。
- 行ブレークポイント:特定の行に到達したときに実行を一時停止します。
- メソッドブレークポイント:メソッドが開始または終了したときに一時停止します(どの実装が呼び出されるか確認したいインターフェースメソッドに便利です)。
- 例外ブレークポイント:特定の例外がスローされたときに一時停止します(キャッチされた場合でも)。Run > Add Java Exception Breakpoint を開き、
AdempiereExceptionまたはDBExceptionを追加してビジネスロジックとデータベースエラーをキャッチします。
条件付きブレークポイント
ブレークポイントが頻繁に発火する場合(例:数千のレコードを処理するループ内)、条件を追加します:
- ブレークポイントを右クリックして Breakpoint Properties を選択します。
- Conditional にチェックを入れ、Java 式を入力します:
// 特定のドキュメントを処理する場合のみ中断
getDocumentNo().equals("SO-1234")
// 特定のイテレーションでのみ中断
i == 500
// 値が予期しない場合のみ中断
getGrandTotal().compareTo(BigDecimal.ZERO) < 0
// 特定の製品の場合のみ中断
getM_Product_ID() == 134
ウォッチ式
Variables ビューで、複雑な条件を評価したり、一時停止したオブジェクトのメソッドを呼び出すためのカスタムウォッチ式を追加できます:
Env.getContext(ctx, "#AD_Client_ID")— 現在のクライアントコンテキストを確認order.getLines()— コードをステップ実行せずに注文明細行を検査CacheMgt.get().getElementCount()— キャッシュ状態を確認trx.isActive()— トランザクション状態を確認
コードのステップ実行
以下のステッピングコマンドを使用して実行をナビゲートします:
- Step Into(F5):呼び出されるメソッドに入ります。フレームワークメソッドの動作を理解する必要がある場合に、iDempiere コアコードにトレースするために使用します。
- Step Over(F6):現在の行を実行して次の行に移動します。呼び出されるメソッドを信頼し、結果のみに関心がある場合に使用します。
- Step Return(F7):現在のメソッドが戻るまで実行します。深く入りすぎた場合に、呼び出し元のコードに戻るために使用します。
- Drop to Frame:現在のメソッドの先頭から再実行します(コールスタックビュー内)。デバッグ中に異なる変数値で再試行する場合に便利です。
リモートデバッグ
テストまたはステージングサーバーの問題をデバッグする場合、Eclipse デバッガーをリモートで接続します:
リモートデバッグ用にサーバーを設定
iDempiere サーバーの起動スクリプトに JVM デバッグ引数を追加します:
# idempiere-server.sh または idempiereEnv.properties 内
IDEMPIERE_JAVA_OPTIONS="$IDEMPIERE_JAVA_OPTIONS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:4444"
transport=dt_socket— デバッグ接続にソケットトランスポートを使用server=y— JVM がデバッガー接続をリッスンsuspend=n— 起動前にデバッガーのアタッチを待たない(起動問題のデバッグにはyに設定)address=*:4444— 任意のインターフェースのポート 4444 でリッスン(本番環境では特定の IP に制限)
Eclipse から接続
- Run > Debug Configurations を開きます。
- 新しい Remote Java Application 設定を作成します。
- ホストとポートを設定します(例:
192.168.1.100、ポート4444)。 - Debug をクリックします。Eclipse が実行中のサーバーに接続し、Plugin ソースコード内のブレークポイントがアクティブになります。
リモートデバッグはローカルデバッグと同じ機能を持ちます — ブレークポイント、ステッピング、変数検査 — がネットワーク経由で動作します。ブレークポイントで本番サーバーを一時停止するとすべてのリクエスト処理が停止するため、リモートデバッグはテスト/ステージング環境でのみ使用してください。
CLogger とログフレームワーク
iDempiere は CLogger を使用します。これは java.util.logging 上に構築されたカスタムログフレームワークです。効果的なログは、デバッガーをアタッチできない本番環境で問題を診断するために不可欠です。
Plugin での CLogger の使用
import java.util.logging.Level;
import org.compiere.util.CLogger;
public class MyPluginClass {
// このクラス用のロガーを作成
private static final CLogger log = CLogger.getCLogger(MyPluginClass.class);
public void processRecord(MOrder order) {
log.fine("Processing order: " + order.getDocumentNo());
try {
// ビジネスロジック
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());
}
// 処理...
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);
}
}
}
ログレベル
各ログメッセージに適切なレベルを選択します:
| レベル | 目的 | 本番環境デフォルト | 例 |
|---|---|---|---|
SEVERE |
正常な動作を妨げるエラー | 常に記録 | データベース接続の失敗、データ破損 |
WARNING |
実行を停止しない潜在的な問題 | 常に記録 | 非推奨メソッドの使用、オプション設定の欠落 |
INFO |
重要な運用イベント | 通常記録 | Plugin の起動、大規模バッチの完了 |
CONFIG |
設定とコンテキスト情報 | 場合により記録 | コネクションプール設定、キャッシュサイズ |
FINE |
詳細なトレース情報 | 記録しない | メソッドの開始/終了、レコード処理の詳細 |
FINER |
より詳細なトレース | 記録しない | ループイテレーション、中間計算値 |
FINEST |
最大の詳細度 | 記録しない | SQL 文、パラメータ値、生データ |
本番環境では、デフォルトのログレベルは通常 WARNING または INFO です。問題を調査する際は、影響を受けるクラスのレベルを一時的に FINE または FINER に下げて、システム全体のログを溢れさせることなく詳細なトレース情報を取得します。
ランタイムでのログレベルの変更
iDempiere を再起動せずにログレベルを変更できます:
- System Admin > General Rules > System Rules > Trace Level:iDempiere UI を通じてグローバルトレースレベルまたはクラスごとのトレースレベルを変更します。
- プログラム的に:
CLogMgt.setLevel(Level.FINE)でグローバルレベルを設定します。CLogger.getCLogger("com.example.plugin").setLevel(Level.FINEST)で特定のクラスに対して設定します。
ログ分析技術
本番環境の問題を診断する際は、以下の体系的なログ分析アプローチに従います:
- 時間枠を特定:問題が発生した時期を判断し、その期間にログをフィルタリングします。
- SEVERE と WARNING を検索:最も重要なメッセージから始めます — 多くの場合、根本原因を直接指し示しています。
- リクエストフローを追跡:iDempiere のログにはスレッド名とセッション ID が含まれています。ログ内で単一のリクエストの実行パスをたどります。
- パターンを探す:特定の時刻に繰り返されるエラーは、スケジュールされたプロセスの失敗を示す可能性があります。特定のユーザーと相関するエラーは、権限の問題を示す可能性があります。
- 周囲のコンテキストを確認:エラーの直前の行に、最も有用な診断情報が含まれていることが多いです。
# 便利なログ検索コマンド
# 過去 24 時間のすべての SEVERE エラーを検索
grep "SEVERE" /opt/idempiere/log/idempiere.$(date +%Y%m%d).log
# 特定のドキュメントに関連するすべてのエラーを検索
grep "SO-1234" /opt/idempiere/log/idempiere.*.log
# 特定の Plugin クラスからのすべてのエラーを検索
grep "com.example.plugin" /opt/idempiere/log/idempiere.$(date +%Y%m%d).log
# タイプ別にエラーを集計
grep "SEVERE" idempiere.log | sort | uniq -c | sort -rn | head -20
一般的なデバッグパターン
PO ライフサイクルの問題
永続オブジェクト(PO)のライフサイクルは、Plugin バグの最も一般的な原因です:
- 問題:save() が黙って false を返す。
save()メソッドは例外をスローせず、ブール値を返します。戻り値をチェックしないと、データ損失が黙って発生します。解決策:失敗時に例外をスローするsaveEx()を使用するか、常にsave()の戻り値をチェックします。 - 問題:beforeSave/afterSave が発火しない。Model Validator は適切に登録されている必要があります。解決策:Model Validator が OSGi Component XML に登録されており、テーブル名が正確に一致していることを確認します。
- 問題:保存後のデータの古さ。レコードを変更してから関連データを読み取ると、関連レコードが変更を反映していない場合があります。解決策:
load(trxName)を呼び出してデータベースからリフレッシュするか、同じトランザクション名を一貫して使用します。
トランザクションの問題
// 問題:トランザクションリーク — トランザクションが閉じられない
Trx trx = Trx.get(Trx.createTrxName(), true);
MOrder order = new MOrder(ctx, 0, trx.getTrxName());
order.saveEx();
// ここで例外が発生すると、トランザクションは閉じられない!
trx.commit();
// 不足:finally ブロックでの trx.close()
// 解決策:try-with-resources または 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(); // 常に閉じる
}
キャッシュの問題
- 問題:キャッシュされたデータが最近の変更を反映しない。直接 SQL 更新はキャッシュ無効化メカニズムをバイパスします。解決策:直接 SQL 更新後、
CacheMgt.get().reset(tableName)を呼び出します。 - 問題:制限のないキャッシュによるメモリの増大。最大サイズのない CCache インスタンスは無制限に拡大できます。解決策:CCache インスタンスに常に最大サイズと有効期限を指定します。
- 問題:不正確なキャッシュキー。CCache で間違ったテーブル名を使用すると、自動無効化が機能しません。解決策:常に実際のデータベーステーブル名を CCache 識別子として使用します。
OSGi クラスローディングの問題のデバッグ
OSGi Bundle は分離されたクラスローダーを持っています。一般的な症状には以下が含まれます:
ClassNotFoundException— クラスが別の Bundle に存在するにもかかわらず発生。ClassCastException— 2 つの Bundle が異なるソースから同じクラスをロードした場合に発生。NoClassDefFoundError— 推移的な依存関係がインポートされていない場合に発生。
診断:MANIFEST.MF で不足している Import-Package または Require-Bundle エントリを確認します。OSGi コンソール(ss コマンド)を使用して Bundle の状態を確認し、diag <bundle-id> で未解決の依存関係を確認します。
Plugin パフォーマンスのプロファイリング
Plugin がパフォーマンスの問題を引き起こす場合、以下の技術を使用してボトルネックを特定します:
シンプルなタイミング
public void processLargeDataSet() {
long start = System.currentTimeMillis();
// フェーズ 1:データロード
long phase1Start = System.currentTimeMillis();
List<MOrder> orders = loadOrders();
log.info("Phase 1 (load): " + (System.currentTimeMillis() - phase1Start) + "ms, "
+ orders.size() + " orders");
// フェーズ 2:処理
long phase2Start = System.currentTimeMillis();
for (MOrder order : orders) {
processOrder(order);
}
log.info("Phase 2 (process): " + (System.currentTimeMillis() - phase2Start) + "ms");
// フェーズ 3:保存
long phase3Start = System.currentTimeMillis();
saveResults();
log.info("Phase 3 (save): " + (System.currentTimeMillis() - phase3Start) + "ms");
log.info("Total processing time: " + (System.currentTimeMillis() - start) + "ms");
}
JVM プロファイリング
より深い分析には、VisualVM や Java Flight Recorder(JFR)などのプロファイラーを接続します:
# Java Flight Recorder を有効化
-XX:+FlightRecorder
-XX:StartFlightRecording=duration=60s,filename=/tmp/idempiere-profile.jfr
# または jcmd でオンデマンドで記録を開始
jcmd <pid> JFR.start duration=60s filename=/tmp/idempiere-profile.jfr
JDK Mission Control で記録を分析し、ホットメソッド、過度なオブジェクトアロケーション、ロック競合、I/O ボトルネックを特定します。
まとめ
厳密なテストと体系的なデバッグは、信頼性の高い本番 Plugin と脆弱なプロトタイプを分けるものです。OSGi 環境でのトランザクションロールバック分離を使用した JUnit テストのセットアップ、条件付きブレークポイントとリモートデバッグを使用した Eclipse デバッガーの活用、本番環境での診断のための CLogger の利用、最も一般的な iDempiere Plugin バグの診断方法を学びました。これらの技術を一貫して適用することで、Plugin はユーザーと管理者の信頼を獲得します。次のレッスンでは、P2 リポジトリと継続的インテグレーションパイプラインを使用して、テスト済みの Plugin を配布用にパッケージ化する方法を学びます。