Salesforce Apex 测试类全面指南:最佳实践与代码示例


身份:Salesforce 开发人员

大家好,我是一名 Salesforce 开发人员。在日常工作中,编写健壮、可维护的 Apex 代码是我们的核心职责之一。而要确保代码质量,没有什么比编写全面、有效的 Apex 测试类更重要了。今天,我想从开发人员的视角,深入探讨 Apex 测试类(Apex Test Classes)的方方面面,分享一些原理、最佳实践和实用的代码示例。


背景与应用场景

在 Salesforce 平台,Apex 测试类并不仅仅是一个“推荐”的实践,它是一项强制性要求。任何希望部署到生产环境(Production Environment)的 Apex 代码(包括 Triggers 和 Classes),都必须伴随有相应的测试类,并且这些测试必须覆盖至少 75% 的代码行。如果达不到这个覆盖率,部署将会失败。

但我们编写测试类的目的远不止于满足平台的部署门槛。它的核心价值在于:

  • 质量保证:通过单元测试(Unit Testing),我们可以验证代码的每个独立部分是否按预期工作,从而在开发早期就发现并修复缺陷(Bugs)。
  • 回归预防:当我们需要修改现有功能或进行系统升级时,运行完整的测试套件可以确保我们的改动没有意外地破坏其他功能。这就像一张安全网,让我们能自信地进行重构和迭代。
  • 设计驱动:遵循测试驱动开发(Test-Driven Development, TDD)的理念,先编写测试用例再编写业务逻辑,可以帮助我们设计出更清晰、更模块化、更易于测试的代码结构。
  • 动态文档:一个好的测试类本身就是一份“活文档”。其他开发人员可以通过阅读测试代码,快速理解业务逻辑的预期行为、边界条件和使用方法。

作为开发人员,我们应该将测试视为编码过程中不可或缺的一部分,而不是事后为了满足覆盖率而补写的东西。一个没有经过充分测试的功能,就像一座地基不稳的大楼,随时可能在压力下坍塌。

原理说明

要写好 Apex 测试类,首先需要理解其核心原理和构成要素。

1. @isTest 注解

所有测试类都必须使用 @isTest 注解进行标记。这个注解告诉 Salesforce 平台,这个类是用于测试的,它不会计入组织的 Apex 代码总量限制。同样,测试方法也需要使用 @isTest 注解(或者旧版的 testMethod 关键字)来标记。

@isTest
private class MyTestClass {
    @isTest
    static void myTestMethod() {
        // 测试逻辑
    }
}

2. 数据隔离与测试数据创建

这是一个至关重要的概念。默认情况下,Apex 测试方法无法访问组织中的任何现有数据(如 Account, Contact 记录)。这种设计确保了测试的独立性和可重复性,测试结果不会因为生产数据的变化而受到影响。因此,我们必须在测试方法内部或者使用特定的测试设置方法来创建所有需要的测试数据。

  • @testSetup 方法:如果一个测试类中的多个测试方法需要使用相同的初始数据,最佳实践是使用 @testSetup 注解的方法。这个方法在类中所有其他测试方法执行之前仅运行一次,它创建的数据会对该类中的所有测试方法可见。这极大地提高了测试的执行效率,因为无需在每个测试方法中重复创建数据。
  • 测试数据工厂(Test Data Factory):对于复杂的、需要被多个测试类复用的数据创建逻辑,我们通常会创建一个专门的、可复用的工具类,即测试数据工厂。这个工厂类提供简单的方法来创建各种场景下的 SObject 记录,使测试代码更简洁、更易于维护。

3. Test.startTest() 和 Test.stopTest()

这对方法非常关键,它们用于界定测试的核心逻辑。Test.startTest()Test.stopTest() 之间包裹的代码块会获得一组全新的、独立的 गवर्नर限制(Governor Limits)。这对于测试那些消耗大量资源(如 SOQL 查询、DML 操作)的代码尤为重要。

更重要的是,所有在 Test.startTest()Test.stopTest() 之间调用的异步 Apex(如 @future 方法、队列任务 Queueable Apex、批处理任务 Batch Apex)会在 Test.stopTest() 执行后立即同步执行完毕。这使得我们能够直接在测试方法中断言异步操作的结果,而无需等待。

4. 断言(Assertions)

一个没有断言的测试方法是没有意义的。它仅仅是执行了代码,却没有验证结果是否正确。断言是测试的“灵魂”,用于验证代码的实际输出是否与预期输出相符。如果断言失败,整个测试方法也会失败。

  • System.assertEquals(expected, actual, msg):验证两个值是否相等。
  • System.assertNotEquals(unexpected, actual, msg):验证两个值是否不相等。
  • System.assert(condition, msg):验证一个布尔条件是否为 true。

