Salesforce · · 27 min read

Async Apex Project: Scheduling a Batch Update

A hands-on project building a scheduled batch process — creating a service class, batch class, scheduled class, and comprehensive test classes for a real-world data update scenario.

Part 54: Async Apex Project — Scheduling a Batch Update

Welcome back to the Salesforce series. Over the last several installments we have covered the asynchronous Apex toolkit — future methods, queueable Apex, batch Apex, and scheduled Apex. Each post explored one piece of the async puzzle in isolation. Now it is time to combine them into a complete, production-quality project.

In this post we will build a Scheduled Batch Account Health Score Updater. The system will calculate a health score for every Account in the org based on its related Opportunities, Cases, and Activities. A service class will own the calculation logic. A batch class will iterate over all Accounts and delegate to the service. A scheduled class will kick off the batch on a recurring basis. And a full suite of test classes will verify everything end to end.

This is the kind of project you will encounter in the real world — a nightly job that rolls up data from multiple child objects into a summary field on a parent record. By the end of this post you will have a reusable pattern for any scheduled batch processing requirement.


The Project Overview

Here is what we are building:

LayerClass NameResponsibility
ServiceAccountHealthScoreServiceCalculates health scores from Opportunities, Cases, and Activities
BatchAccountHealthScoreBatchIterates over Accounts in batches, calls the service, tracks metrics
SchedulerAccountHealthScoreSchedulerImplements Schedulable, launches the batch on a CRON schedule
Test FactoryAccountHealthScoreTestFactoryCreates test data for all test classes
Test — ServiceAccountHealthScoreServiceTestUnit tests for the service class
Test — BatchAccountHealthScoreBatchTestIntegration tests for the batch class
Test — SchedulerAccountHealthScoreSchedulerTestTests for the scheduler class

We will assume there is a custom field on the Account object called Health_Score__c (a Number field, 0–100). In your own org, you would create this field through Setup before deploying the code.


Creating Our Service Class

The service class is the heart of the project. It contains pure business logic with no direct coupling to batch or scheduling infrastructure. This separation is critical. It means the same calculation logic can be called from a trigger, a Flow-invocable method, an API endpoint, or our batch class without any changes.

The Scoring Algorithm

Our health score is a weighted composite of three factors:

  • Opportunity Score (40% weight) — Based on the percentage of Closed Won Opportunities relative to total Opportunities. An Account with a high win rate scores higher.
  • Case Score (30% weight) — Based on the percentage of Closed Cases relative to total Cases. An Account where support issues get resolved scores higher.
  • Activity Score (30% weight) — Based on whether the Account has had recent activity (Tasks or Events) in the last 90 days. Recent engagement scores higher.

The Service Class Code

