Test Driven Development with Apex Triggers on Force.com

In an earlier blog, we examined a simple example of Test Driven Development (TDD). Here, we dive into a real-life example of using TDD to develop production Apex code for Salesforce CRM.

How does TDD work in practice?


Let’s walk through a non-trivial example of using test-driving development to craft a complex Apex trigger.

Problem: An off-the-shelf integration requires the existence of a specific Salesforce CRM Opportunity object. When the object is present, the integration acts on the Account related to the Opportunity. When the object is not present. the integration bypasses the Account.

User Story: As a CRM User, I need to easily manage the Opportunity that signals the integration, for example, by selecting a checkbox that creates or removes the related integration object.

Solution: Provide an Account trigger that observes the checkbox and inserts or deletes the related integration object.

How do we test an Apex trigger?

In the case of an Apex trigger, we can’t invoke the behavior directly. The purpose of a trigger is to create a side effect when we insert, update, or delete objects. Basically, we do something like

| Account a = new Account(name = ‘test’,);
| insert(a);

and observe the state changes.

(Note: Another approach is to have the trigger call a worker class, and then write tests against the worker class. Here, we are using the direct approach, and keeping the trigger code in the trigger.)

As we saw in Part 1, a good place to start any coding exercise is to clearly define the expected state change. We can then code tests against the expected state changes, call the relevant DML operations (insert, update, and/or delete), and observe the outcome. (DML = Database Manipulation Language.)

Let’s express our expectations (or requirements) in the form of a documentation comment for the trigger under test.

// Integration Account trigger requirements

/
Manages a2z Opportunity by referring to the “Do Import” checkbox on the Account object (NU_isA2zImport__c). When an a2z Opportunity exists, the Account is imported to a2z.
The trigger handles three transitional states: (1) if insert and doImport, insert opportunity;
(2) if update and !doImport and hasOpp, delete opportunity; (3) if update and doImport and !hasOpp, insert opportunity.
By inference, the trigger also handles: (4) if insert and !doImport, exit;
(5) if update and doImport and hasOpp, exit; (6) if !doImport and !hasOpp, exit.
*/

To start coding the trigger that implements this logic, according to test driven development, we need a failing test. Let’s start with (1) and write a test for the Insert case. Since this is a non-trivial example, there is some scaffolding for the test.

// Test for Insert case

final static Id A2Z_RECORD_TYPE_ID = Schema.SObjectType.Opportunity.
getRecordTypeInfosByName().get(‘a2z’).getRecordTypeId();
final static String A2Z_STAGE_NAME = ‘Closed Won’;

/
Exercises “Insert Import” (1) by inserting an new Account with the checkbox set, and observing whether a corresponding Opportunity is created.
/
static testMethod void testInsImp() {
// Bootstrap a test account, passing in values for needed fields.
Account a = new Account(name = ‘test’, NU_isA2zImport__c=true);
// Insert and Select the test Account
insert(a);
// Do we have an a2z opp?
Integer opps = [
SELECT COUNT()
FROM Opportunity
WHERE AccountId = :a.id
AND RecordTypeId = :A2Z_RECORD_TYPE_ID
AND StageName = :A2Z_STAGE_NAME
];
Boolean hasOpp = (opps>0);
System.assert(hasOpp,’Expected opp on insert.’);
}


When we run this test, it fails, because no one has written a trigger to insert the opportunity when the checkbox is true. Next!

// Code for the Insert case

