在Swift中编写更好的单元测试:第一部分

问题

瞧,我们是这里的朋友,所以我觉得我很坦白:我在职业生涯中写了一些非常糟糕的单元测试。 20行怪物,具有多个模拟和断言以及异步期望。 您在书中看到的这类标题为“如何修复以前工作过的白痴留下的混乱”的书。我还必须在写完单元测试和不太那么说的代码后维护该代码。更好。 可以这么说,我现在将编写“好的”单元测试作为优先事项。

在开始之前,我将定义我认为是“良好”的单元测试。 如果我们可以同意一个单元测试(或者实际上是任何一个测试)是由一些设置 ,我们正在测试的动作以及关于该动作效果的断言组成的,那么我可以这么简单地说,一种“好的”单元测试可以使这三个组件中的每一个都清晰可见。

(此外:这可能与您对“好的”单元测试的定义有所不同,而这只是我们俩都必须忍受的。)

在一些博客文章中,我将向您展示我们在Clue所做的一些工作,以确保我们始终试图编写“良好”的单元测试。 在本文中,我们将看一个简单的技巧,我们可以使用它来最小化单元测试的设置部分,同时保持清晰度。

假设我们要在一个简单的Swift结构上对相等方法进行单元测试。

  struct用户:Equatable { 
命名:字符串
让电子邮件:字符串
让需求验证:布尔
 静态函数==(lhs:用户,rhs:用户)-> Bool { 
返回lhs.name == rhs.name
&& lhs.email == rhs.email
&& lhs.needsVerification == rhs.needsVerification
}
}

我们可以(无限)有许多不同的参数组合可以传递给此方法进行测试。 显然,我们无法对所有这些进行测试,因此我们必须选择一些代表总体趋势的案例。 对该方法进行单元测试的有效方法(实际上,我通常会使用测试驱动的开发方法编写这种方法)是从两个User的所有属性均相等的情况开始,然后测试每个属性不同时会发生什么。 这给了我们这样的测试套件:

  func test_equals_allPropertiesMatch_isTrue(){ 
let sut = User(名称:“”,电子邮件:“”,needsVerification:false)
让其他=用户(名称:“”,电子邮件:“”,需要验证:false)
XCTAssertEqual(ut,其他)
XCTAssertEqual(other,sut)
}
  func test_equals_nameDiffers_isFalse(){ 
let sut = User(名称:“ Jo”,电子邮件:“”,需要验证:false)
让其他=用户(名称:“”,电子邮件:“”,需要验证:false)
XCTAssertNotEqual(sut,其他)
XCTAssertNotEqual(other,sut)
}
  func test_equals_emailDiffers_isFalse(){ 
let sut =用户(名称:“”,电子邮件:“ A”,需要验证:false)
让其他=用户(名称:“”,电子邮件:“”,需要验证:false)
XCTAssertNotEqual(sut,其他)
XCTAssertNotEqual(other,sut)
}
  func test_equals_needsVerificationDiffers_isFalse(){ 
let sut = User(名称:“”,电子邮件:“”,needsVerification:true)
让其他=用户(名称:“”,电子邮件:“”,需要验证:false)
XCTAssertNotEqual(sut,其他)
XCTAssertNotEqual(other,sut)
}

sut代表“被测对象”,由于某种原因,它是我可以接受的唯一的缩写变量名称。)

从所有的想象力来看,这些都不是糟糕的单元测试-实际上它们基本上是不错的-但它们也不是“好”。为什么不呢?

让我们看一下两个User的设置。 每次创建它们时,我们都必须传递init方法期望的所有属性。 即使在测试的上下文中,我们实际上并不关心它们是什么,也是如此。 例如,在第一个测试中,我们关心的只是属性Match ,它们的值可以是Spice Girls的名称(以升序排列),也可以是我最喜欢的柏林咖啡店(Bonanza和Five Elephant btw),只要它们相同,对测试没有影响。 同样,在其他三个测试中,我们只关心一个特定属性Differs

在这些测试中拥有我们不关心的信息会产生噪音,并使测试更难以理解。 对于这些小例子来说,这可能还可以,但是当您试图弄清为什么在发布前一个小时,一个更复杂的单元测试失败了,您将非常感激您消除了尽可能多的不必要的噪音。

如果我们的测试仅包含相关信息,那岂不是超级伟大,令人敬畏,令人难以忘怀以及其他最高级的东西吗? 而且,当然,如果不可能的话,所有这些设置将一事无成。

解决方案

因此,这就是我们要做的事情:我们将在测试目标中扩展User类型,以便添加一种方法,该方法可以让我们用我们关心的数据配置User ,同时对其他属性使用合理的默认值。 我倾向于将此方法称为create因为我认为它读起来很不错。 无论您叫什么,该方法应如下所示:

 扩展用户{ 
静态函数create(
名称:字符串=“”,
电子邮件:字符串=“”,
needsVerification:布尔=假
)->用户{
返回用户(
名称:名称,
电子邮件:电子邮件,
needsVerification:需求验证

}
}

(不用担心,我也不喜欢您将代码间隔开的方式。)

这些create方法非常简单-它们采用与类型的init方法相同的参数,但为每个参数赋予一个默认值。 根据经验,我倾向于对String参数使用"" ,对于数字参数使用0 ,对于Bool使用false ,等等。无论您决定使用哪种方法,真正重要的是尝试在不同的create实现之间保持一致你写的

(放凉:一旦您的一些类型具有create方法,您也可以开始使用不带任何参数的create调用结果作为默认值。因此,如果您具有Account(user: User)则可以在Account.create设置默认值user参数的值是User.create()

现在,结合Swift的类型推断能力,我们可以像上面这样重写上面的测试:

  var sut,其他:用户! 
  func test_equals_allPropertiesMatch_isTrue(){ 
(sut,other)=(.create(),.create())
XCTAssertEqual(ut,其他)
XCTAssertEqual(other,sut)
}
  func test_equals_nameDiffers_isFalse(){ 
(其他)=(.create(name:“ Jo”),.create())
XCTAssertNotEqual(sut,其他)
XCTAssertNotEqual(other,sut)
}
  func test_equals_emailDiffers_isFalse(){ 
(其他)=(.create(电子邮件:“ A”)、. create())
XCTAssertNotEqual(sut,其他)
XCTAssertNotEqual(other,sut)
}
  func test_equals_needsVerificationDiffers_isFalse(){ 
(sut,other)=(.create(needsVerification:true),.create())
XCTAssertNotEqual(sut,其他)
XCTAssertNotEqual(other,sut)
}

很好,对吗? 现在,每个测试仅包含与特定测试用例实际相关的信息。 这是一件好事。