我们应该始终为代码的关键业务成果编写断言。

5. 在特定用户上下文中运行

有时,我们需要测试代码在不同简档(Profile)或权限集(Permission Set)的用户下是如何运行的。System.runAs(user) 方法块允许我们实现这一点。块内的所有代码都将以指定用户的身份和权限来执行,这对于测试共享规则、字段级安全(Field-Level Security)等至关重要。

示例代码

以下示例均来自 Salesforce 官方文档,展示了上述原理的实际应用。

示例 1:基础测试类与断言

假设我们有一个名为 TemperatureConverter 的类,它有一个方法可以将华氏度转换为摄氏度。我们需要为它编写一个测试类。

// The class to be tested
public class TemperatureConverter {
    public static Decimal FahrenheitToCelsius(Decimal fahrenheit) {
        Decimal celsius = (fahrenheit - 32) * 5/9;
        return celsius.setScale(2);
    }
}

// The test class for the TemperatureConverter class
@isTest
private class TemperatureConverterTest {
    @isTest static void testWarmTemp() {
        // Call the method to be tested
        Decimal celsius = TemperatureConverter.FahrenheitToCelsius(70);
        // Assert that the result is the expected value
        System.assertEquals(21.11, celsius, 'Incorrect celsius value.');
    }

    @isTest static void testFreezingPoint() {
        Decimal celsius = TemperatureConverter.FahrenheitToCelsius(32);
        System.assertEquals(0, celsius, 'Incorrect celsius value.');
    }

    @isTest static void testBoilingPoint() {
        Decimal celsius = TemperatureConverter.FahrenheitToCelsius(212);
        System.assertEquals(100, celsius, 'Incorrect celsius value.');
    }
}

注释:这个例子非常直观。TemperatureConverterTest 类测试了 TemperatureConverter 类的 FahrenheitToCelsius 方法。我们为不同的输入(华氏 70 度、冰点 32 度、沸点 212 度)编写了独立的测试方法,并使用 System.assertEquals() 来验证返回的摄氏度值是否正确。

示例 2:使用 @testSetup 和 DML 操作

这个例子演示了如何使用 @testSetup 为测试方法准备数据。假设我们有一个触发器,当客户(Account)的某个字段更新时,会级联更新其下的所有联系人(Contact)。

@isTest
private class TestAccountDelete {
    // testSetup 方法,用于创建所有测试方法共用的测试数据
    @isTestSetup static void setup() {
        // 创建一个客户列表
        List testAccts = new List();
        for(Integer i=0; i < 2; i++) {
            testAccts.add(new Account(Name = 'TestAcct' + i));
        }
        insert testAccts;

        // 为每个客户创建联系人
        List testContacts = new List();
        for (Integer i=0; i < 10; i++) {
            testContacts.add(new Contact(FirstName='Test',
                                         LastName='Contact'+i,
                                         AccountId=testAccts[0].Id));
        }
        insert testContacts;
    }

    @isTest static void testDeleteAccountWithContacts() {
        // 在 testSetup 中创建的数据在这里是可见的
        // 查询 testSetup 中创建的客户
        Account acct = [SELECT Id FROM Account WHERE Name='TestAcct0' LIMIT 1];

        // 开始测试,并获取一组新的 Governor 限制
        Test.startTest();
        // 执行 DML 操作
        Database.delete(acct.Id);
        Test.stopTest();

        // 验证结果:查询是否还有关联的联系人
        // 由于级联删除,预期结果是 0
        List cons = [SELECT Id FROM Contact WHERE AccountId = :acct.Id];
        System.assertEquals(0, cons.size(), 'Contacts were not deleted.');
    }
}

注释:setup() 方法首先被执行,创建了2个客户和10个联系人。然后,testDeleteAccountWithContacts() 方法执行时,可以直接查询和使用这些数据。我们将删除操作包裹在 Test.startTest()Test.stopTest() 之间,并在之后通过 SOQL 查询和断言来验证联系人是否被成功级联删除了。

示例 3:测试异步 Apex (@future)

这个例子展示了如何测试一个 future 方法。Future 方法用于执行异步操作,如 API 调用。

// Class with a future method
public class MyFutureClass {
    @future
    public static void updateAccounts(List ids) {
        List accts = [SELECT Id, Name FROM Account WHERE Id IN :ids];
        for (Account a : accts) {
            a.Name = a.Name + ' (Updated)';
        }
        update accts;
    }
}

