使缓慢的测试变得更快的9种方法

133 阅读19分钟

从开发人员到测试人员,从业务分析员到管理层,你的组织中的每个人都必须致力于保持测试的最佳状态。如果你有一个广泛的测试套件,你将需要一个计划来集中精力。如果你不确定从哪里开始,请查看我们的识别和优化缓慢测试的5步框架

软件开发是由支持它的工具来调节的。其中,测试是最广泛的,也是影响最大的。保持测试的快速和响应,可以提高生产力,改善代码质量,并提高部署频率。

当测试速度减慢时,开发也跟着减慢。当团队无法达到他们的目标和组织的部署速度时,他们会感到沮丧。在Semaphore,我们已经看到了我们的测试,并确定了导致测试速度下降的九个因素。

测试应该有多快?

测试首先是由开发人员消费的,他们在提交修改之前首先在机器上运行测试。快速的测试可以保持开发人员的生产力,使他们能够保持创造性的注意力,这对于解决问题是非常重要的。

从这个角度来看,测试套件花费的时间比伸展双腿和喝杯咖啡的时间还长,这就太慢了

开发人员应该能够在本地运行测试--至少是与他们正在工作的代码直接相关的那部分套件。一个快速和易于使用的测试套件将鼓励开发者在他们的修改之前、期间和之后运行测试。

测试也会影响CI/CD的速度。他们花的时间越长,你的管道就越慢。这很重要,因为管道决定了你可以多长时间发布一次。一个缓慢的管道会扰乱开发周期。

更准确地说,除非你的管道花费少于10分钟,否则你就不是真正在做持续集成。因为测试是在流水线上运行的,所以它们应该在这10分钟的时间内舒适地完成:

The CI/CD pipeline

让慢速测试重新变得快速

我们如何解决缓慢的测试?我们又如何加速CI/CD管道?下面是九个最常见的性能问题和它们的解决方案:

  1. 我的测试太大:把它们分开。
  2. 我的测试有多个依赖关系:用存根或嘲弄来隔离测试。
  3. 我的测试是紧密耦合的:重构它们,使其独立。
  4. 我有过时的测试:删除死代码和过时的测试。
  5. 我的测试使用sleepwait用同步机制替换睡眠语句。
  6. 我的测试使用了一个数据库:确保查询被优化。
  7. 我的测试总是通过UI:减少UI交互。例如,如果有一个API可用,就测试它。
  8. 我通过用户界面来设置我的测试:使用带外通道来设置和拆解测试。
  9. 我的测试涵盖了用户界面的每一个边缘情况:只关注用户最关键的路径。

打破大型测试

  • 问题:大型测试需要很长的时间来完成,很难维护,而且不能利用并发的优势。
  • 解决方法分而治之。将测试套件分成小单元,并在你的管道中配置并行作业。

长的、单线程的测试不能从现代机器配备的许多内核中受益。让我们看一下下面的伪代码,它以三种不同的方式测试一个函数:

// tests 1 to 3
setup()
value1 = function(input1)
assert value1 == output1
cleanup()

setup()
value2 = function(input2)
assert value2 == output2
cleanup()

setup()
value3 = function(input3)
assert value3 == output3
cleanup()

将这个测试分解开来可以使其速度提高3倍。根据你的测试方案,这可能很简单,只要将代码分布在三个文件中,并设置你的框架以使用所有可用的CPU。

测试1测试2测试3
setup()setup()setup()
value1 = function(input1)value2 = function(input2)value3 = function(input3)
assert value1 == output1assert value2 == output2assert value3 == output3
cleanup()cleanup()cleanup()

然而,并行化并不是没有危险的。依次运行的测试确实需要更长的时间,但更容易让人理解。另一方面,并发是复杂的。如果测试有副作用并且没有被隔离,它可能会产生意想不到的结果。一个小错误会导致难以调试的问题、竞赛条件和不稳定的测试

The truth about multithreaded programming

CI/CD中的并行工作

如果你在这篇文章中只带走了一件事,那就是:并行化以最少的努力获得最直接的好处--主要是在应用于CI/CD管道时--尽管在实施中存在上述障碍。一旦并行化不再具有成本效益,继续执行文章中的其他建议。

Parallelization helps speeding up slow tests

分割测试开辟了一个全新的优化层次。像Semaphore这样的云服务允许人们将持续集成和交付扩展到一台机器之外:

