学习目标
完成本单元后,你将能够:
- 解释正向测试为什么重要,并编写正向单元测试。
- 解释什么是负向单元测试,并编写负向单元测试。
编写正向测试
正向单元测试是指在向被测代码传入有效数据时,返回预期结果的测试。以经典计算器为例:加法方法的正向测试会用数据点 2 和 3 执行,并断言输出为 5。思想很简单——用有效数据执行时,被测代码应产生可预测的结果。
正向测试模式
正向单元测试的核心是:当输入数据已知良好时,看到一组预期结果。标准模式包含以下步骤:
- 使用数据工厂或 CSV 文件准备数据。
- 做断言来验证数据。
- 通过
Test.startTest()重置 governor limits,将测试与测试数据准备隔离。 - 执行被测代码。
- 通过
Test.stopTest()强制异步代码完成。 - 断言被测代码的输出(例如记录存在、字段为预期值)。
以 AccountWrapper 类为例,它是 Account 对象之上的自定义逻辑层,用于计算账户关联商机的四舍五入平均金额。
安装 VS Code 并连接 Playground
本模块中的测试通过 VS Code 执行。先完成「Apex Testing: Prepare for Unit Testing」徽章,把 VS Code 和 Trailhead Playground 设置好,并使用 VSCodeQuickstart 项目来完成本徽章。
安装非托管包
在 Trailhead Playground 中安装一个包含可测试代码类的非托管包。包 ID 为 04taj000000BcRl。安装的包中包含一个名为 AccountWrapper.cls 的类,接下来为它编写正向测试。
代码要点:正向测试
这个测试类使用 @TestSetup 方法加载测试数据,并调用工厂方法为每个账户生成五个商机。有了账户和商机,就可以测试 AccountWrapper 的 getRoundedAvgPriceOfOpps 方法——它返回该账户所有商机、四舍五入到最近千美元的平均价格。正向测试的数据在逻辑上与方法需求一致(商机金额为正值、平均值可预测)。正向测试的一条准则:给好的代码好的输入,它就会产生预期的结果。
编写负向测试
负向单元测试不仅用于确保每一行代码都被测试,有时甚至比正向测试更重要。负向测试证明代码能正确处理无效数据、意外的用户输入,以及代码库其他部分的变化;更重要的是,它证明你的代码是容错的。
测试代码如何处理异常,与确保代码在有效条件下按预期工作同样重要。想象一个批处理进程:每天凌晨 2 点处理客户社区提交的账户更新。如果代码在批处理第一条记录上失败、又没有优雅地处理异常,剩余记录就不会被处理。负向测试能防止这种情况发生。
负向测试模式
负向测试的一般模式如下:
- 生成或加载测试数据。
- 开启 try/catch 块。
- 调用
Test.startTest(),执行代码,再调用Test.stopTest()。 - 在下一行加一个永远失败的断言(
Assert.fail())——该语句不应被到达。 - 捕获预期的异常,并验证异常消息是否匹配。
AccountWrapper 类中有一行会抛出异常,测试正是针对它来验证:把商机金额设为 0,调用 getRoundedAvgPriceOfOpps 应抛出 AWException。
代码要点:负向测试
负向测试基于 @TestSetup 创建的商机,用 for 循环把每个商机的金额字段设为 0。这样 getRoundedAvgPriceOfOpps 计算出四舍五入价格为 0,导致代码抛出 AWException(在 AccountWrapper 中定义的自定义异常)。
try/catch 块若写得不好,catch 可能捕获任何异常实例或子类。最佳实践是:捕获异常后,检查异常类型、消息和详情是否符合预期。如果只捕获异常而不校验类型和消息,测试就可能「假通过」(false positive),侵蚀对测试的信任。
为动手挑战做准备
在 VS Code 中,每次运行一个或多个测试时,都可以获取 Apex 类和触发器的代码覆盖率。编辑用户或工作区设置,搜索并勾选 retrieve-test-code-coverage,然后运行 Apex 测试。之后就能在 Output 面板中看到每个 Apex 类和触发器的覆盖率百分比,以及未被测试覆盖的行。
创建专门测试
本单元将编写针对 Apex 触发器和基于权限场景的专门测试。
学习目标
完成本单元后,你将能够:
- 为触发单条记录操作的触发器编写测试。
- 执行类中的所有测试方法。
- 解释基于权限测试的重要性,并编写基于权限的单元测试。
测试 Apex 触发器
部署触发器前,要编写测试来执行触发该触发器的操作并验证预期结果。触发器测试不完全是单元测试——它测的不是方法做了什么,而是 DML 操作发生时触发器的行为,因此更准确地说是集成测试。
最佳实践是把所有触发器逻辑封装到一个类(通常叫 trigger handler)中,在那里编写真正的单元测试;同时也要编写如下的触发器集成测试。以 AccountDeletion 触发器为例:当账户有关联商机时,阻止删除该记录。测试通过 Database.delete(acct, false) 删除账户,并断言返回的 DeleteResult 不成功、错误消息为「Cannot delete account with related opportunities.」。
代码要点:触发器测试
测试方法先准备一个带商机的测试账户,然后删除该账户,触发 AccountDeletionTrigger。测试通过检查 Database.delete 的返回值(Database.DeleteResult 对象)验证触发器阻止了删除:断言删除未成功,并校验返回的错误消息。
测试不同条件
一个测试方法不足以覆盖触发器的所有可能输入。还需要测试其他条件:例如删除没有商机的账户(应成功删除),以及用批量记录(而非单条)测试相同场景。更新测试类,加入这三个额外测试方法,然后对 AccountDeletionTriggerTests 类运行 Run All Tests。
测试基于权限的场景
基于权限的测试可能是最复杂的测试模式:一方面权限本身容易混淆,另一方面一套好的权限测试需要同时用到正向和负向测试模式。写权限测试时,不仅要生成测试数据,还要创建一或多个测试用户;之后以这些测试用户身份(有或没有特定权限)运行正/负向测试。
基于权限的测试模式
权限测试的模式如下:
- 生成或加载测试数据。
- 创建带相应权限集的用户。
- 开启
System.runAs(user)块。 - 在
System.runAs(user)块内执行负向或正向测试。
权限测试是唯一不需要创建自己测试数据的情况——权限集记录本质是元数据而非数据,属于组织配置,因此测试时使用现有权限集即可。测试里只需创建一个测试用户并为其分配现有权限集。
代码要点:权限测试
示例中,@TestSetup 创建了一个 Private_Object__c 记录(默认共享设置为私有)。在测试方法中创建新用户,并以 System.runAs(user) 查询该对象——因为没有权限集,新用户看不到任何记录,断言结果数量为 0。这演示了没有权限集的用户无法访问私有对象记录。
使用 Mock 和 Stub 对象
本单元介绍 mock 和 stub 对象——更高级的单元测试技巧,让测试更易编写、理解和维护。
学习目标
完成本单元后,你将能够:
- 解释什么是 mock 和 stub。
- 描述何时使用 mock 和 stub。
- 使用 mock 和 stub 编写单元测试。
理解 Mock 和 Stub 对象
Mock 和 stub 是单元测试中较高级的话题,但极其实用——它们让测试更易编写、理解和维护,并把被测代码与代码库其他部分的变化隔离开来。
它们统称 mock 对象,作用相同:都是替代真实对象实例的假对象,因此可以覆盖其功能、返回我们选择的数据。技术上有细微差别:mock 作用于对象层面,stub 替换单个方法。在 Salesforce Platform 上,开发者通过扩展平台接口来写 mock 和 stub,例如实现 HttpCalloutMock 接口创建 HTTP 响应 mock。创建 mock 对象是为了把被测代码与组织中其他代码(第三方代码、服务、其他类)隔离开。
Mock 和 Stub 测试模式
Mock 对象有两个经典用例:第一个较专门——当从 Apex 向第三方 Web 服务发起 callout 时,需要 mock HTTP callout;第二个更通用——当测试依赖另一个对象内部状态或实现的代码时,用 stub 对象测试非常有用。
Mock 对象模式:HttpCalloutMock
HttpCalloutMock 的模式:
- 创建一个实现
HttpCalloutMock接口的类(例如HTTPMockFactory)。 - 创建或加载测试数据。
- 创建 mock 实例,调用
Test.setMock(instance)传入该实例。 - 调用
Test.startTest(),执行发起 callout 的代码,再调用Test.stopTest()。 - 断言代码按预期工作。
Stub 对象模式
使用 stub 对象的模式类似:
- 创建或加载测试数据。
- 创建实现
StubProvider接口的类的实例。 - 调用
Test.startTest(),执行被测代码并传入 stub 实例,再调用Test.stopTest()。 - 断言代码按预期工作。
创建 HTTPMockFactory 类
创建一个 @IsTest 的 HTTPMockFactory 类,实现 HttpCalloutMock 接口。其构造函数接收 code、status、body 和 responseHeaders 参数,respond 方法用这些参数构造并返回一个 HttpResponse 对象。
代码要点:HTTPMockFactory
该类的构造函数接收的参数会在 respond 方法中传回。就像数据用的 TestFactory 一样,这个工厂让我们能在测试中即时定义 mock 的返回内容。
测试:ExternalSearch 与 HTTP Mock
安装的包中有一个 ExternalSearch.cls 类,它接收搜索字符串并执行 Web 搜索。为它写单元测试:创建 HTTPMockFactory 实例(返回 200、OK、「I found it!」),调用 Test.setMock(HttpCalloutMock.class, mock),然后调用 ExternalSearch.googleIt('epic search'),断言返回值为「I found it!」。
代码要点:ExternalSearch 测试
这个测试的关键是调用 Test.setMock()——它确保代码永远不会真正发起 callout,而是返回你指定的 HttpResponse。在这里,googleIt 方法返回「I found it!」。把返回值注入 mock 工厂的能力,让你能轻松编写正向和负向测试。
Stubbing(存根)
Stubbing 与 mocking 类似,但更灵活。安装的包中有一个 OpportunityDiscount.cls 类,其 getTotalDiscount 方法根据账户是否高优先级来确定商机的总折扣。因为 isHighPriority 的实现可能随时间变化,测试 getTotalDiscount 时不想依赖它的内部实现——这正是为 AccountWrapper 创建 stub 的绝佳场景。
测试通过 Test.createStub(AccountWrapper.class, new AccountWrapperMock()) 创建 stub,注入 OpportunityDiscount,分别验证低优先级(返回 .1)和高优先级(返回 .25)两种情况。
代码要点:Stub 测试
这两个测试方法结构几乎相同:创建 AccountWrapper 的 mock 实例,注入 OpportunityDiscount 对象替代真实对象,于是我们能精确知道 isHighPriority 被调用时的响应。
关键区别在第二个测试的第一行:在 AccountWrapperMock 上设置静态变量。这是个实用技巧——当被 stub 的方法不接收参数时,可以用 stub 类中定义的静态变量在多个测试中有意改变 stub 的行为。
总结
本模块信息量很大:你学习了正向测试、负向测试、权限测试,以及 mocking 和 stubbing。这些模式和工具共同构成了代码库中富有生产力的测试,也能帮你成为更好的开发者——因为写测试会开始塑造你写代码的方式。记住,内化这些思想和实践的最好方法,就是写更多的测试!






