public with sharing class AccountHealthScoreService {

    // Weights for each scoring component
    private static final Decimal OPPORTUNITY_WEIGHT = 0.40;
    private static final Decimal CASE_WEIGHT = 0.30;
    private static final Decimal ACTIVITY_WEIGHT = 0.30;

    // Activity lookback window
    private static final Integer ACTIVITY_LOOKBACK_DAYS = 90;

    /**
     * Calculates and sets the Health_Score__c field on the provided Accounts.
     * Expects Accounts to have their IDs populated.
     * Returns the list of Accounts with updated Health_Score__c values.
     * Does NOT perform DML — the caller is responsible for updating.
     */
    public static List<Account> calculateHealthScores(List<Account> accounts) {
        if (accounts == null || accounts.isEmpty()) {
            return new List<Account>();
        }

        Set<Id> accountIds = new Set<Id>();
        for (Account acc : accounts) {
            accountIds.add(acc.Id);
        }

        // Gather Opportunity data
        Map<Id, OpportunityMetrics> oppMetricsMap = getOpportunityMetrics(accountIds);

        // Gather Case data
        Map<Id, CaseMetrics> caseMetricsMap = getCaseMetrics(accountIds);

        // Gather Activity data
        Map<Id, ActivityMetrics> activityMetricsMap = getActivityMetrics(accountIds);

        // Calculate composite score for each Account
        List<Account> updatedAccounts = new List<Account>();
        for (Account acc : accounts) {
            Decimal oppScore = calculateOpportunityScore(
                oppMetricsMap.get(acc.Id)
            );
            Decimal caseScore = calculateCaseScore(
                caseMetricsMap.get(acc.Id)
            );
            Decimal activityScore = calculateActivityScore(
                activityMetricsMap.get(acc.Id)
            );

            Decimal compositeScore = (
                (oppScore * OPPORTUNITY_WEIGHT) +
                (caseScore * CASE_WEIGHT) +
                (activityScore * ACTIVITY_WEIGHT)
            );

            // Round to nearest integer, clamp between 0 and 100
            Integer finalScore = Math.min(
                100,
                Math.max(0, (Integer) compositeScore.round(System.RoundingMode.HALF_UP))
            );

            acc.Health_Score__c = finalScore;
            updatedAccounts.add(acc);
        }

        return updatedAccounts;
    }

    // ─── Opportunity Metrics ───────────────────────────────────────

    private static Map<Id, OpportunityMetrics> getOpportunityMetrics(
        Set<Id> accountIds
    ) {
        Map<Id, OpportunityMetrics> metricsMap = new Map<Id, OpportunityMetrics>();

        for (AggregateResult ar : [
            SELECT AccountId,
                   COUNT(Id) totalCount,
                   SUM(CASE WHEN StageName = 'Closed Won' THEN 1 ELSE 0 END) wonCount
            FROM Opportunity
            WHERE AccountId IN :accountIds
            GROUP BY AccountId
        ]) {
            Id accId = (Id) ar.get('AccountId');
            OpportunityMetrics metrics = new OpportunityMetrics();
            metrics.totalCount = (Integer) ar.get('totalCount');
            metrics.wonCount = ((Decimal) ar.get('wonCount')).intValue();
            metricsMap.put(accId, metrics);
        }

        return metricsMap;
    }

    private static Decimal calculateOpportunityScore(OpportunityMetrics metrics) {
        if (metrics == null || metrics.totalCount == 0) {
            return 50; // Neutral score when no Opportunities exist
        }
        // Win rate as a percentage (0-100)
        return ((Decimal) metrics.wonCount / metrics.totalCount) * 100;
    }

    // ─── Case Metrics ──────────────────────────────────────────────

    private static Map<Id, CaseMetrics> getCaseMetrics(Set<Id> accountIds) {
        Map<Id, CaseMetrics> metricsMap = new Map<Id, CaseMetrics>();

        for (AggregateResult ar : [
            SELECT AccountId,
                   COUNT(Id) totalCount,
                   SUM(CASE WHEN IsClosed = true THEN 1 ELSE 0 END) closedCount
            FROM Case
            WHERE AccountId IN :accountIds
            GROUP BY AccountId
        ]) {
            Id accId = (Id) ar.get('AccountId');
            CaseMetrics metrics = new CaseMetrics();
            metrics.totalCount = (Integer) ar.get('totalCount');
            metrics.closedCount = ((Decimal) ar.get('closedCount')).intValue();
            metricsMap.put(accId, metrics);
        }

        return metricsMap;
    }

    private static Decimal calculateCaseScore(CaseMetrics metrics) {
        if (metrics == null || metrics.totalCount == 0) {
            return 50; // Neutral score when no Cases exist
        }
        // Resolution rate as a percentage (0-100)
        return ((Decimal) metrics.closedCount / metrics.totalCount) * 100;
    }

    // ─── Activity Metrics ──────────────────────────────────────────

    private static Map<Id, ActivityMetrics> getActivityMetrics(Set<Id> accountIds) {
        Map<Id, ActivityMetrics> metricsMap = new Map<Id, ActivityMetrics>();

        Date lookbackDate = Date.today().addDays(-ACTIVITY_LOOKBACK_DAYS);

        // Query Tasks
        for (AggregateResult ar : [
            SELECT WhatId, COUNT(Id) taskCount
            FROM Task
            WHERE WhatId IN :accountIds
              AND CreatedDate >= :lookbackDate
            GROUP BY WhatId
        ]) {
            Id accId = (Id) ar.get('WhatId');
            ActivityMetrics metrics = new ActivityMetrics();
            metrics.recentTaskCount = (Integer) ar.get('taskCount');
            metricsMap.put(accId, metrics);
        }

        // Query Events
        for (AggregateResult ar : [
            SELECT WhatId, COUNT(Id) eventCount
            FROM Event
            WHERE WhatId IN :accountIds
              AND CreatedDate >= :lookbackDate
            GROUP BY WhatId
        ]) {
            Id accId = (Id) ar.get('WhatId');
            ActivityMetrics metrics = metricsMap.containsKey(accId)
                ? metricsMap.get(accId)
                : new ActivityMetrics();
            metrics.recentEventCount = (Integer) ar.get('eventCount');
            metricsMap.put(accId, metrics);
        }

        return metricsMap;
    }

    private static Decimal calculateActivityScore(ActivityMetrics metrics) {
        if (metrics == null) {
            return 0; // No recent activity = lowest score
        }
        Integer totalActivities = metrics.recentTaskCount + metrics.recentEventCount;

        // Scoring tiers based on activity volume
        if (totalActivities >= 10) {
            return 100;
        } else if (totalActivities >= 5) {
            return 75;
        } else if (totalActivities >= 1) {
            return 50;
        }
        return 0;
    }

    // ─── Inner Classes for Metrics ─────────────────────────────────

    @TestVisible
    private class OpportunityMetrics {
        public Integer totalCount = 0;
        public Integer wonCount = 0;
    }

    @TestVisible
    private class CaseMetrics {
        public Integer totalCount = 0;
        public Integer closedCount = 0;
    }

    @TestVisible
    private class ActivityMetrics {
        public Integer recentTaskCount = 0;
        public Integer recentEventCount = 0;
    }
}

Design Decisions Worth Noting