Speed up a slow test by breaking it into two jobs

让测试可以独立运行

  • 问题:紧密耦合的测试破坏了速度,使重构变得困难。
  • 解决方案:解耦组件,使它们可以独立测试。不要破坏测试封装。

设计可测试的代码意味着构建小的、孤立的和解耦的组件。我们已经看到,并行化可以帮助我们减少测试运行时间。分离组件是这个过程中的一个必要部分。

测试在保持与实现的距离的前提下增加价值。当测试与实现结合得太紧密时,它们会更容易损坏,而且是出于错误的原因。测试应该验证一个外部行为,而不是内部如何工作。耦合度过高的测试会阻碍重构的进行。

想象一下,我们有一个代表购物车的类,如下图所示:

class ShoppingCart {

	Item _lastItemAdded;
	Item[] _cartContents;
	float _totalSum;

	function AddItem(Item) {
		_lastItemAdded = Item;
		_cartContents.Add(Item);
		_totalSum =+ Item.cost;
	}

	function getTotal() {
		return _totalSum;
	}

	function getItem(index) {
		return _cartContents[index];
	}
}

我们写了下面的测试:

// test item added
item = new Item("bottle", 10.50);
cart = new ShoppingCart();
cart.AddItem(item);

assert _totalSum == 10.50;
assert _lastItemAdded.name == "bottle";
assert _cartContents.toString() == "bottle-10.50";

这个测试对被测类的内部细节了解太多。它破坏了封装,使得在不破坏测试的情况下重构这个类是不可能的:

  • 测试检查lastItemAdded,而不是使用getter
  • 该测试访问私有属性,如_cartContents
  • 该测试依赖于对象被序列化的方式

想一想:你希望只有在公共接口的行为发生变化时,测试才会失败。所以,让我们把测试都做成关于行为的:

// test item added
item = new Item("bottle", 10.50);
cart = new ShoppingCart();
cart.AddItem(item);

assert cart.getTotal() == 10.50;
assert cart.getItem[0].name == "bottle";
assert cart.getItem[0].price == 10.50;

抽象的分支

大规模的重构不是一夜之间就能完成的--它需要几周甚至几个月的时间。渐进的变化可以通过抽象分支来实现。这种技术允许你在发生变化的同时继续发布版本。

抽象分支的开始是选择一个组件与代码库的其他部分解耦:

Branch by abstration makes slow tests faster

接下来,将该组件包裹在一个抽象层中,并将所有调用重定向到新的抽象层:

抽象层隐藏了实现细节。如果这个组件很大,很麻烦,可以创建一个副本,并在它上面工作,直到它准备好。然后,重构该组件,直到它可以独立测试:

Branch by abstration makes slow tests faster

一旦重构完成,将抽象层切换到新的实现。

Branch by abstration makes slow tests faster

使测试自成一体

  • 问题:外部依赖性使测试变慢,并给结果增加不确定性。
  • 解决方案:用测试替身、存根和模拟取代外部组件。把服务、数据库和API从方程中移除,使单元测试自成一体。

模拟是一个组件的简化实例,它的响应就像真实的东西,至少在有关测试的范围内是这样。它不需要实现全部的响应,只需要实现那些被认为相关的响应。例如,不是像这样从API端点请求实际数据。

function precipitation
      weather = fetch 'https://api.openweathermap.org/data'
      return weather.precipitation
end function

function leave_the_house
      if precipitation() > 0 then
            return "Don't forget your umbrella. It's raining!"
      else
            return "Enjoy the sun."
      end if
end function

我们可以用一个预制的响应来代替真正的precipitation 函数。这将使测试自成一体,从而更加可靠。这也避免了对API的意外滥用。只要接口得到维护,测试代码就仍然有效:

// Override the precipitation function
function precipitation()
	return 10
end function

message = leave_the_house()
assert message == "Don't forget your umbrella. It's raining!"

当然,现在我们已经与实际的API脱钩了,任何破坏性的变化都不会被测试所发现。我们可以实现一个单独的测试,定期轮询API并验证其结果,以减少风险。这种类型的测试被称为契约测试

嘲弄、存根和测试替身使你能够孤立地运行一段代码。在这个过程中,你的测试速度要快得多,因为你没有受到环境限制的约束。如果没有它们,你会得到不一致的结果,因为你的测试依赖于可能不受你控制的外部组件。