/** Manages a2z Opportunity by referring to the “Do Import” checkbox
on the Account object.
/
trigger NU_a2zCreateOpportunity on Account (after insert, after update) {

final static Id A2Z_RECORD_TYPE_ID = Schema.SObjectType.Opportunity.
getRecordTypeInfosByName().get(‘a2z’).getRecordTypeId();
final static String A2Z_STAGE_NAME = ‘Closed Won’;

// (1) On insert, if isImport, insert opp
if (Trigger.isInsert) {
List delta = new List();
for (Account a : Trigger.new) {
if (a.NU_isA2zImport__c) {
Opportunity o = new Opportunity(
AccountId = a.Id,
Name = ‘a2z’,
RecordTypeId = A2Z_RECORD_TYPE_ID,
StageName = A2Z_STAGE_NAME,
CloseDate = Date.Today()
);
delta.add(o);
}
}
if (delta.size()>0) insert(delta);
}

When we run the test again, it succeeds, because we now have the trigger code that inserts the a2z Opportunity.

(Note: If you haven’t written Apex triggers, the for-loop might seem odd. As a performance tweak, Apex triggers work with batches. Most times, it’s a batch of one. But, if data is being imported, a batch could contain 200, or even 2000, objects to insert, update, or delete.)

Since unit testing is baked into Apex, we don’t need to undo any database operations that occur with an actual testMethod. While the testMethod is running, the trigger can insert all the opportunities it likes, but when the test ends, Force.com rolls it all back for us. No fuss. No muss. No left-over cruft.

Looking back at the code, I see things I don’t like. There are redundant constants, and we are also doing a lot of heavy lifting inline, obscuring the flow of the code. Since we have a passing test, let’s improve the design, and run the test again.

First, since we will need to share constants and helpers between the tests and the code-under-test, let’s extract existing code into a static helper class.

// Extracting a utility class to share test and domain code.

/** Encapsulates tools used by a2z Opportunity test and domain code.
/
public Class NU_a2zOpps {

/**
Defines an a2z Opp with a particular Record Type ID and Stage Name.
(TODO: Transfer to custom settings and expose to validation rule.) /
final static public String A2Z_STAGE_NAME = ‘Closed Won’;
final static public Id A2Z_RECORD_TYPE_ID = Schema.SObjectType.Opportunity.
getRecordTypeInfosByName().get(‘a2z’).getRecordTypeId();

/
Declares a default name for generated opportunities. /
static public String A2Z_NAME = ‘a2z Import’;

/

Returns a ready-to-use a2z Opportunity. /
static public Opportunity newOpp(Account a) {
return new Opportunity(
AccountId = a.Id,
RecordTypeId = A2Z_RECORD_TYPE_ID,
Name = A2Z_NAME,
StageName = A2Z_STAGE_NAME,
CloseDate = Date.Today()
);
}

/
Determines if a given Account has an A2z Opportunity. /
static public Boolean hasOpp(Account a) {
Integer opps = [
SELECT COUNT()
FROM Opportunity
WHERE AccountId = :a.id
AND RecordTypeId = :A2Z_RECORD_TYPE_ID
AND StageName = :A2Z_STAGE_NAME
];
return (opps>0);
}
}

Replacing the extracted code with references to the static class, our test and trigger are now easier to follow.

// Test and domain classes refactored to use new utility class.

@isTest
private class NU_TEST_a2zCreateOpportunity {

/
Generates an account with the NU_isA2zImport__c raised or lowered. Assumes default is false.
/
static Account newAccount(Boolean doImport) {
Account a = new Account(Name = ‘Test Account’,
CompanyNumber__c = ‘1’;
if (doImport) a.NU_isA2zImport__c = true; // False is default
return a;
}

/**
Inserts the given account, and returns it again from a select.
/
static Account doInsert(Account a) {
insert(a);
return [
SELECT id, NU_isA2zImport__c, CompanyNumber__c
FROM Account
WHERE id = :a.id
];
}

/**
Exercises “Insert Import”.
/
static testMethod void testInsImp() {
Account a = newAccount(true);
Account a2 = doInsert(a);
System.assert(NU_a2zOpps.hasOpp(a2), ‘Expected opp on insert.’);
}
}

trigger NU_a2zCreateOpportunity on Account (after insert, after update) {
// (1) On insert, if isImport, insert opp
if (Trigger.isInsert) {
List delta = new List();
for (Account a : Trigger.new) {
if (a.NU_isA2zImport__c) {
delta.add(NU_a2zOpps.newOpp(a));
}
}
if (delta.size()>0) insert delta ;
}
}

We implemented the first requirement using a classic TDD pattern:
Create a failing test that proves a desired behavior is not present.
Write just enough code to pass the test. Once the test succeeds, improve the design (refactor) so that it’s easy to maintain.Let’s continue to follow the TDD pattern with our second requirement: “if update and !doImport and hasOpp, delete opp”.
First, the failing test:

// Test deleting a related opportunity

static testMethod void testUpdImpNoImp() {
Account a = newAccount(true); // Import
Account a2 = doInsert(a); // Line 2
a2.NU_isA2zImport__c = false; // No Import
update a2; // Line 4
System.assert(!NU_a2zOpps.hasOpp(a2),’Expected no opps on update.’);
}


When we run the test, it fails, because our trigger inserts a new a2z Opportunity (at line 2) but does not delete the Opportunity (at line 4).

OK, let’s update the trigger to provide the behavior expected by line 4. As before, we need to write the trigger to loop through a batch, while minimizing database calls to stay within governor limits.

// (2) On update, if not isImport and haveOpp, delete opp

if (Trigger.isUpdate) { Map opps = NU_a2zOpps.getOpps(Trigger.new);
Set ids = opps.Keyset();
List omega = new List();
for (Account a : Trigger.new) {
if ( !a.NU_isA2zImport__c && ids.contains(a.Id)) {
omega.add(opps.get(a.Id)); // (2)
}
}
if (omega.size()>0) delete omega;
}


The crux of the code change is determining if we have a related opportunity to delete. Easy enough with one Account, but in the case of a trigger, we might have to check 200 accounts, and our query limit is only 100. Since the trigger passes us the set of Accounts in the batch, it’s not difficult to retrieve the set of Opportunities related to those Accounts. Though, now that we have a utility class, we should keep the query details encapsulated behind another helper method.

The getOpps helper method returns a Map itemizing the accounts in our batch that have related a2z opportunities.

// The getOpps helper method

static public Map getOpps(List accounts) {
List oppsList = new List();
Map opps = new Map();
Integer count = [
SELECT COUNT() FROM Opportunity WHERE RecordTypeId = :A2Z_RECORD_TYPE_ID AND StageName = :A2Z_STAGE_NAME AND AccountId IN :accounts ];
if (count>0) {
oppsList = [
SELECT AccountId FROM Opportunity
WHERE RecordTypeId = :A2Z_RECORD_TYPE_ID
AND StageName = :A2Z_STAGE_NAME
AND AccountId IN :accounts
];
for (Opportunity o : oppsList) {
opps.put(o.AccountId,o);
}
}
return opps;
}


A critical clause in the helper’s SELECT statement is “AccountId IN :accounts“. This clause ensures that we only retrieve the Opportunities that are related to Accounts in the current batch. Without this clause, we could retrieve more Opportunities than allowed by the Force.com governor (50,000). The helper also makes a point of returning an empty Map if there are no matching opportunities, simplifying life for the caller.

While we’ve been coding the trigger to act on a batch, our tests have not been passing a batch of objects to the trigger. Let’s add a test to be sure batch mode is working.

// Test to verify that insert works with batches of records

/
Exercise Insert Import in batch mode.
/
static testMethod void verifyBatchInsert() {
List rows = new List();
for (Integer r=0; r<200; r++) {
rows.add(newAccount(true, r));
}
insert(rows);
List inserted = [
SELECT id, NU_isA2zImport__c, CompanyNumber__c
FROM Account
WHERE id in :rows
];
Set ids = NU_a2zOpps.getOpps(inserted).Keyset();
Boolean success = true;
for (Account a : inserted) {
success = success && ids.contains(a.Id);
}
System.assert(success,’Expected opps on batch insert.’);
}


Running the test, initially, we hit a problem with a helper method. This particular organization includes an external ID that must be unique for each record. In batch mode, our external IDs are not unique, and so we hit a validation error. A quick fix is to pass in the counter from the loop, creating a serial number for each Account.

// newAccount with offset parameter

static Account newAccount(Boolean doImport, Integer offset) {
Account a = new Account(
Name = ‘Test Account’,
CompanyNumber__c = String.valueOf(offset)
);
if (doImport) a.NU_isA2zImport__c = true; // False is default
return a;
}

// for backward-compatibility
static Account newAccount(Boolean doImport) {
return newAccount(doImport, 0);
}


And, a batch delete test.

// batchDelete

static testMethod void batchDelete() {
List rows = new List();
for (Integer r=0; r<200; r++) {
rows.add(newAccount(true, r));
}
insert rows;
List inserted = [
SELECT id, NU_isA2zImport__c, CompanyNumber__c
FROM Account
WHERE id in :rows
];
for (Account a : inserted) {
a.NU_isA2zImport__c = false;
}
update inserted;
Set ids = NU_a2zOpps.getOpps(inserted).Keyset();
Boolean success = true;
for (Account a : inserted) {
success = success && !ids.contains(a.Id);
}
System.assert(success,’Expected no opps on batch delete.’);
}


Both of the new tests are passing, but they seem to repeat a lot of code, and we have a third requirement coming up that will also need batch mode testing. Let’s see if we can create a helper class that can serve both tests.

// batchHelper method

static void batchHelper(Boolean insImp) {
List rows = new List();
for (Integer r=0; r<200; r++) {
rows.add(newAccount(insImp, r));
}
insert rows;
List inserted = [
SELECT id, NU_isA2zImport__c, CompanyNumber__c
FROM Account
WHERE id in :rows
];
// For delete, we need to update the flag
if (!insImp) {
for (Account a : inserted) {
a.NU_isA2zImport__c = false;
}
update inserted;
}
Set ids = NU_a2zOpps2.getOpps(inserted).Keyset();
Boolean success = true;
if (insImp) {
for (Account a : inserted) {
success = success && ids.contains(a.Id);
}
System.assert(success,’Expected opps on batch insert.’);
} else {
for (Account a : inserted) {
success = success && !ids.contains(a.Id);
}
System.assert(success,’Expected no opps on batch delete.’);
}
}

/
Exercises Batch insert.
/
static testMethod void batchHelperIns() {
batchHelper(false);
}

/*
Exercises Batch delete.
/
static testMethod void batchHelperDel() {
batchHelper(true);
}


By passing a flag, we are able to use one utility for both cases, and share 90% of the code.

Repeating the patterns we’ve seen, we can test and refactor our way into a robust, reliable trigger to manage our integration object.

For the complete production source code (without the play-by-play), see Managing a Related Object via a Checkbox in Salesforce CRM.

Key takeaways are: Use a utility class to share code between test and domain classes.
Use helper methods to share code between similar test methods. Use the SELECT-IN-SET pattern to keep triggers within governor limits.Test Driven Development is a rigorous, structured approach that helps us create robust and reliable code. Since TDD uses successive refinement, we can easily extend and improve the code over time.