There are several deliberate design choices in this service class.

No DML inside the service. The calculateHealthScores method modifies the Health_Score__c field in memory but does not call update. This keeps the service composable — the caller decides when and how to commit the changes. In a batch context, the batch framework handles DML. In a trigger context, before-trigger field changes require no DML at all.

Neutral default scores. When an Account has no Opportunities or no Cases, we assign a neutral score of 50 rather than 0. This prevents new Accounts from being unfairly penalized before they have had time to generate data.

Inner classes for metrics. Using small inner classes (OpportunityMetrics, CaseMetrics, ActivityMetrics) keeps the data organized and makes the scoring methods easy to test. The @TestVisible annotation allows our test class to access them even though they are private.

Bulkified queries. Every query uses WHERE ... IN :accountIds to handle collections of records efficiently. There are no queries inside loops. This is critical because the batch class will call this method with up to 200 Accounts at a time.


Creating Our Batch Class

The batch class acts as the orchestration layer. It pulls Accounts from the database in manageable chunks, delegates to the service class for the calculation, and handles errors gracefully so that a failure in one batch does not derail the entire job.

Implementing Database.Batchable and Database.Stateful

public class AccountHealthScoreBatch
    implements Database.Batchable<SObject>, Database.Stateful {

    // ─── Stateful Tracking Variables ───────────────────────────────
    // These persist across batch execute calls because we implement
    // Database.Stateful. Without Stateful, they would reset to their
    // initial values in each execute invocation.

    private Integer totalRecordsProcessed = 0;
    private Integer totalRecordsUpdated = 0;
    private Integer totalErrors = 0;
    private List<String> errorMessages = new List<String>();

    // Cap error messages to avoid heap limits on very large jobs
    private static final Integer MAX_ERROR_MESSAGES = 50;

    // ─── start() ───────────────────────────────────────────────────
    // Returns the QueryLocator that defines the full scope of records
    // the batch will process.

    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator([
            SELECT Id, Name, Health_Score__c
            FROM Account
            WHERE IsDeleted = false
            ORDER BY Name
        ]);
    }

    // ─── execute() ─────────────────────────────────────────────────
    // Called once per batch chunk (default 200 records). Delegates to
    // the service class and handles partial failures.

    public void execute(Database.BatchableContext bc, List<Account> scope) {
        totalRecordsProcessed += scope.size();

        try {
            // Delegate calculation to the service class
            List<Account> updatedAccounts =
                AccountHealthScoreService.calculateHealthScores(scope);

            // Use allOrNone = false so partial success is possible
            List<Database.SaveResult> results =
                Database.update(updatedAccounts, false);

            // Process results
            for (Integer i = 0; i < results.size(); i++) {
                if (results[i].isSuccess()) {
                    totalRecordsUpdated++;
                } else {
                    totalErrors++;
                    if (errorMessages.size() < MAX_ERROR_MESSAGES) {
                        String accountName = updatedAccounts[i].Name;
                        String errors = '';
                        for (Database.Error err : results[i].getErrors()) {
                            errors += err.getMessage() + '; ';
                        }
                        errorMessages.add(
                            'Account "' + accountName + '": ' + errors
                        );
                    }
                }
            }
        } catch (Exception ex) {
            // Catch unexpected exceptions so the batch can continue
            totalErrors += scope.size();
            if (errorMessages.size() < MAX_ERROR_MESSAGES) {
                errorMessages.add(
                    'Unexpected error in batch execute: ' +
                    ex.getMessage() + ' | Stack: ' + ex.getStackTraceString()
                );
            }
        }
    }

    // ─── finish() ──────────────────────────────────────────────────
    // Called once after all batches have completed. Logs a summary
    // and optionally sends a notification email.

    public void finish(Database.BatchableContext bc) {
        String summary = buildSummary(bc);

        // Log to debug
        System.debug(LoggingLevel.INFO, summary);

        // Optionally send a notification email to the admin
        sendCompletionEmail(summary);
    }

    // ─── Helper Methods ────────────────────────────────────────────

    private String buildSummary(Database.BatchableContext bc) {
        AsyncApexJob job = [
            SELECT Id, Status, NumberOfErrors,
                   JobItemsProcessed, TotalJobItems,
                   CreatedDate, CompletedDate
            FROM AsyncApexJob
            WHERE Id = :bc.getJobId()
        ];

        String summary = '=== Account Health Score Batch Summary ===\n';
        summary += 'Job ID: ' + job.Id + '\n';
        summary += 'Status: ' + job.Status + '\n';
        summary += 'Batches Processed: ' + job.JobItemsProcessed +
                   ' / ' + job.TotalJobItems + '\n';
        summary += 'Records Processed: ' + totalRecordsProcessed + '\n';
        summary += 'Records Updated: ' + totalRecordsUpdated + '\n';
        summary += 'Errors: ' + totalErrors + '\n';
        summary += 'Started: ' + job.CreatedDate + '\n';
        summary += 'Completed: ' + job.CompletedDate + '\n';

        if (!errorMessages.isEmpty()) {
            summary += '\n--- Error Details ---\n';
            for (String msg : errorMessages) {
                summary += '  • ' + msg + '\n';
            }
            if (totalErrors > MAX_ERROR_MESSAGES) {
                summary += '  ... and ' +
                    (totalErrors - MAX_ERROR_MESSAGES) +
                    ' more errors (truncated)\n';
            }
        }

        return summary;
    }

    private void sendCompletionEmail(String summary) {
        try {
            // Get the running user's email
            String userEmail = [
                SELECT Email FROM User WHERE Id = :UserInfo.getUserId()
            ].Email;

            Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
            mail.setToAddresses(new List<String>{ userEmail });
            mail.setSubject('Account Health Score Batch Completed');
            mail.setPlainTextBody(summary);
            Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{ mail });
        } catch (Exception ex) {
            // Do not let email failure break the finish method
            System.debug(
                LoggingLevel.ERROR,
                'Failed to send completion email: ' + ex.getMessage()
            );
        }
    }

    // ─── Public Getters for Testing ────────────────────────────────

    @TestVisible
    private Integer getTotalRecordsProcessed() {
        return totalRecordsProcessed;
    }

    @TestVisible
    private Integer getTotalRecordsUpdated() {
        return totalRecordsUpdated;
    }

    @TestVisible
    private Integer getTotalErrors() {
        return totalErrors;
    }
}