为了帮助你隔离测试,Semaphore为每个作业执行一个干净的环境。

删除过时的测试和死代码

  • 问题:死代码和过时的测试浪费了时间,并造成了项目的技术债务。
  • 解决方案:做一些内部清理。从你的项目中删除所有过时的代码。

不要对删除代码和测试感到焦虑。当被测试的代码不再流通时,测试就会过时。过时的测试是一个开发团队迟早要处理的技术债务的一部分。

过时的测试不一定是唯一值得删除的。有时一个测试涵盖了工作代码,就像下面的例子。然而,如果这个测试是微不足道的,没有什么价值,它就不应该出现在你的套件中。在这种情况下,删除它往往更好:

person = new Person()
person.setName("John")
name = person.getName()
assert name == "John"

如何删除一个测试

分两个阶段删除过时的测试是比较安全的。0.在本地运行测试套件并验证ALL PASS

  1. 删除被测试的代码。
  2. 如果没有任何破坏,就删除测试。

测试应该是可证伪的。换句话说,删除代码应该使测试失败--如果不是这样,你在测试什么?

Refactor slow tests to make them faster

一旦我们确定删除代码没有任何附带损害,我们就删除测试。

Refactor slow tests to make them faster

消除测试中的等待/睡眠语句

  • 问题:等待语句减慢了测试套件的速度,隐藏了大量的设计问题。
  • 解决方案:使用适当的同步机制。

测试中出现的sleep语句表明,开发人员需要等待一些东西,但不知道要等多久。因此,他们设置了一个固定的定时器,使测试工作。

睡眠语句通常出现在一个函数调用和它的验证性断言之间:

output1 = my_async_function(parameters)
// wait 2000 milliseconds
wait(2000)
assert(output1 == value1)

虽然这可能会起作用。它是脆性的,也是不完善的。为什么要等两秒?因为它在开发者的机器上工作?与其说是睡眠,不如说是使用某种轮询或同步机制,将等待时间降到最低。例如,在JavaScript中,await/async 解决了这个问题:

async function my_async_function() {
    // function definition
}

output1 = await my_async_function()
assert(output1 == value1)

许多语言也有回调,以连锁行动:

function callback(output) {
      assert(output == value)
}

function my_function(callback) {
    // function definition

    callback(output)
}

my_async_function(callback)

在这两种情况下,最正确的实现还包括某种形式的超时,以处理无响应的端点。

常常,等待被添加为一个不稳定的测试的变通方法--一个没有明显原因的测试有时成功,有时失败。然而,等待并不是一个正确的解决方案;它只是隐藏了问题。

等待服务

睡眠语句应该被适当的轮询或消息传递所取代,以确定所需资源何时准备好。这很有效,在某些情况下,这可能是你唯一的选择:

while port not open
      sleep(100)
end while

// test that needs port available...

但当你可以时,最好是打开端口并将测试函数作为回调传递。这将使动作连锁,而不需要任何明确的等待:

function run_test()
      // the port is open, run your test here
end function

connect(port, run_test)

优化测试中的数据库查询

  • 问题:不理想的数据库查询会浪费资源,降低测试速度。
  • 解决方案:剖析你的查询并优化它们。

当测试调用数据库查询时,首先检查是否可以用一个预填充的数据集来代替它。你的单元测试的大部分不应该依赖于数据库。它的效率很低,而且通常没有必要。

你如何模拟数据库调用?想象一下,我们想计算一个表中的所有用户。我们有一个函数连接到数据库并发出一个查询:

import mysql

function countUsers(host, port, user, password)
	connection = new mysql(host, port, user, password)
	users = connection.exec("SELECT USERNAME FROM USERTABLE")
	return users.count()
end function

print countUsers('localhost', 3306, 'dbuser', 'dbpassword')

虽然简单明了,但很难模拟。你需要写一个MySQL的替代程序,接受相同的参数,并且行为类似,这样才会有用。一个更好的选择是使用控制的倒置。这种模式涉及到将代码注入到被测函数中以允许更多的控制:

function countUsers(dbobject)
	if dbobject.connected is false
		throw "database not connected"
	end if

	users = dbobject.exec("SELECT USERNAME FROM USERTABLE")
	return users.count()