// Test class for the future method
@isTest
private class MyFutureClassTest {
    @isTest static void testUpdateAccounts() {
        // 准备测试数据
        List testAccts = new List();
        for (Integer i = 0; i < 5; i++) {
            testAccts.add(new Account(Name = 'TestAcct' + i));
        }
        insert testAccts;

        List ids = new List();
        for (Account a : testAccts) {
            ids.add(a.Id);
        }

        // 将异步方法调用包裹在 startTest 和 stopTest 之间
        Test.startTest();
        // 调用 future 方法,此时它会被放入队列
        MyFutureClass.updateAccounts(ids);
        // 执行 stopTest 后,队列中的异步任务会立即完成
        Test.stopTest();

        // 验证异步操作的结果
        List updatedAccts = [SELECT Name FROM Account WHERE Id IN :ids];
        for (Account a : updatedAccts) {
            System.assert(a.Name.endsWith('(Updated)'), 'Account name was not updated correctly.');
        }
    }
}

注释:我们首先创建了5个客户记录。然后,在 Test.startTest()Test.stopTest() 之间调用了 future 方法 MyFutureClass.updateAccounts()。在 Test.stopTest() 执行完毕后,我们可以立即查询这些客户,并断言它们的名字是否已经被成功追加了“(Updated)”后缀。

注意事项

  • 批量化测试(Bulkification):始终要测试你的代码能否处理批量数据。Salesforce 的触发器和流程经常会处理 200 条记录的批次。因此,你的测试数据创建逻辑应该模拟这种情况,例如,在一个列表中创建 200 条记录然后一次性插入,以确保没有触碰 Governor 限制。
  • 避免硬编码 ID(Hardcoding IDs):绝不在测试类中硬编码任何记录的 ID(如 RecordType ID, User ID, Profile ID)。这些 ID 在不同组织中是不同的,会导致测试在部署时失败。应该在代码中动态查询它们。
  • 避免使用 `(SeeAllData=true)`:使用 @isTest(SeeAllData=true) 注解会让测试类访问组织中的真实数据,这是一种非常不好的实践。它会使测试变得脆弱、不可预测,并可能在不同环境中产生不同的结果。只有在极少数情况下(如测试依赖于标准价格手册这样无法在测试中创建的数据)才考虑使用,并且需要充分的理由。
  • 测试正面与负面场景:一个完整的测试套件不仅要测试“快乐路径”(即一切按预期进行),还必须测试异常和边界情况。例如:当输入为空时会发生什么?当用户缺少必要权限时会怎样?当触发 DML 异常时代码能否优雅地处理?可以使用 try-catch 块来捕获预期的异常并进行断言。
  • 模拟 Callout:默认情况下,测试方法不能执行对外部服务的 HTTP Callout。为了测试这类逻辑,必须使用 Apex 提供的模拟框架(Mocking Framework),通过实现 HttpCalloutMock 接口来提供一个虚拟的响应。

总结与最佳实践

作为 Salesforce 开发人员,我们必须将测试视为一等公民。编写高质量的测试不仅是为了满足 75% 的覆盖率要求,更是为了构建稳定、可靠和易于维护的应用程序。

以下是一些关键的最佳实践总结:

  1. 一个测试方法只测一件事:保持测试方法的专注和简洁。方法名应清晰地描述它正在测试的场景,例如 testAccountUpdate_WithValidData_ShouldSucceed()
  2. 使用测试数据工厂:将数据创建逻辑抽象到可复用的工厂类中,保持测试代码的整洁和 DRY (Don't Repeat Yourself)。
  3. 善用 @testSetup:对于所有测试方法共享的通用数据,始终使用 @testSetup 来提高性能。
  4. 始终使用 Test.startTest() 和 Test.stopTest():即使不测试异步代码,它也能帮助隔离你要测试的核心逻辑,并确保 governor 限制的准确性。
  5. 断言,断言,再断言:确保每个测试方法都包含有意义的断言,以验证业务逻辑的正确性。覆盖率只是数字,断言才是质量的保证。
  6. 全面覆盖场景:除了正面路径,还要测试负面路径、批量操作、不同用户权限等多种场景。

遵循这些原则,你编写的 Apex 测试类将不再是部署的负担,而是你代码库中最有价值的资产之一。

评论

此博客中的热门博文

Salesforce 登录取证:深入解析用户访问监控与安全

Salesforce Experience Cloud 技术深度解析:构建社区站点 (Community Sites)

精通 Salesforce Email Studio:咨询顾问指南之 AMPscript 与数据扩展实现动态个性化邮件