Why Database.Stateful Matters

By default, each execute invocation in a batch class receives a fresh instance of the class. Member variables are reset. This is efficient because Salesforce does not need to serialize and deserialize the class state between batches.

When you implement Database.Stateful, Salesforce serializes the instance after each execute call and deserializes it before the next one. This means our totalRecordsProcessed, totalRecordsUpdated, totalErrors, and errorMessages accumulate across all batches and are available in the finish method for the summary report.

The trade-off is performance. Serialization adds overhead, and if your stateful variables grow large (for example, a massive list of error messages), you can hit heap size limits. That is why we cap errorMessages at 50 entries.

Error Handling Strategy

Our batch class uses a layered error handling approach.

Layer 1 — Partial DML success. By passing false as the second argument to Database.update, we tell Salesforce to commit the records that succeed and return errors for the ones that fail. This prevents one bad record from blocking updates to 199 good records in the same batch.

Layer 2 — Exception catch. The entire execute body is wrapped in a try-catch. If the service class throws an unexpected exception (perhaps a query limit or null pointer), the batch logs the error and moves on to the next chunk rather than aborting the entire job.

Layer 3 — Safe finish. The sendCompletionEmail method has its own try-catch. An email sending failure (perhaps the org has hit its daily email limit) should not cause the finish method to throw an unhandled exception.


Creating Our Scheduled Class

The scheduled class is intentionally thin. Its only job is to instantiate the batch class and execute it. All business logic lives in the service, and all orchestration logic lives in the batch. The scheduler is just a timer.

public class AccountHealthScoreScheduler implements Schedulable {

    // Default batch size — can be overridden via the constructor
    private Integer batchSize;

    // Default constructor uses batch size of 200
    public AccountHealthScoreScheduler() {
        this.batchSize = 200;
    }

    // Parameterized constructor for flexibility
    public AccountHealthScoreScheduler(Integer batchSize) {
        this.batchSize = batchSize;
    }

    public void execute(SchedulableContext sc) {
        AccountHealthScoreBatch batch = new AccountHealthScoreBatch();
        Database.executeBatch(batch, this.batchSize);
    }
}

The parameterized constructor is a small but valuable addition. In production you might want a batch size of 200 for maximum throughput, but during testing or in an org with complex triggers you might want to reduce it to 50 to stay within governor limits. The constructor lets you configure this without changing code.


Scheduling Options for Our Scheduled Class

Salesforce provides several ways to schedule our class. Each has its place depending on your workflow.

Option 1: System.schedule with a CRON Expression

This is the most common approach and gives you full control over the schedule. You execute it from Anonymous Apex in the Developer Console or a script.

// Schedule to run every day at 2:00 AM
String jobId = System.schedule(
    'Nightly Account Health Score Update',
    '0 0 2 * * ?',
    new AccountHealthScoreScheduler()
);
System.debug('Scheduled job ID: ' + jobId);

The CRON expression format in Salesforce follows this pattern:

Seconds  Minutes  Hours  Day_of_Month  Month  Day_of_Week  (Optional_Year)

Here are practical examples:

// Every weekday at 6:00 AM
String cron1 = '0 0 6 ? * MON-FRI';

// First day of every month at midnight
String cron2 = '0 0 0 1 * ?';

// Every Sunday at 11:30 PM
String cron3 = '0 30 23 ? * SUN';

// Every hour (on the hour)
String cron4 = '0 0 * * * ?';

// Specific date: January 15, 2027 at 9:00 AM
String cron5 = '0 0 9 15 1 ? 2027';