end function

在这里,我们发送一个数据库对象作为参数,使函数的调用变得更加复杂:

import mysql

connection = new mysql(host, port, user, password)
print countUsers(connection)

然而,测试是更直接的:

Class MockedDB
      // always connected
	connected = true

      // returns a canned answer, no db connection required
	function exec
		return ["Mary", "Jane", "Smith"]
	end function

end class

usercount = countUsers(MockedDB)
assert usercount == 3

查询一个真正的数据库

Mocks并不总是有效。你会发现有时需要一个实际的数据库,特别是在集成和端到端测试中。Semaphore提供了流行的数据库服务,你可以在你的测试环境中快速启动。

当谈到使用数据库时,有一些典型的失误。也许最常见的是N+1问题,它源于循环和SQL查询的结合:

users = db.exec("SELECT id, name FROM Users")
foreach user in users
	email = db.exec("SELECT email FROM Emails WHERE userid = $user['id']")
	body = "Hello $user['name']"
	sendEmail(body, email)
end foreach

这有什么问题呢?乍一看,没有什么问题:从Users 表中获取所有用户,然后从另一个表中获取他们的电子邮件地址。但是,想想有多少查询在冲击着数据库:一个是获取所有的用户,另一个是为找到的每个用户查询。如果你有10,000个用户,这就是10,001次查询。

数据库,尤其是SQL数据库,被设计为以集合的方式工作。他们讨厌迭代,因为每个查询都有很大的前期处理成本。我们可以通过一次性请求所有记录并利用JOIN 子句,将10,001次查询减少到1次:

users = db.exec("SELECT id, name, email FROM Users JOIN Emails ON Users.id = Emails.userid")
foreach user in users
	body = "Hello $user['name']"
	sendEmail(body, $user['email'])
end foreach

选择所有列

选择所有列是另一个容易犯的错误。查询SELECT * ,会出现几个问题:

  • 检索不需要的列,导致更多的I/O和内存使用
  • 否定了现有索引所提供的一些好处
  • 在JOIN中使用时,如果列序或列名改变,更容易断裂

因此,与其选择所有的列:

SELECT *
FROM Users U
JOIN Orders O on O.userid = O.id

你应该更加明确,只要求你需要的数据:

SELECT U.id, U.name, O.shipping_address
FROM Users U
JOIN Orders O on O.userid = O.id

分批操作

分批操作是指在一个交易中改变多个记录的能力。达到比普通事务高得多的数据速度,批处理是初始化测试的一个很好的方法。因此,不要像这样一次插入一条记录:

INSERT INTO founders (name, surname) values ('John', 'Romero');
INSERT INTO founders (name, surname) values ('John', 'Carmack');
INSERT INTO founders (name, surname) values ('Tom', 'Hall');

考虑将这些值捆绑在一条语句中:

INSERT INTO founders (name, surname) values ('John', 'Romero'), ('John', 'Carmack'), ('Tom', 'Hall');

在通过ORM设置测试时,也存在类似的有用设备,以这个RSpec为例:

before(:each) do
	@user = User.create(name: "Adrian", surname: "Carmack")
end

after(:each) do
	@user.destroy
end

context 'model test' do
	subject { @user }
    it { should validate_presence_of(:name) }
    it { should validate_presence_of(:surname) }
	it { should have_many(:projects) }
end

需要一点Ruby的知识才能理解,before(:each)after(:each) 在这里不是最佳选择。该测试为每个it 语句创建和删除一个用户。由于没有一个测试会改变数据,你可以用before(:all)after(all) 来初始化一次数据集,在测试的开始和结束时只运行一次。考虑加载一次样本数据,并尽可能在你的测试中重复使用。

测试API而不是UI

  • 问题:太多的UI测试会降低套件的性能。
  • 解决方案:当有更好的测试目标时,避免使用UI,如API端点。

API是为程序性访问而设计的,它总是比UI更适合运行测试。这并不意味着不应该测试UI,只是说如果你可以选择通过API或通过UI进行测试,前者更容易,而且资源消耗更少。

下面的例子显示了一个基本的UI测试。它访问了一个URL,做了一个搜索,并验证了结果值:

driver = new ChromeDriver();
driver.get("http://myapp/users");

// search for a user
driver.findElement(By.linkText("User")).sendKeys("John");
driver.findElement(By.linkText("Search")).click();

