Apex 单元测试入门
学习目标
完成本单元后,您将能够:
- 描述 Apex 单元测试的主要优势。
- 使用测试方法定义一个类。
- 在同一个类中执行所有测试方法并检查失败情况。
- 创建并执行一组测试类。
Apex 单元测试
Apex 测试框架使您能够在 Lightning 平台上为 Apex 类和触发器编写和执行测试。Apex 单元测试可确保 Apex 代码的高质量,并满足您部署 Apex 的要求。
测试是长期成功开发的关键,也是开发流程的关键组成部分。Apex 测试框架可以轻松测试您的 Apex 代码。Apex 代码只能在 Sandbox 环境或开发人员组织中编写,不能在生产环境中编写。Apex 代码可以从 Sandbox 部署到生产组织。此外,应用程序开发人员通过将软件包上载到 Lightning 平台 AppExchange,实现从开发者组织向客户分发 Apex 代码。除了对质量保证至关重要之外,Apex 单元测试也是部署和分发 Apex 的要求。
这些是 Apex 单元测试的优势。
- 确保您的 Apex 类和触发器按预期工作
- 拥有一套可以在每次类和触发器更新时重新运行的回归测试,以确保未来对应用程序的更新不会破坏现有功能
- 满足将 Apex 部署到生产环境或通过软件包将 Apex 分发给客户的代码覆盖率要求
- 为生产组织交付高质量应用程序,提高生产用户的工作效率
- 为软件包订户交付高质量应用程序,增加客户的信任度
部署代码覆盖率要求
在您为 Lightning 平台 AppExchange 部署或打包代码之前,必须测试至少 75% 的 Apex 代码,并且所有这些测试都必须通过。此外,每个触发器都必须有一定的覆盖范围。尽管代码覆盖率是部署的一项要求,但不要仅仅为了满足此要求而编写测试。确保测试应用中的常见用例,包括正向和反向测试用例,以及批量和单记录处理情况。
测试方法语法
测试方法使用 @isTest
注释定义,语法如下:
@isTest static void testName() { // code_block }
@isTest
注释接受括号内的多个修饰符并用空格分隔。稍后我们将介绍这样一个参数。
测试方法的可见性并不重要,因此将测试方法声明为公用或专用没有区别,因为测试框架始终能够访问测试方法。基于这个原因,语法中省略了访问修饰符。
必须在测试类中定义测试方法,这些类是使用 @isTest
注释的类。此示例类中展示了带有一种测试方法的测试类的定义。
@isTest private class MyTestClass { @isTest static void myTest() { // code_block } }
测试类可以是专用或公用。如果您仅将测试类用于单元测试,请将其声明为专用。公用测试类通常用于测试数据工厂类,稍后会介绍。
单元测试示例:测试 TemperatureConverter 类
以下简单示例是带有三种测试方法的测试类。正在测试的类方法将华氏温度作为输入。类方法将此温度转换为摄氏度并返回转换后的结果。让我们添加自定义类及其测试类。
- 在 Developer Console 中,单击 File(文件)| New(新建)| Apex Class(Apex 类),并输入
TemperatureConverter
作为类名,然后单击 OK(确定)。
- 将默认类主体替换为以下内容。
public class TemperatureConverter { // Takes a Fahrenheit temperature and returns the Celsius equivalent. public static Decimal FahrenheitToCelsius(Decimal fh) { Decimal cs = (fh - 32) * 5/9; return cs.setScale(2); } }
- 按 Ctrl+S 保存您的类。
- 重复前面的步骤,创建
TemperatureConverterTest
类。为该类添加以下内容。@isTest private class TemperatureConverterTest { @isTest static void testWarmTemp() { Decimal celsius = TemperatureConverter.FahrenheitToCelsius(70); System.assertEquals(21.11,celsius); } @isTest static void testFreezingPoint() { Decimal celsius = TemperatureConverter.FahrenheitToCelsius(32); System.assertEquals(0,celsius); } @isTest static void testBoilingPoint() { Decimal celsius = TemperatureConverter.FahrenheitToCelsius(212); System.assertEquals(100,celsius,'Boiling point temperature is not expected.'); } @isTest static void testNegativeTemp() { Decimal celsius = TemperatureConverter.FahrenheitToCelsius(-10); System.assertEquals(-23.33,celsius); } }
TemperatureConverterTest
测试类通过使用华氏温度的不同输入来调用该方法,以验证该方法是否按预期工作。每种测试方法验证一种类型的输入:暖和温度、冰点温度、沸点温度和负温度。通过调用 System.assertEquals()
方法完成验证,该方法采用两个参数:第一个是期望值,第二个是实际值。该方法的另一个版本采用第三个参数,即描述正在进行比较的字符串,该参数在 testBoilingPoint()
中使用。如果断言失败,则记录可选字符串。
让我们在该类中运行这些方法。
- 在 Developer Console 中,单击 Test(测试)| New Run(新运行)。
- 在 Test Classes(测试类)下方,单击 TemperatureConverterTest。
- 要将
TemperatureConverterTest
类中的所有测试方法添加到测试运行中,请单击 Add Selected(添加所选项)。
- 单击运行。
- 在 Tests(测试)选项卡中,您可以看到正在运行的测试的状态。展开测试运行,然后再次展开,您会看到已运行的单个测试列表。这些测试都带着绿色复选标记。
运行测试后,将自动为组织中的 Apex 类和触发器生成代码覆盖率。您可以在 Developer Console 的 Tests(测试)选项卡中查看代码覆盖率百分比。在本示例中,您测试的 TemperatureConverter
类具有 100% 的覆盖率,如下图所示。
虽然一种测试方法会导致 TemperatureConverter
类的全覆盖,但测试不同的输入以确保代码质量仍然很重要。显然,不可能验证每一个数据点,但您可以测试常见的数据点以及不同的输入范围。例如,您可以验证传递正数和负数、边界值和无效参数值,以验证消极行为。TemperatureConverter
类测试验证常见数据点,如沸点温度和负温度。
TemperatureConverterTest
测试类不包括无效输入或边界条件。边界条件关乎最小值和最大值。在这种情况下,温度转换方法接受 Decimal
,它可以接受比 Double
值更高的数值。对于温度而言,没有无效输入,唯一的无效输入是 null。转换方法如何处理这个值?在这种情况下,当 Apex 运行时取消引用参数变量来评估公式时,会抛出 System.NullPointerException
。您可以修改 FahrenheitToCelsius()
方法以检查无效输入,在这种情况下会返回 null,然后添加测试以验证无效输入行为。
到目前为止,所有测试都通过了,因为类方法中使用的转换公式是正确的。但这看起来有点枯燥!让我们尝试模拟失败场景,看看当断言失败时会发生什么。例如,让我们修改沸点温度测试,并为沸点摄氏温度传入一个错误的预期值(0 而不是 100)。这会导致相应的测试方法失败。
- 将
testBoilingPoint()
测试方法更改为以下内容。@isTest static void testBoilingPoint() { Decimal celsius = TemperatureConverter.FahrenheitToCelsius(212); // Simulate failure System.assertEquals(0,celsius,'Boiling point temperature is not expected.'); }
- 要执行相同的测试,请单击 Tests(测试)选项卡中的最新运行,然后单击 Test(测试) | Rerun(重新运行)。
testBoilingPoint()
断言失败并抛出致命错误(无法捕获的AssertException
)。
- 通过展开最新的测试运行,检查 Tests(测试)选项卡中的结果。测试运行报告四分之一的测试失败。要获取有关失败的更多详细信息,请双击测试运行。详细结果将显示在单独的选项卡中,如下图所示。
[Alt text:在 Developer Console 中检查失败测试的结果]
- 要获取测试失败的错误提示,请在失败测试的 Errors(错误)列内双击。您将看到以下内容:
Assertion Failed:
(断言失败:)旁边的描述性文本是我们在System.assertEquals()
语句中提供的文本。System.AssertException:Assertion Failed:Boiling point temperature is not expected.:Expected:0, Actual:100.00
这些测试方法中的测试数据是数字而不是 Salesforce 记录。您将在下一单元中了解有关如何测试 Salesforce 记录以及如何建立数据的更多信息。
提高代码覆盖率
在编写测试时,尽可能实现最高的代码覆盖率。不要只瞄准 75% 的覆盖率,这是 Lightning 平台部署和软件包所需的最低覆盖率。测试涵盖的测试用例越多,代码稳健的可能性就越大。有时,即使您为所有类方法编写了测试方法,代码覆盖率也不是 100%。常见原因是没有涵盖条件代码执行的所有数据值。例如,当您的类方法包含根据是否满足评估条件导致执行不同分支的 if 语句时,某些数据值往往会被忽略。确保您的测试方法考虑到这些不同的值。
此示例包括类方法 getTaskPriority()
,它包含了两个 if
语句。此方法的主要任务是根据给定的潜在客户状态返回优先级字符串值。该方法首先验证状态,如果状态无效则返回 null。如果状态是 Ca,该方法返回 High(高);否则,对任何其他状态值返回 Normal(正常)。
public class TaskUtil { public static String getTaskPriority(String leadState) { // Validate input if(String.isBlank(leadState) || leadState.length() > 2) { return null; } String taskPriority; if(leadState == 'CA') { taskPriority = 'High'; } else { taskPriority = 'Normal'; } return taskPriority; } }
这是 getTaskPriority()
方法的测试类。该测试方法只是调用具有一种状态 ('NY') 的 getTaskPriority()
。
@isTest private class TaskUtilTest { @isTest static void testTaskPriority() { String pri = TaskUtil.getTaskPriority('NY'); System.assertEquals('Normal', pri); } }
让我们在 Developer Console 中运行这个测试类 (TaskUtilTest
),并检查这个测试涵盖的相应 TaskUtil
类的代码覆盖率。测试运行完成后,TaskUtil
的代码覆盖率显示为 75%。如果在 Developer Console 中打开这个类,您会看到 6 行蓝色(覆盖)和 2 行红色(未覆盖),如下图所示。
没有覆盖第 5 行的原因是我们的测试类不包含传递无效状态参数的测试。同理,第 11 行未覆盖的原因是状态为 'CA’ 的测试方法没有通过。让我们再添加两个测试方法来覆盖这些场景。以下展示了添加 testTaskHighPriority()
和 testTaskPriorityInvalid()
测试方法后的完整测试类。如果您用运行所有或 New Run(新运行)重新运行这个测试类,会发现 TaskUtil
的代码覆盖率已达到 100%!
@isTest private class TaskUtilTest { @isTest static void testTaskPriority() { String pri = TaskUtil.getTaskPriority('NY'); System.assertEquals('Normal', pri); } @isTest static void testTaskHighPriority() { String pri = TaskUtil.getTaskPriority('CA'); System.assertEquals('High', pri); } @isTest static void testTaskPriorityInvalid() { String pri = TaskUtil.getTaskPriority('Montana'); System.assertEquals(null, pri); } }
创建并执行测试套件
测试套件是您运行 Apex 测试类的集合。例如,创建测试套件,您每次准备部署或 Salesforce 发布新版本时都会运行这些测试。在 Developer Console 中设置测试套件,以定义一组您定期执行的测试类。
现在您的组织中有两个测试类。这两个类没有关系,但是我们可以暂时假设它们是相互关联的两个类。假设在某些情况下,您想运行这两个测试类但不想运行组织中的所有测试。创建一个包含这两个类的测试套件,然后在套件中执行测试。
- 在 Developer Console 中,选择 Test(测试)| New Suite(新套件)。
- 输入
TempConverterTaskUtilSuite
作为套件名称,然后单击 OK(确定)。
- 选择 TaskUtilTest,按住 Ctrl 键,然后选择 TemperatureConverterTest。
- 如需在套件中添加选定的测试类,请单击 >。
- 单击保存。
- 选择 Test(测试)| New Suite Run(新套件运行)。
- 选择 TempConverterTaskUtilSuite,然后单击 >,将
TempConverterTaskUtilSuite
移至 Selected Test Suites(选定测试套件)列。
- 单击 Run Suites(运行套件)。
- 在 Tests(测试)选项卡上,监控正在运行的测试所处的状态。展开测试运行,然后再次展开,您会看到已运行的单个测试列表。与运行单个测试方法一样,您可以双击方法名称以查看详细的测试结果。
创建测试数据
在测试方法中创建的 Salesforce 记录不会提交到数据库。测试执行完成后,回滚 Salesforce 记录。回滚行为对测试而言很方便,原因是您不必在测试执行后清理测试数据。
默认情况下,Apex 测试除拥有访问设置和元数据对象(例如用户或配置文件对象)权限以外,无权访问组织中预先存在的数据。为测试建立测试数据。创建测试数据可以让您的测试更加稳健,并防止由于组织中的数据丢失或更改导致的失败。稍后您会发现,您可以直接在测试方法中或使用实用程序测试类创建测试数据。
了解详细信息
- 您可以使用 Salesforce Apex Extension for Visual Studio Code 来运行 Apex 测试并验证代码的功能。
- 您最多可以在每个组织中保存 6 MB 的 Apex 代码。用
@isTest
注释的测试类不计入此限制。
- 即使测试数据回滚,也不会使用单独的数据库进行测试。因此,对于某些具有唯一约束字段的 sObject,插入重复的 sObject 记录会导致错误。
- 测试方法不发送电子邮件。
- 测试方法不能调用外部服务。您可以在测试中模拟调用。
- 在测试中执行的 SOSL 搜索返回空结果。为了确保可预测的结果,请使用
Test.setFixedSearchResults()
定义搜索要返回的记录。
资源
准备好迎接实践挑战
为了完成本单元的实践挑战,您需要使用从下面复制的代码创建一个名为 VerifyDate
的新 Apex 类:
public class VerifyDate { //method to handle potential checks against two dates public static Date CheckDates(Date date1, Date date2) { //if date2 is within the next 30 days of date1, use date2. Otherwise use the end of the month if(DateWithin30Days(date1,date2)) { return date2; } else { return SetEndOfMonthDate(date1); } } //method to check if date2 is within the next 30 days of date1 private static Boolean DateWithin30Days(Date date1, Date date2) { //check for date2 being in the past if( date2 < date1) { return false; } //check that date2 is within (>=) 30 days of date1 Date date30Days = date1.addDays(30); //create a date 30 days away from date1 if( date2 >= date30Days ) { return false; } else { return true; } } //method to return the end of the month of a given date private static Date SetEndOfMonthDate(Date date1) { Integer totalDays = Date.daysInMonth(date1.year(), date1.month()); Date lastDay = Date.newInstance(date1.year(), date1.month(), totalDays); return lastDay; } }