A few rules to keep in mind about Salesforce CRON:

  • You cannot specify both Day_of_Month and Day_of_Week. One of them must be ? (no specific value).
  • The Seconds field only supports 0 — you cannot schedule at sub-minute granularity.
  • Each org can have a maximum of 100 scheduled Apex jobs at a time.

Option 2: Schedule from Setup UI

Navigate to Setup > Apex Classes > Schedule Apex. The UI lets you pick a class that implements Schedulable, give the job a name, and configure the schedule using a visual form (frequency, start date, end date, preferred start time). Under the hood Salesforce generates a CRON expression from your selections.

This option is useful for admins who are not comfortable with CRON syntax, but it is limited — you cannot express complex patterns like “every weekday” or “first Monday of the month” through the UI.

Option 3: System.scheduleBatch Shortcut

If you do not need a separate Schedulable class and just want to run a batch at a specific time, Salesforce provides a shortcut:

// Run the batch 60 minutes from now with a batch size of 200
AccountHealthScoreBatch batch = new AccountHealthScoreBatch();
String jobId = System.scheduleBatch(batch, 'One-Time Health Score Update', 60, 200);

The third parameter is the delay in minutes. The fourth (optional) parameter is the batch size. This method creates a one-time scheduled job — it does not recur. It is handy for ad-hoc jobs or one-time data fixes, but for recurring nightly jobs, use the full Schedulable class approach.

Option 4: Monitoring Scheduled Jobs

After scheduling, you can monitor your jobs in Setup > Scheduled Jobs (or by navigating to /0CQ). The Scheduled Jobs page shows:

  • Job Name — The label you passed to System.schedule.
  • Submitted By — The user who scheduled the job.
  • Next Scheduled Run — When the job will fire next.
  • Type — Will show “Scheduled Apex” for our job.

You can also query scheduled jobs programmatically:

List<CronTrigger> jobs = [
    SELECT Id, CronJobDetail.Name, State, NextFireTime, PreviousFireTime
    FROM CronTrigger
    WHERE CronJobDetail.JobType = '7'
    ORDER BY NextFireTime
];

for (CronTrigger ct : jobs) {
    System.debug(ct.CronJobDetail.Name + ' | Next: ' + ct.NextFireTime);
}

To abort a scheduled job:

// Get the CronTrigger ID from the Scheduled Jobs page or a query
System.abortJob('0CQ...');

Testing Our Classes

Testing async Apex is one of the areas that trips up many developers. The key insight is that Test.startTest() and Test.stopTest() force asynchronous work to execute synchronously within the test context, giving you a clean way to assert results.

Test Data Factory

First, we create a factory class that all three test classes can share. Centralizing test data creation reduces duplication and makes tests easier to maintain.

@IsTest
public class AccountHealthScoreTestFactory {

    /**
     * Creates an Account with a configurable set of related records.
     * Returns the Account ID.
     */
    public static Id createAccountWithRelatedData(
        String accountName,
        Integer totalOpportunities,
        Integer wonOpportunities,
        Integer totalCases,
        Integer closedCases,
        Integer recentTasks,
        Integer recentEvents
    ) {
        // Create the Account
        Account acc = new Account(Name = accountName);
        insert acc;

        // Create Opportunities
        List<Opportunity> opps = new List<Opportunity>();
        for (Integer i = 0; i < totalOpportunities; i++) {
            opps.add(new Opportunity(
                Name = accountName + ' Opp ' + i,
                AccountId = acc.Id,
                StageName = (i < wonOpportunities) ? 'Closed Won' : 'Prospecting',
                CloseDate = Date.today().addDays(30),
                Amount = 10000
            ));
        }
        if (!opps.isEmpty()) {
            insert opps;
        }

        // Create Cases
        List<Case> cases = new List<Case>();
        for (Integer i = 0; i < totalCases; i++) {
            cases.add(new Case(
                AccountId = acc.Id,
                Subject = accountName + ' Case ' + i,
                Status = (i < closedCases) ? 'Closed' : 'New',
                Origin = 'Web'
            ));
        }
        if (!cases.isEmpty()) {
            insert cases;
        }

        // Create Tasks (recent activity)
        List<Task> tasks = new List<Task>();
        for (Integer i = 0; i < recentTasks; i++) {
            tasks.add(new Task(
                WhatId = acc.Id,
                Subject = accountName + ' Task ' + i,
                Status = 'Completed',
                ActivityDate = Date.today()
            ));
        }
        if (!tasks.isEmpty()) {
            insert tasks;
        }

        // Create Events (recent activity)
        List<Event> events = new List<Event>();
        for (Integer i = 0; i < recentEvents; i++) {
            events.add(new Event(
                WhatId = acc.Id,
                Subject = accountName + ' Event ' + i,
                StartDateTime = DateTime.now(),
                EndDateTime = DateTime.now().addHours(1)
            ));
        }
        if (!events.isEmpty()) {
            insert events;
        }

        return acc.Id;
    }

    /**
     * Creates a bare Account with no related records.
     */
    public static Id createBareAccount(String accountName) {
        Account acc = new Account(Name = accountName);
        insert acc;
        return acc.Id;
    }
}

