slashdev 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-skills/salesforce-development/SKILL.md +341 -0
- package/claude-skills/salesforce-development/references/apex-patterns.md +388 -0
- package/claude-skills/salesforce-development/references/lwc-patterns.md +374 -0
- package/claude-skills/salesforce-development/references/testing-patterns.md +438 -0
- package/package.json +1 -1
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
# Testing Patterns — Reference
|
|
2
|
+
|
|
3
|
+
Load this file when writing or reviewing Salesforce test classes.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## TestDataFactory
|
|
8
|
+
|
|
9
|
+
A centralized factory class that creates fully-formed test records. Every test class uses this
|
|
10
|
+
instead of building records inline — keeps tests DRY and ensures records pass validation rules.
|
|
11
|
+
|
|
12
|
+
```apex
|
|
13
|
+
@IsTest
|
|
14
|
+
public class TestDataFactory {
|
|
15
|
+
|
|
16
|
+
public static List<Account> createAccounts(Integer count, Boolean doInsert) {
|
|
17
|
+
List<Account> accounts = new List<Account>();
|
|
18
|
+
for (Integer i = 0; i < count; i++) {
|
|
19
|
+
accounts.add(new Account(
|
|
20
|
+
Name = 'Test Account ' + i,
|
|
21
|
+
Industry = 'Technology',
|
|
22
|
+
BillingStreet = '123 Test St',
|
|
23
|
+
BillingCity = 'San Francisco',
|
|
24
|
+
BillingState = 'CA',
|
|
25
|
+
BillingPostalCode = '94105',
|
|
26
|
+
BillingCountry = 'US'
|
|
27
|
+
));
|
|
28
|
+
}
|
|
29
|
+
if (doInsert) {
|
|
30
|
+
insert accounts;
|
|
31
|
+
}
|
|
32
|
+
return accounts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public static List<Contact> createContacts(Integer count, Id accountId, Boolean doInsert) {
|
|
36
|
+
List<Contact> contacts = new List<Contact>();
|
|
37
|
+
for (Integer i = 0; i < count; i++) {
|
|
38
|
+
contacts.add(new Contact(
|
|
39
|
+
FirstName = 'Test',
|
|
40
|
+
LastName = 'Contact ' + i,
|
|
41
|
+
Email = 'test.contact' + i + '@example.com',
|
|
42
|
+
AccountId = accountId
|
|
43
|
+
));
|
|
44
|
+
}
|
|
45
|
+
if (doInsert) {
|
|
46
|
+
insert contacts;
|
|
47
|
+
}
|
|
48
|
+
return contacts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public static List<Opportunity> createOpportunities(Integer count, Id accountId, Boolean doInsert) {
|
|
52
|
+
List<Opportunity> opps = new List<Opportunity>();
|
|
53
|
+
for (Integer i = 0; i < count; i++) {
|
|
54
|
+
opps.add(new Opportunity(
|
|
55
|
+
Name = 'Test Opportunity ' + i,
|
|
56
|
+
AccountId = accountId,
|
|
57
|
+
StageName = 'Prospecting',
|
|
58
|
+
CloseDate = Date.today().addDays(30),
|
|
59
|
+
Amount = 10000
|
|
60
|
+
));
|
|
61
|
+
}
|
|
62
|
+
if (doInsert) {
|
|
63
|
+
insert opps;
|
|
64
|
+
}
|
|
65
|
+
return opps;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public static User createTestUser(String profileName, Boolean doInsert) {
|
|
69
|
+
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
|
|
70
|
+
String uniqueStr = String.valueOf(DateTime.now().getTime());
|
|
71
|
+
User u = new User(
|
|
72
|
+
FirstName = 'Test',
|
|
73
|
+
LastName = 'User ' + uniqueStr,
|
|
74
|
+
Email = 'testuser' + uniqueStr + '@example.com',
|
|
75
|
+
Username = 'testuser' + uniqueStr + '@example.com.test',
|
|
76
|
+
Alias = 'tuser',
|
|
77
|
+
TimeZoneSidKey = 'America/Los_Angeles',
|
|
78
|
+
LocaleSidKey = 'en_US',
|
|
79
|
+
EmailEncodingKey = 'UTF-8',
|
|
80
|
+
LanguageLocaleKey = 'en_US',
|
|
81
|
+
ProfileId = p.Id
|
|
82
|
+
);
|
|
83
|
+
if (doInsert) {
|
|
84
|
+
insert u;
|
|
85
|
+
}
|
|
86
|
+
return u;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Design principles
|
|
92
|
+
|
|
93
|
+
- **`doInsert` parameter** — lets callers get records pre-insert for field modification,
|
|
94
|
+
or inserted for immediate use. Both paths are common.
|
|
95
|
+
- **Populate required fields** — factory records must pass all validation rules and required
|
|
96
|
+
field constraints without the caller needing to know which fields are mandatory.
|
|
97
|
+
- **Use unique values** — timestamps or counters in Name/Email to avoid duplicate rule
|
|
98
|
+
violations in bulk tests.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Bulk Testing
|
|
103
|
+
|
|
104
|
+
Every test should insert 200+ records to prove the code under test handles a full trigger
|
|
105
|
+
batch. A test that works with 1 record and fails with 200 has a bulkification bug.
|
|
106
|
+
|
|
107
|
+
```apex
|
|
108
|
+
@IsTest
|
|
109
|
+
private class AccountTriggerTest {
|
|
110
|
+
|
|
111
|
+
@IsTest
|
|
112
|
+
static void shouldHandleFullBatch() {
|
|
113
|
+
// 200 = one full trigger batch
|
|
114
|
+
List<Account> accounts = TestDataFactory.createAccounts(200, false);
|
|
115
|
+
|
|
116
|
+
Test.startTest();
|
|
117
|
+
insert accounts;
|
|
118
|
+
Test.stopTest();
|
|
119
|
+
|
|
120
|
+
// Assert on outcomes — query fresh to see trigger side-effects
|
|
121
|
+
List<Account> inserted = [
|
|
122
|
+
SELECT Id, Custom_Status__c
|
|
123
|
+
FROM Account
|
|
124
|
+
WHERE Id IN :accounts
|
|
125
|
+
];
|
|
126
|
+
Assert.areEqual(200, inserted.size(), 'All 200 accounts should be inserted');
|
|
127
|
+
for (Account a : inserted) {
|
|
128
|
+
Assert.areEqual('New', a.Custom_Status__c,
|
|
129
|
+
'Trigger should set default status to New');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Why 200?
|
|
136
|
+
|
|
137
|
+
- Triggers fire in batches of up to 200 records (Data Loader, Bulk API, batch Apex).
|
|
138
|
+
- `Test.startTest()` resets governor limits, so the 200-record DML gets a fresh set of limits
|
|
139
|
+
— mimicking real-world trigger execution.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Mocking HTTP Callouts
|
|
144
|
+
|
|
145
|
+
Salesforce blocks real HTTP callouts in tests. Implement `HttpCalloutMock` and register it
|
|
146
|
+
with `Test.setMock`.
|
|
147
|
+
|
|
148
|
+
### Single-endpoint mock
|
|
149
|
+
|
|
150
|
+
```apex
|
|
151
|
+
@IsTest
|
|
152
|
+
public class ExternalAccountServiceMock implements HttpCalloutMock {
|
|
153
|
+
|
|
154
|
+
private Integer statusCode;
|
|
155
|
+
private String body;
|
|
156
|
+
|
|
157
|
+
public ExternalAccountServiceMock(Integer statusCode, String body) {
|
|
158
|
+
this.statusCode = statusCode;
|
|
159
|
+
this.body = body;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public HttpResponse respond(HttpRequest req) {
|
|
163
|
+
HttpResponse res = new HttpResponse();
|
|
164
|
+
res.setStatusCode(this.statusCode);
|
|
165
|
+
res.setHeader('Content-Type', 'application/json');
|
|
166
|
+
res.setBody(this.body);
|
|
167
|
+
return res;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Using the mock in a test
|
|
173
|
+
|
|
174
|
+
```apex
|
|
175
|
+
@IsTest
|
|
176
|
+
private class ExternalAccountServiceTest {
|
|
177
|
+
|
|
178
|
+
@IsTest
|
|
179
|
+
static void shouldDeserializeSuccessResponse() {
|
|
180
|
+
String mockBody = '{"id": "EXT-001", "name": "Acme Corp", "status": "active"}';
|
|
181
|
+
Test.setMock(HttpCalloutMock.class, new ExternalAccountServiceMock(200, mockBody));
|
|
182
|
+
|
|
183
|
+
Test.startTest();
|
|
184
|
+
ExternalAccountService.ExternalAccount result =
|
|
185
|
+
ExternalAccountService.getAccount('EXT-001');
|
|
186
|
+
Test.stopTest();
|
|
187
|
+
|
|
188
|
+
Assert.isNotNull(result, 'Result should not be null for 200 response');
|
|
189
|
+
Assert.areEqual('EXT-001', result.id, 'ID should match mock response');
|
|
190
|
+
Assert.areEqual('Acme Corp', result.name, 'Name should match mock response');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@IsTest
|
|
194
|
+
static void shouldReturnNullFor404() {
|
|
195
|
+
Test.setMock(HttpCalloutMock.class, new ExternalAccountServiceMock(404, ''));
|
|
196
|
+
|
|
197
|
+
Test.startTest();
|
|
198
|
+
ExternalAccountService.ExternalAccount result =
|
|
199
|
+
ExternalAccountService.getAccount('NONEXISTENT');
|
|
200
|
+
Test.stopTest();
|
|
201
|
+
|
|
202
|
+
Assert.isNull(result, 'Should return null for 404');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@IsTest
|
|
206
|
+
static void shouldThrowOnServerError() {
|
|
207
|
+
Test.setMock(HttpCalloutMock.class, new ExternalAccountServiceMock(500, 'Internal Server Error'));
|
|
208
|
+
|
|
209
|
+
Test.startTest();
|
|
210
|
+
try {
|
|
211
|
+
ExternalAccountService.getAccount('EXT-001');
|
|
212
|
+
Assert.fail('Should have thrown CalloutException');
|
|
213
|
+
} catch (CalloutException e) {
|
|
214
|
+
Assert.isTrue(e.getMessage().contains('500'), 'Exception should contain status code');
|
|
215
|
+
}
|
|
216
|
+
Test.stopTest();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Multi-endpoint mock
|
|
222
|
+
|
|
223
|
+
For classes that call multiple endpoints, route by URL in `respond()`:
|
|
224
|
+
|
|
225
|
+
```apex
|
|
226
|
+
public HttpResponse respond(HttpRequest req) {
|
|
227
|
+
String endpoint = req.getEndpoint();
|
|
228
|
+
HttpResponse res = new HttpResponse();
|
|
229
|
+
res.setHeader('Content-Type', 'application/json');
|
|
230
|
+
|
|
231
|
+
if (endpoint.contains('/accounts')) {
|
|
232
|
+
res.setStatusCode(200);
|
|
233
|
+
res.setBody('{"id": "ACC-001"}');
|
|
234
|
+
} else if (endpoint.contains('/contacts')) {
|
|
235
|
+
res.setStatusCode(200);
|
|
236
|
+
res.setBody('{"id": "CON-001"}');
|
|
237
|
+
} else {
|
|
238
|
+
res.setStatusCode(404);
|
|
239
|
+
res.setBody('Not found');
|
|
240
|
+
}
|
|
241
|
+
return res;
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Async Testing — Test.startTest() / Test.stopTest()
|
|
248
|
+
|
|
249
|
+
`Test.startTest()` and `Test.stopTest()` do two things:
|
|
250
|
+
|
|
251
|
+
1. **Reset governor limits** — code between them gets a fresh set of limits, isolating the
|
|
252
|
+
code under test from test setup overhead.
|
|
253
|
+
2. **Force async execution** — `@future`, `Queueable`, and `Batch Apex` jobs enqueued between
|
|
254
|
+
`startTest`/`stopTest` execute synchronously at `stopTest()`, making them testable.
|
|
255
|
+
|
|
256
|
+
### Testing a Queueable
|
|
257
|
+
|
|
258
|
+
```apex
|
|
259
|
+
@IsTest
|
|
260
|
+
private class SyncAccountToERPTest {
|
|
261
|
+
|
|
262
|
+
@IsTest
|
|
263
|
+
static void shouldCallERPEndpoint() {
|
|
264
|
+
Account a = TestDataFactory.createAccounts(1, true)[0];
|
|
265
|
+
Test.setMock(HttpCalloutMock.class, new ExternalAccountServiceMock(200, '{"status":"ok"}'));
|
|
266
|
+
|
|
267
|
+
Test.startTest();
|
|
268
|
+
System.enqueueJob(new SyncAccountToERP(new List<Id>{ a.Id }));
|
|
269
|
+
Test.stopTest(); // Queueable executes here synchronously
|
|
270
|
+
|
|
271
|
+
// Assert on side effects — e.g., a log record, field update, etc.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Testing Batch Apex
|
|
277
|
+
|
|
278
|
+
```apex
|
|
279
|
+
@IsTest
|
|
280
|
+
private class DeactivateStaleLeadsBatchTest {
|
|
281
|
+
|
|
282
|
+
@IsTest
|
|
283
|
+
static void shouldDeactivateStaleLeads() {
|
|
284
|
+
List<Lead> leads = new List<Lead>();
|
|
285
|
+
for (Integer i = 0; i < 200; i++) {
|
|
286
|
+
leads.add(new Lead(
|
|
287
|
+
FirstName = 'Test', LastName = 'Lead ' + i, Company = 'TestCo',
|
|
288
|
+
Status = 'Open'
|
|
289
|
+
));
|
|
290
|
+
}
|
|
291
|
+
insert leads;
|
|
292
|
+
// Backdate LastActivityDate via Test.setCreatedDate or a field update
|
|
293
|
+
// (LastActivityDate is read-only — use a proxy field or adjust the query for testing)
|
|
294
|
+
|
|
295
|
+
Test.startTest();
|
|
296
|
+
Database.executeBatch(new DeactivateStaleLeadsBatch(), 200);
|
|
297
|
+
Test.stopTest(); // All batch execute() calls run here
|
|
298
|
+
|
|
299
|
+
List<Lead> updated = [SELECT Status FROM Lead WHERE Id IN :leads];
|
|
300
|
+
for (Lead l : updated) {
|
|
301
|
+
Assert.areEqual('Closed - Not Converted', l.Status, 'Stale leads should be deactivated');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## System.runAs — Testing Sharing and FLS
|
|
310
|
+
|
|
311
|
+
`System.runAs` executes code in the context of a specific user, enforcing their profile
|
|
312
|
+
permissions, sharing rules, and field-level security. Use it to verify that `with sharing`
|
|
313
|
+
classes and FLS checks work correctly.
|
|
314
|
+
|
|
315
|
+
```apex
|
|
316
|
+
@IsTest
|
|
317
|
+
private class AccountServiceTest {
|
|
318
|
+
|
|
319
|
+
@IsTest
|
|
320
|
+
static void shouldEnforceSharingRules() {
|
|
321
|
+
// Create a restricted user
|
|
322
|
+
User restrictedUser = TestDataFactory.createTestUser('Standard User', true);
|
|
323
|
+
|
|
324
|
+
// Create an account owned by the running user (not the restricted user)
|
|
325
|
+
Account privateAccount = TestDataFactory.createAccounts(1, true)[0];
|
|
326
|
+
|
|
327
|
+
System.runAs(restrictedUser) {
|
|
328
|
+
Test.startTest();
|
|
329
|
+
List<Account> visible = new AccountService().getVisibleAccounts();
|
|
330
|
+
Test.stopTest();
|
|
331
|
+
|
|
332
|
+
// Restricted user should NOT see the private account
|
|
333
|
+
// (assuming OWD for Account is Private)
|
|
334
|
+
Set<Id> visibleIds = new Set<Id>();
|
|
335
|
+
for (Account a : visible) {
|
|
336
|
+
visibleIds.add(a.Id);
|
|
337
|
+
}
|
|
338
|
+
Assert.isFalse(visibleIds.contains(privateAccount.Id),
|
|
339
|
+
'Restricted user should not see accounts they do not own');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Caveats
|
|
346
|
+
|
|
347
|
+
- `System.runAs` does NOT count against governor limits for the enclosing test.
|
|
348
|
+
- Mixed DML restrictions apply — inserting a User and a standard object in the same
|
|
349
|
+
transaction throws an error. Insert the user first, then use `System.runAs` for the rest.
|
|
350
|
+
- `System.runAs` only works in `@IsTest` methods.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## @testSetup — Shared Test Data
|
|
355
|
+
|
|
356
|
+
`@testSetup` runs once per test class and creates records available to all `@IsTest` methods
|
|
357
|
+
in that class. Each test method gets its own copy (rolled back after each method), so tests
|
|
358
|
+
remain independent.
|
|
359
|
+
|
|
360
|
+
```apex
|
|
361
|
+
@IsTest
|
|
362
|
+
private class OpportunityServiceTest {
|
|
363
|
+
|
|
364
|
+
@testSetup
|
|
365
|
+
static void setupData() {
|
|
366
|
+
List<Account> accounts = TestDataFactory.createAccounts(5, true);
|
|
367
|
+
for (Account a : accounts) {
|
|
368
|
+
TestDataFactory.createOpportunities(10, a.Id, true);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
@IsTest
|
|
373
|
+
static void shouldCalculateTotalPipeline() {
|
|
374
|
+
Test.startTest();
|
|
375
|
+
Decimal total = OpportunityService.getTotalPipeline();
|
|
376
|
+
Test.stopTest();
|
|
377
|
+
|
|
378
|
+
Assert.areEqual(500000, total, '5 accounts x 10 opps x $10,000 = $500,000');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
@IsTest
|
|
382
|
+
static void shouldFilterByStage() {
|
|
383
|
+
// This test gets the SAME data as above — fresh copy, not affected by other tests
|
|
384
|
+
List<Opportunity> opps = [SELECT Id FROM Opportunity WHERE StageName = 'Prospecting'];
|
|
385
|
+
Assert.areEqual(50, opps.size(), 'All 50 opps should be in Prospecting stage');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Caveats
|
|
391
|
+
|
|
392
|
+
- `@testSetup` cannot be used with `@IsTest(SeeAllData=true)` — they are mutually exclusive.
|
|
393
|
+
- Records created in `@testSetup` are not visible to the `@testSetup` method's own
|
|
394
|
+
`System.runAs` blocks — each `@IsTest` method sees a fresh snapshot.
|
|
395
|
+
- If any `@IsTest` method needs different data, create it within that method — don't
|
|
396
|
+
over-centralize.
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## Assert Patterns
|
|
401
|
+
|
|
402
|
+
### Modern Assert class (Spring '22+)
|
|
403
|
+
|
|
404
|
+
Prefer `Assert` over the legacy `System.assert*` methods. The modern class has clearer
|
|
405
|
+
method names and better error messages.
|
|
406
|
+
|
|
407
|
+
```apex
|
|
408
|
+
// Equality
|
|
409
|
+
Assert.areEqual(expected, actual, 'Descriptive message explaining what went wrong');
|
|
410
|
+
Assert.areNotEqual(unexpected, actual, 'Values should differ because...');
|
|
411
|
+
|
|
412
|
+
// Null checks
|
|
413
|
+
Assert.isNull(value, 'Should be null because...');
|
|
414
|
+
Assert.isNotNull(value, 'Should not be null because...');
|
|
415
|
+
|
|
416
|
+
// Boolean
|
|
417
|
+
Assert.isTrue(condition, 'Condition should be true because...');
|
|
418
|
+
Assert.isFalse(condition, 'Condition should be false because...');
|
|
419
|
+
|
|
420
|
+
// Forced failure (in catch blocks for expected exceptions)
|
|
421
|
+
Assert.fail('Should have thrown an exception');
|
|
422
|
+
|
|
423
|
+
// Instance check
|
|
424
|
+
Assert.isInstanceOfType(obj, Account.class, 'Should be an Account');
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Rules
|
|
428
|
+
|
|
429
|
+
- **Always include a message parameter.** When a test fails in a CI pipeline, the message
|
|
430
|
+
is the only context the developer has. "Expected 200 but got 0" is useless without
|
|
431
|
+
"All accounts should have been inserted by the bulk trigger."
|
|
432
|
+
- **Assert on specific values, not just non-null.** `Assert.isNotNull(result)` proves the
|
|
433
|
+
query returned something — it doesn't prove it returned the *right* thing.
|
|
434
|
+
- **One logical assertion per test method** when practical. Multiple assertions are fine if
|
|
435
|
+
they verify the same operation's outcome (e.g., checking multiple fields on a record), but
|
|
436
|
+
avoid combining unrelated assertions that make failures ambiguous.
|
|
437
|
+
- **Never use `System.assertEquals` in new code** — it still works but `Assert.areEqual` is
|
|
438
|
+
the modern replacement with consistent naming.
|