slashdev 1.1.0 → 1.3.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.
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slashdev",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Skills & workflows for AI agents in the Slashdev ecosystem",
5
5
  "type": "module",
6
6
  "main": "src/index.js",