Testing the Service Class

The service class test focuses on validating the scoring logic under different scenarios — high health, low health, mixed data, and no data.

@IsTest
private class AccountHealthScoreServiceTest {

    @IsTest
    static void testHighHealthScore() {
        // Account with 80% win rate, 90% case resolution, high activity
        Id accId = AccountHealthScoreTestFactory.createAccountWithRelatedData(
            'High Health Corp',
            10,  // total opps
            8,   // won opps (80% win rate)
            10,  // total cases
            9,   // closed cases (90% resolution)
            6,   // recent tasks
            5    // recent events (11 total = tier 100)
        );

        Account acc = [SELECT Id, Name, Health_Score__c FROM Account WHERE Id = :accId];

        Test.startTest();
        List<Account> results = AccountHealthScoreService.calculateHealthScores(
            new List<Account>{ acc }
        );
        Test.stopTest();

        Account result = results[0];
        // Expected: (80 * 0.4) + (90 * 0.3) + (100 * 0.3) = 32 + 27 + 30 = 89
        System.assertEquals(89, result.Health_Score__c,
            'High health Account should score 89');
    }

    @IsTest
    static void testLowHealthScore() {
        // Account with 10% win rate, 20% case resolution, no activity
        Id accId = AccountHealthScoreTestFactory.createAccountWithRelatedData(
            'Low Health Corp',
            10,  // total opps
            1,   // won opps (10% win rate)
            10,  // total cases
            2,   // closed cases (20% resolution)
            0,   // no tasks
            0    // no events
        );

        Account acc = [SELECT Id, Name, Health_Score__c FROM Account WHERE Id = :accId];

        Test.startTest();
        List<Account> results = AccountHealthScoreService.calculateHealthScores(
            new List<Account>{ acc }
        );
        Test.stopTest();

        Account result = results[0];
        // Expected: (10 * 0.4) + (20 * 0.3) + (0 * 0.3) = 4 + 6 + 0 = 10
        System.assertEquals(10, result.Health_Score__c,
            'Low health Account should score 10');
    }

    @IsTest
    static void testNeutralScoreNoRelatedRecords() {
        // Account with no Opportunities, no Cases, no Activities
        Id accId = AccountHealthScoreTestFactory.createBareAccount('Empty Corp');

        Account acc = [SELECT Id, Name, Health_Score__c FROM Account WHERE Id = :accId];

        Test.startTest();
        List<Account> results = AccountHealthScoreService.calculateHealthScores(
            new List<Account>{ acc }
        );
        Test.stopTest();

        Account result = results[0];
        // Expected: (50 * 0.4) + (50 * 0.3) + (0 * 0.3) = 20 + 15 + 0 = 35
        System.assertEquals(35, result.Health_Score__c,
            'Account with no related records should score 35 (neutral opp/case, zero activity)');
    }

    @IsTest
    static void testEmptyListInput() {
        Test.startTest();
        List<Account> results = AccountHealthScoreService.calculateHealthScores(
            new List<Account>()
        );
        Test.stopTest();

        System.assertEquals(0, results.size(),
            'Empty input should return empty output');
    }

    @IsTest
    static void testNullInput() {
        Test.startTest();
        List<Account> results = AccountHealthScoreService.calculateHealthScores(null);
        Test.stopTest();

        System.assertEquals(0, results.size(),
            'Null input should return empty output');
    }

    @IsTest
    static void testBulkCalculation() {
        // Create 200 Accounts with varied data
        List<Id> accountIds = new List<Id>();
        for (Integer i = 0; i < 200; i++) {
            Id accId = AccountHealthScoreTestFactory.createBareAccount(
                'Bulk Account ' + i
            );
            accountIds.add(accId);
        }

        List<Account> accounts = [
            SELECT Id, Name, Health_Score__c
            FROM Account
            WHERE Id IN :accountIds
        ];

        Test.startTest();
        List<Account> results =
            AccountHealthScoreService.calculateHealthScores(accounts);
        Test.stopTest();

        System.assertEquals(200, results.size(),
            'Should process all 200 Accounts');
        for (Account acc : results) {
            System.assertNotEquals(null, acc.Health_Score__c,
                'Every Account should have a score assigned');
        }
    }
}

Testing the Batch Class

Testing a batch class requires Test.startTest() and Test.stopTest() to force the asynchronous batch execution to complete synchronously. After Test.stopTest(), you can query the database and assert that the batch made the expected changes.

@IsTest
private class AccountHealthScoreBatchTest {

    @TestSetup
    static void setupTestData() {
        // Create a high-health Account
        AccountHealthScoreTestFactory.createAccountWithRelatedData(
            'Batch Test High Health',
            10, 8,   // 80% opp win rate
            10, 9,   // 90% case resolution
            6, 5     // 11 activities = tier 100
        );

        // Create a low-health Account
        AccountHealthScoreTestFactory.createAccountWithRelatedData(
            'Batch Test Low Health',
            10, 1,   // 10% opp win rate
            10, 2,   // 20% case resolution
            0, 0     // no activity
        );

        // Create a bare Account
        AccountHealthScoreTestFactory.createBareAccount('Batch Test Bare');
    }