// validate result
firstTableCell = driver.findElement(By.Xpath("//table/tbody/tr[0]/td[0]"));
assertEquals(firstTableCell.getText(), "John")

即使是这样一个简单的测试也需要很多:我们需要打开一个浏览器,等待页面渲染,并模拟所有的交互。更不用说在我们开始之前,整个应用程序必须已经启动并运行。

与之相比,一个更贴近实际的测试则是对应用所消耗的同一个API端点进行ping:

request = fetch("http://myapi/v1/users$?name=John");

assertEquals(request.status(), 200);
assertEquals(request.header.contentType, "application/json");
assertEquals(request.body.users.name, "John");

在这里,我们正在测试API的行为是否符合规范,并实现与通过用户界面的测试相同的目标。这种测试的扩展性更好,因为它使用的资源更少,而且可以更容易地进行并行化。

减少设置过程中的UI交互

  • 问题:使用用户界面设置测试是次优的。
  • 解决方案:准备带外测试,只对被测元素进行UI。

由于UI测试需要更多的时间来运行和更多的精力来维护,我们需要保持它们的轻量级。如果你想测试结账按钮,就测试结账按钮,而不是其他。你不需要UI将产品填充到购物车中。你可以在幕后通过填充数据库或直接调用API来做到这一点。

当然,你仍然需要做端到端的测试,用Cucumber或JBehave这样的BDD框架更容易实现。测试用例可以使用Gherkin的结构化Given-When-Then 模式写下来,这有助于使其更加精简:

Feature: Checkout cart
  User must be able to buy their products

  Scenario: Successful purchase
    Given cart has products
    When I press the checkout button
    Then my order should be accepted

在测试中作弊

上面的场景要求有一个带产品的购物车。这是一个给定的前提条件,但测试并不关心购物车是如何被填满的。我们可以 "作弊",直接用数据库查询来预填充购物车,这意味着我们不需要通过用户界面。这里的重点是,不是所有的东西都需要通过用户界面

@Given("cart has products")
public void cart_has_products() {
    stmt.executeUpdate("INSERT INTO Cart (user_id,product_id) VALUES (1,1000), (1,2000), (1, 3000)");
}

相比之下,When和Then子句都需要与用户界面打交道,因为它们是为了验证结账体验而进行测试的。在引擎盖下,测试中的每个子句都是通过一个框架实现的,如Selenium:

public class StepDefinitions {

    private WebDriver driver = new ChromeDriver();

    @When("I press the checkout button")
    public void i_click_checkout() {
        driver.findElement(By.linkText("Checkout")).click();
    }

    @Then("my order should be accepted")
    public void i_should_get_order_number() {
        assertNotNull(driver.findElement(By.xpath("//*[matches(@id, 'Your order number is \d+')]")));
    }

}

测试框架有重复使用场景和条件的方法。例如,Cucumber就有背景。花费在优化UI交互上的时间通常是很好的时间。

使UI测试集中在快乐的路径上

  • 问题:试图测试UI的每个角落。
  • 解决方法:聪明地选择要测试的案例。你不需要尝试所有可能的组合。

现在,你可能已经意识到,UI和端到端测试的座右铭是 "保持简短,保持价值"。每一个测试都应该赢得它的保留。

对用户界面过于严格不是一个好主意,因为这使得移动一个按钮或改变一个字符串就很容易破坏测试。你应该能够在不破坏你的端到端测试层的情况下进行外观上的改变和改进。

这并不意味着UI测试没有价值。我们只是需要对测试的路径有所挑剔。举个例子,你肯定想确保新用户可以创建一个账户或现有用户可以登录。相反,测试用户在搜索框中输入非Unicode字符时会发生什么,最好用单元测试来完成。

Should you write an UI test?

没有硬性规定。找到有效的方法完全取决于你的应用程序的性质。考虑主要的用户体验(快乐的路径),忘记边缘案例--至少在UI测试方面。

不要忽视你的测试

响应性测试的重要性怎么强调都不为过。测试是代码,应该以同样的态度对待。必须分配时间来维护它们,因为测试套件的速度直接关系到你能多长时间发布软件。一个测试套件会逐渐变慢,除非它被尽职地维护,拖累团队的士气,使你的组织错过最后期限。不要让这种情况发生!