Acumatica customizations tend to go untested for a structural reason: most of the business logic lives in PXGraph and PXGraphExtension classes that expect a live PXCache backed by a database connection, and the framework wasn't built with dependency injection in mind. That doesn't mean the logic is untestable — it means the test strategy has to work with the framework's grain rather than fight it, and know where to draw the line between unit tests, integration tests against a real instance, and UI automation.
Why Acumatica code resists unit testing
A typical event handler — a RowUpdated or FieldUpdated on a PXGraphExtension — reads and writes through Base.Caches[typeof(SOOrder)], calls PXSelect against live tables, and often triggers cascading events on related graphs. None of that has an obvious seam for a mock. The pragmatic split: business logic that's pure calculation (tax rounding, a custom allocation formula, a validation rule that only reads fields already on the row) can be pulled into a plain static method or a small non-PXGraph class and unit tested directly with no Acumatica dependencies at all. Logic that genuinely needs the cache — anything reading related rows, checking cache state, or calling PXCache.SetValueExt — needs either a real (if minimal) PXCache or an integration-level test against a running instance. Trying to mock PXCache itself is usually more code than the logic it's testing; it's rarely worth it beyond a handful of interaction points.
Testing PXGraphExtension logic in isolation
Acumatica ships a Test Automation Framework built on a Selenium-driven UI layer, but for logic-level checks most teams end up writing plain NUnit or xUnit tests that instantiate the graph against a real (usually disposable) database rather than mocking PXContext wholesale — PXCache's internal state tracking is deep enough that a hand-rolled mock drifts out of sync with real behavior quickly. The practical pattern: keep a small seeded test database (or a snapshot restored per run), instantiate the graph extension through PXGraph.CreateInstance<T>(), push a row through the cache, and assert on the resulting field values or exceptions.
[TestFixture]
public class SOOrderEntryExtTests
{
[Test]
public void RowUpdated_BlocksOrder_WhenCreditLimitExceeded()
{
var graph = PXGraph.CreateInstance<SOOrderEntry>();
var ext = graph.GetExtension<SOOrderEntryExt>();
SOOrder order = graph.Document.Insert(new SOOrder
{
CustomerID = TestData.OverLimitCustomerId,
OrderNbr = "TEST-0001"
});
order.OrderTotal = 999999m;
graph.Document.Update(order);
var ex = Assert.Throws<PXSetPropertyException>(() => graph.Document.View.Cache.Persist(PXDBOperation.Insert));
Assert.That(ex.Message, Does.Contain("credit limit"));
}
}
This runs against a real cache and a real (test) database connection rather than a mock, which is deliberately the boring choice — PXCache's validation and event pipeline is too intertwined to fake convincingly, and a database round trip in a seeded test instance is fast enough not to matter for a few hundred tests.
Contract tests for REST endpoints
For integrations built on Acumatica's contract-based REST API, the more valuable automated check is often not a unit test at all but a contract test: hit the generated OpenAPI/Swagger endpoint for the custom endpoint version, assert the schema hasn't silently changed shape (a field renamed, a required field dropped), and run a handful of black-box create/update/query calls against a seeded tenant. This catches the failure mode that unit tests miss entirely — a customization that compiles fine but changes the shape of data an external integration depends on.
CI pipeline considerations
Acumatica doesn't run in a lightweight container the way a typical web app does, so CI for Acumatica customizations usually looks different from a standard .NET pipeline. Common approach: maintain a golden database snapshot with representative seed data, restore it at the start of each pipeline run (or reuse a warm test instance and roll back via a database snapshot/restore rather than tearing the whole instance down), publish the customization package, run the NUnit suite against that instance, then discard the state. Full instance spin-up is expensive enough that most teams run it nightly or on a merge to a release branch rather than on every commit — pure unit tests with no Acumatica dependency run on every push, database-backed tests run less frequently.
Relying on each test to undo its own database changes gets fragile fast once tests run in parallel or a test fails mid-run and leaves orphaned data. Restoring a snapshot before the suite (or before each test class) is more code up front but far more reliable than a fleet of teardown methods that all have to be correct.
Where UI automation fits
Selenium or Playwright against actual Acumatica screens has its place, but it's the most expensive and most brittle layer, so it should cover the smallest surface: end-to-end workflows that span multiple screens and can't be validated any other way — a sales order flowing through to a shipment, an invoice, and a GL posting, verified by reading the resulting screens rather than querying the database directly. Anything that can be verified with a database assertion or an API call doesn't need a browser. A healthy ratio looks like a large base of pure-logic unit tests, a smaller layer of cache/database-backed graph tests, a thin layer of contract tests on the REST surface, and a handful of UI smoke tests for the workflows that matter most to the business.
Wrapping up
Acumatica testing works when you stop trying to mock the framework and instead separate what's genuinely framework-independent (test it directly, fast and cheap) from what needs PXCache and a database (test it against a real seeded instance, restored per run) from what only a full UI flow can validate (test it sparingly). Get that split right and the suite catches real regressions without turning every commit into a ten-minute wait for a full instance to boot.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.