    @IsTest
    static void testBatchExecution() {
        Test.startTest();
        AccountHealthScoreBatch batch = new AccountHealthScoreBatch();
        Id batchId = Database.executeBatch(batch, 200);
        Test.stopTest();

        // After Test.stopTest(), all batch execute calls have completed.
        // Verify the high-health Account.
        Account highHealth = [
            SELECT Health_Score__c FROM Account
            WHERE Name = 'Batch Test High Health'
        ];
        System.assertEquals(89, highHealth.Health_Score__c,
            'Batch should calculate correct score for high health Account');

        // Verify the low-health Account.
        Account lowHealth = [
            SELECT Health_Score__c FROM Account
            WHERE Name = 'Batch Test Low Health'
        ];
        System.assertEquals(10, lowHealth.Health_Score__c,
            'Batch should calculate correct score for low health Account');

        // Verify the bare Account.
        Account bare = [
            SELECT Health_Score__c FROM Account
            WHERE Name = 'Batch Test Bare'
        ];
        System.assertEquals(35, bare.Health_Score__c,
            'Batch should calculate correct score for bare Account');
    }

    @IsTest
    static void testBatchWithSmallBatchSize() {
        // Run with batch size of 1 to ensure each Account is processed
        // in its own execute call — tests Database.Stateful accumulation
        Test.startTest();
        AccountHealthScoreBatch batch = new AccountHealthScoreBatch();
        Id batchId = Database.executeBatch(batch, 1);
        Test.stopTest();

        // All three Accounts should still be updated correctly
        List<Account> accounts = [
            SELECT Name, Health_Score__c FROM Account ORDER BY Name
        ];

        for (Account acc : accounts) {
            System.assertNotEquals(null, acc.Health_Score__c,
                'Account "' + acc.Name + '" should have a score after batch');
        }
    }

    @IsTest
    static void testBatchJobDetails() {
        Test.startTest();
        AccountHealthScoreBatch batch = new AccountHealthScoreBatch();
        Id batchId = Database.executeBatch(batch, 200);
        Test.stopTest();

        // Verify the AsyncApexJob completed
        AsyncApexJob job = [
            SELECT Status, NumberOfErrors, JobItemsProcessed, TotalJobItems
            FROM AsyncApexJob
            WHERE Id = :batchId
        ];

        System.assertEquals('Completed', job.Status,
            'Batch job should complete successfully');
        System.assertEquals(0, job.NumberOfErrors,
            'Batch job should have no platform errors');
        System.assertEquals(job.TotalJobItems, job.JobItemsProcessed,
            'All batch items should be processed');
    }
}

Testing the Scheduler Class

The scheduler test verifies that scheduling the class creates a CronTrigger record and that executing the scheduler launches the batch.

@IsTest
private class AccountHealthScoreSchedulerTest {

    @IsTest
    static void testSchedulerCreatesJob() {
        String cronExpression = '0 0 2 * * ?'; // Daily at 2 AM

        Test.startTest();
        String jobId = System.schedule(
            'Test Health Score Schedule',
            cronExpression,
            new AccountHealthScoreScheduler()
        );
        Test.stopTest();

        // Verify the scheduled job was created
        CronTrigger ct = [
            SELECT Id, CronExpression, State, NextFireTime
            FROM CronTrigger
            WHERE Id = :jobId
        ];

        System.assertEquals(cronExpression, ct.CronExpression,
            'CRON expression should match what was scheduled');
        System.assertNotEquals(null, ct.NextFireTime,
            'Scheduled job should have a next fire time');
    }

    @IsTest
    static void testSchedulerExecutesBatch() {
        // Create test data
        AccountHealthScoreTestFactory.createAccountWithRelatedData(
            'Scheduler Test Account',
            4, 3,   // 75% opp win rate
            4, 3,   // 75% case resolution
            3, 2    // 5 activities = tier 75
        );

        Test.startTest();
        // Directly invoke the scheduler's execute method
        AccountHealthScoreScheduler scheduler = new AccountHealthScoreScheduler();
        scheduler.execute(null);
        Test.stopTest();

        // After stopTest, the batch that the scheduler kicked off has completed
        Account acc = [
            SELECT Health_Score__c FROM Account
            WHERE Name = 'Scheduler Test Account'
        ];

        // Expected: (75 * 0.4) + (75 * 0.3) + (75 * 0.3) = 30 + 22.5 + 22.5 = 75
        System.assertEquals(75, acc.Health_Score__c,
            'Scheduler should trigger batch that updates health scores');
    }

    @IsTest
    static void testSchedulerWithCustomBatchSize() {
        AccountHealthScoreTestFactory.createBareAccount('Custom Batch Size Test');

        Test.startTest();
        AccountHealthScoreScheduler scheduler =
            new AccountHealthScoreScheduler(50);
        scheduler.execute(null);
        Test.stopTest();

        Account acc = [
            SELECT Health_Score__c FROM Account
            WHERE Name = 'Custom Batch Size Test'
        ];

        System.assertNotEquals(null, acc.Health_Score__c,
            'Scheduler with custom batch size should still produce results');
    }

    @IsTest
    static void testAbortScheduledJob() {
        String cronExpression = '0 0 2 * * ?';

        Test.startTest();
        String jobId = System.schedule(
            'Abort Test Schedule',
            cronExpression,
            new AccountHealthScoreScheduler()
        );

        // Abort the job
        System.abortJob(jobId);
        Test.stopTest();

        // Verify the job was aborted
        CronTrigger ct = [
            SELECT State FROM CronTrigger WHERE Id = :jobId
        ];
        System.assertEquals('DELETED', ct.State,
            'Aborted job should be in DELETED state');
    }
}

Key Testing Patterns

There are a few patterns worth highlighting across our test classes.

Test.startTest() and Test.stopTest() as a synchronous boundary. When you call Database.executeBatch or System.schedule between these two calls, Salesforce forces the async work to complete before Test.stopTest() returns. This is the only way to test async Apex in a deterministic way.

@TestSetup for shared data. The @TestSetup method in AccountHealthScoreBatchTest runs once before all test methods in the class. Each test method gets its own database rollback point, so changes made in one test do not affect another.

allOrNone = false in the batch. Because we use Database.update(records, false) in the batch class, our tests can verify that the batch handles partial failures gracefully. You could extend the tests to include a scenario where a validation rule causes some records to fail.

Direct execute() invocation for the scheduler. In testSchedulerExecutesBatch, we call scheduler.execute(null) directly rather than going through System.schedule. This is a valid pattern when you want to test the scheduler’s behavior without testing the CRON scheduling mechanism itself. The separate testSchedulerCreatesJob method covers the scheduling aspect.


Putting It All Together

Here is the sequence of events when this system runs in production:

  1. The CRON schedule fires at 2:00 AM daily.
  2. Salesforce instantiates AccountHealthScoreScheduler and calls its execute method.
  3. The scheduler creates an instance of AccountHealthScoreBatch and calls Database.executeBatch with a batch size of 200.
  4. Salesforce queries all Accounts and divides them into chunks of 200.
  5. For each chunk, the batch’s execute method calls AccountHealthScoreService.calculateHealthScores, which queries related Opportunities, Cases, and Activities, computes the scores, and sets the Health_Score__c field.
  6. The batch performs a partial-success Database.update and records any errors in the stateful tracking variables.
  7. After all chunks are processed, the finish method builds a summary report and emails it to the running user.

This architecture scales to hundreds of thousands of Accounts. The batch framework handles the chunking, and the service class handles bulkified queries within each chunk.


Deployment Checklist

Before deploying this project to a production org, here is what you need:

  1. Custom field: Create Health_Score__c (Number, 3 decimal places 0, length 3) on the Account object.
  2. Deploy classes: Deploy all seven classes — the service, batch, scheduler, test factory, and three test classes.
  3. Run tests: Verify all tests pass with at least 75% code coverage (though you should aim for 90% or higher).
  4. Schedule the job: Run the scheduling script from Anonymous Apex.
  5. Monitor: Check the Scheduled Jobs page and Apex Jobs page after the first run to confirm everything is working as expected.
// Production scheduling script
System.schedule(
    'Nightly Account Health Score Update',
    '0 0 2 * * ?',
    new AccountHealthScoreScheduler()
);

Summary

In this project we built a complete scheduled batch system from scratch. The key takeaways are:

  • Separate concerns into layers. The service class owns the business logic, the batch class owns the orchestration, and the scheduler class owns the timing. Each layer is independently testable and reusable.
  • Use Database.Stateful for cross-batch tracking. When you need to accumulate metrics across batch chunks (counts, error messages, running totals), implement Database.Stateful and be mindful of heap size limits.
  • Handle errors gracefully. Use allOrNone = false for partial DML success, wrap risky operations in try-catch, and log enough detail to diagnose problems after the fact.
  • Know your scheduling options. System.schedule with CRON is the standard for recurring jobs. System.scheduleBatch is a shortcut for one-time delayed batch runs. The Setup UI provides a point-and-click alternative for admins.
  • Test async Apex with Test.startTest/stopTest. This boundary forces async operations to run synchronously, giving you deterministic assertions. Use @TestSetup and test data factories to keep test classes clean and maintainable.

This pattern — service class, batch class, scheduler, and comprehensive tests — is one you will use repeatedly throughout your Salesforce development career. It applies to any scenario where you need to process large volumes of data on a recurring basis.

In the next post, Part 55, we will step back from code and look at The Order of Operations in Salesforce — the exact sequence of events that Salesforce follows when a record is saved. Understanding this sequence is essential for debugging triggers, Flows, validation rules, and all the automation layers that fire during a save operation. See you there.