
来到一个新项目时,我经常遇到以下情况之一:
- 根本没有测试。
- 测试很少,它们很少编写且不会持续运行。
- 存在测试并将其包含在CI(持续集成)中,但弊大于利。
不幸的是,后一种情况经常导致在缺乏适当技能的情况下进行认真尝试开始实施测试。
如何改变当前状况? 使用测试的想法并不新鲜。 同时,大多数教程都类似于关于如何绘制猫头鹰的著名图片:连接JUnit,编写第一个测试,使用第一个模拟-然后开始! 这些文章没有回答有关需要编写什么测试,需要注意什么以及如何忍受所有这些的问题。 本文的思想由此诞生。 我试图简要总结一下我在不同项目中实施测试的经验,以便为所有人提供便利。
关于此主题的介绍性文章已足够多,因此我们将不再重复说明,而是尝试从另一角度出发。 在第一部分中,我们将揭穿测试仅带来额外成本的神话。 将展示如何创建质量测试反过来可以加速开发过程。 然后,在一个小项目的示例中,将考虑实现该好处应遵循的基本原则和规则。 最后,在最后一节中,将给出具体的实施建议:相反,在测试开始时如何避免出现典型问题,相反会大大减慢开发速度。
由于我的主要专长是Java后端,因此在示例中将使用以下技术堆栈:Java,JUnit,H2,Mockito,Spring,Hibernate。 同时,本文的很大一部分致力于一般测试问题,并且其中的技巧适用于范围更广的任务。
但是,要小心! 测试非常容易上瘾:一旦您学会了如何使用它们,就无法没有它们。
测试与开发速度
在讨论测试的实现时出现的主要问题是:编写测试将花费多长时间?它将带来什么好处? 与任何其他技术一样,测试将需要认真的开发和实施工作,因此起初不应期望会有明显的好处。 至于时间成本,他们高度依赖于特定的团队。 但是,不应准确计算少于20-30%的额外编码成本。 少一点根本不足以实现至少某些结果。 即使在测试变得有用之前,对即时回报的期望通常还是减少此活动的主要原因。
但是,我们所说的效率是多少? 让我们放下关于实现困难的歌词,看看有哪些节省时间测试的特定机会。
在任何地方运行代码
如果项目中没有测试,则唯一的开始方法是提升整个应用程序。 大约需要15到20秒的时间就可以了,但是完全启动可能需要几分钟的大型项目却很少见。 这对开发人员意味着什么? 这些短暂的等待时间是他们工作时间的重要部分,在此期间您无法继续处理当前任务,但是与此同时,切换到其他任务的时间太少。 许多人至少曾经遇到过这样的项目,由于更正之间的重新启动时间较长,因此在一小时内编写的代码需要大量的调试时间。 在测试中,您可以限制自己运行应用程序的一小部分,这将显着减少等待时间并提高处理代码的效率。
此外,在任何地方运行代码的能力都可以进行更彻底的调试。 通常,即使通过应用程序界面检查主要的积极用例,也需要花费大量的精力和时间。 测试的存在使对特定功能的详细检查变得更加容易和快捷。
另一个优点是可以调节测试单元的大小。 根据要测试的逻辑的复杂性,您可以将自己限制为一个方法,一个类,实现某些功能的一组类,一个服务等,直到自动化测试整个应用程序为止。 这种灵活性使您可以从许多部件上卸载高级测试,因为它们将在较低级别上进行测试。
重新启动测试
该优点通常被称为测试自动化的本质,但让我们从一个不太熟悉的角度来看它。 它为开发人员带来了哪些新机会?
首先,每个进入该项目的新开发人员都可以使用示例轻松地运行现有测试以了解应用程序逻辑。 不幸的是,其重要性被大大低估了。 在现代条件下,同一个人很少在一个项目上工作超过1-2年。 而且由于团队由几个人组成,因此每2-3个月出现一个新参与者是相对大型项目的典型情况。 特别困难的项目正在经历几代开发人员的转变! 轻松启动应用程序的任何部分并查看系统行为的能力可简化新程序员在项目中的沉浸。 另外,对代码逻辑的更详细的研究减少了输出中产生的错误数量,并减少了将来调试它们的时间。
其次,轻松验证应用程序是否正常运行的能力为连续重构开辟了道路。 不幸的是,该术语不如CI流行。 这意味着每次精炼代码都可以并且应该进行重构。 这是臭名昭著的“童子军”规则的一项定期遵守情况,即“让停车场比到达前更干净”,这样可以避免代码库降级并保证项目的长寿和幸福。
侦错
在前面的段落中已经提到了调试,但是这一点非常重要,值得一看。 不幸的是,没有一种可靠的方法可以衡量花费在编写代码和调试代码之间的时间之间的关系,因为这些过程实际上是彼此不可分割的。 但是,项目中存在质量测试可以极大地减少调试时间,几乎完全不需要运行调试器。
实效
以上所有内容都可以大大节省代码的初始调试时间。 采用正确的方法,只有这样才能付清所有额外的开发成本。 剩下的测试好处-提高代码库的质量(设计不良的代码很难测试),减少缺陷数量,随时验证代码正确性的能力等-几乎都是免费的。
从理论到实践
换句话说,一切看起来都不错,但让我们开始吧。 如前所述,关于如何进行测试环境的初始设置的材料足够多。 因此,我们立即进行完成的项目。
来源在这里。挑战赛
作为模板任务,请考虑在线商店后端的一小部分。 我们将编写用于产品的典型API:创建,接收,编辑。 还有几种与客户合作的方法:更改“最喜欢的产品”并计算订单的奖励积分。
领域模型
为了不使该示例超载,我们将自己限制在最少的字段和类集合中。
客户有一个用户名,一个指向喜欢的产品的链接以及一个标志,表明他是否是高级客户。
产品(产品)-名称,价格,折扣和标志,指示当前是否在广告。
项目结构
主要项目代码的结构如下。
类是分层的:
- 模型-项目的领域模型;
- Jpa-用于基于Spring Data的数据库的存储库;
- 服务-应用程序的业务逻辑;
- 控制器-实现API的控制器。
单元测试结构。
测试类与原始代码位于相同的包中。 此外,还创建了一个带有用于准备测试数据的构建器的软件包,下面将对此进行详细介绍。
将单元测试和集成测试分开很方便。 它们通常具有不同的依赖关系,并且为了舒适地进行开发,应该能够运行一个或另一个。 这可以通过多种方式实现:命名约定,模块,包,sourceSet。 具体方法的选择仅取决于口味。 在此项目中,集成测试位于单独的sourceSet中-integrationTest。
与单元测试一样,具有集成测试的类与原始代码位于相同的程序包中。 此外,还有一些基类可以帮助您消除配置重复,并在必要时包含有用的通用方法。
整合测试
值得一开始的测试有不同的方法。 如果经过测试的逻辑不是很复杂,则可以立即进行集成(有时也称为验收)。 与单元测试不同,它们确保整个应用程序正常运行。
建筑学首先,您需要确定将在哪个特定级别执行集成检查。 Spring Boot提供了完全的选择自由:您可以提出部分上下文,整个上下文,甚至是功能完善的服务器,可以从测试中进行访问。 随着应用程序大小的增加,此问题变得越来越复杂。 通常,您必须在不同级别编写不同的测试。
一个好的起点是在不启动服务器的情况下进行控制器测试。 在相对较小的应用程序中,引发整个上下文是完全可以接受的,因为默认情况下,它可以在测试之间重用,并且仅初始化一次。 考虑一下
ProductController
类的基本方法:
@PostMapping("new") public Product createProduct(@RequestBody Product product) { return productService.createProduct(product); } @GetMapping("{productId}") public Product getProduct(@PathVariable("productId") long productId) { return productService.getProduct(productId); } @PostMapping("{productId}/edit") public void updateProduct(@PathVariable("productId") long productId, @RequestBody Product product) { productService.updateProduct(productId, product); }
错误处理的问题被搁置了。 假设它是基于对抛出的异常的分析在外部实现的。 这些方法的代码非常简单,它们在
ProductService
的实现并不复杂:
@Transactional(readOnly = true) public Product getProduct(Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new DataNotFoundException("Product", productId)); } @Transactional public Product createProduct(Product product) { return productRepository.save(new Product(product)); } @Transactional public Product updateProduct(Long productId, Product product) { Product dbProduct = productRepository.findById(productId) .orElseThrow(() -> new DataNotFoundException("Product", productId)); dbProduct.setPrice(product.getPrice()); dbProduct.setDiscount(product.getDiscount()); dbProduct.setName(product.getName()); dbProduct.setIsAdvertised(product.isAdvertised()); return productRepository.save(dbProduct); }
ProductRepository
存储库根本不包含其自己的方法:
public interface ProductRepository extends JpaRepository<Product, Long> { }
一切都暗示这些类不需要单元测试,仅仅是因为可以通过多个集成测试轻松而有效地检查整个链。 在不同的测试中重复相同的测试会使调试变得复杂。 如果代码中出现错误,那么现在不会一次测试下降,而是一次下降10到15。 反过来,这将需要进一步的分析。 如果没有重复,则唯一失败的测试可能会立即指示错误。
构型为了方便起见,我们突出显示基类
BaseControllerIT
,其中包含Spring配置和几个字段:
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @Transactional public abstract class BaseControllerIT { @Autowired protected ProductRepository productRepository; @Autowired protected CustomerRepository customerRepository; }
存储库被移至基类,以免使测试类混乱。 它们的作用完全是辅助的:在控制器工作后准备数据并检查数据库的状态。 当您增加应用程序的大小时,这可能不再方便,但是从一开始它就非常合适。
Spring的主要配置由以下几行定义:
@SpringBootTest
用于设置应用程序的上下文。
WebEnvironment.NONE
表示不需要引发任何网络上下文。
@Transactional
Transactional-使用自动回滚将所有类测试包装在事务中,以保存数据库的状态。
测试结构让我们继续进行一下
ProductController
类
ProductControllerIT
一组简约测试。
@Test public void createProduct_productSaved() { Product product = product("productName").price("1.01").discount("0.1").advertised(true).build(); Product createdProduct = productController.createProduct(product); Product dbProduct = productRepository.getOne(createdProduct.getId()); assertEquals("productName", dbProduct.getName()); assertEquals(number("1.01"), dbProduct.getPrice()); assertEquals(number("0.1"), dbProduct.getDiscount()); assertEquals(true, dbProduct.isAdvertised()); }
测试代码应该非常简单,一目了然。 如果不是这样,则本文第一部分中描述的测试的大多数优势都将丢失。 优良作法是将测试主体分为三个部分,这些部分可以在视觉上彼此分开:准备数据,调用测试方法,验证结果。 同时,非常希望测试代码适合整个屏幕。
就我个人而言,当稍后在检查中使用数据准备部分的测试值时,对我来说似乎更明显。 另外,您可以显式比较对象,例如:
assertEquals(product, dbProduct);
在另一个用于更新产品信息的测试(
updateProduct
)中,很明显,数据的创建变得更加复杂,并且为了保持测试的三个部分的视觉完整性,它们被行中的两个换行符隔开:
@Test public void updateProduct_productUpdated() { Product product = product("productName").build(); productRepository.save(product); Product updatedProduct = product("updatedName").price("1.1").discount("0.5").advertised(true).build(); updatedProduct.setId(product.getId()); productController.updateProduct(product.getId(), updatedProduct); Product dbProduct = productRepository.getOne(product.getId()); assertEquals("updatedName", dbProduct.getName()); assertEquals(number("1.1"), dbProduct.getPrice()); assertEquals(number("0.5"), dbProduct.getDiscount()); assertEquals(true, dbProduct.isAdvertised()); }
面团的三个部分均可简化。 对于数据准备,测试构建器非常出色,其中包含创建对象的逻辑,该逻辑便于从测试中使用。 过于复杂的方法调用可以变成测试类内部的辅助方法,从而隐藏了与此类无关的一些参数。 为了简化复杂的检查,您还可以编写辅助功能或实现自己的匹配器。 所有这些简化的主要目的是不失去测试的可见性:所有方法都应一目了然,无需深入了解。
测试构建者测试构建者应特别注意。 封装创建对象的逻辑可以简化测试维护。 特别是,与该测试无关的模型字段的填充可以隐藏在构建器内部。 为此,您不必直接创建它,而是使用静态方法,该方法将使用默认值填充缺少的字段。 例如,如果新的必填字段出现在模型中,则可以轻松地将它们添加到此方法中。 在
ProductBuilder
它看起来像这样:
public static ProductBuilder product(String name) { return new ProductBuilder() .name(name) .advertised(false) .price("0.00"); }
测试名称必须了解在此测试中专门测试的内容。 为了清楚起见,最好在标题中给出该问题的答案。 将示例测试用于
getProduct
方法
getProduct
考虑使用的命名约定:
@Test public void getProduct_oneProductInDb_productReturned() { Product product = product("productName").build(); productRepository.save(product); Product result = productController.getProduct(product.getId()); assertEquals("productName", result.getName()); } @Test public void getProduct_twoProductsInDb_correctProductReturned() { Product product1 = product("product1").build(); Product product2 = product("product2").build(); productRepository.save(product1); productRepository.save(product2); Product result = productController.getProduct(product1.getId()); assertEquals("product1", result.getName()); }
在一般情况下,测试方法的标题由三部分组成,并用下划线分隔:测试方法的名称,脚本和预期结果。 但是,没有人取消常识,如果在这种情况下不需要名称的某些部分(例如,用于创建产品的单个测试中的脚本),则可以省略名称的某些部分。 命名的目的是确保无需学习代码即可理解每个测试的本质。 这样可以使测试结果的窗口尽可能清晰,并且通常这样就可以开始进行测试。
结论仅此而已。 第一次,最少四个测试集就足以测试
ProductController
类的方法。 在检测到错误的情况下,您可以随时添加缺少的测试。 同时,最少的测试次数大大减少了支持测试的时间和精力。 反过来,这对于测试的实施过程至关重要,因为通常无法获得最优质的首批测试,并且会产生许多意想不到的问题。 同时,这样的测试套件足以接收本文第一部分所述的奖金。
值得注意的是,此类测试不会检查应用程序的Web层,但是通常这不是必需的。 如有必要,您可以使用存根而不是基础(
@WebMvcTest
,
MockMvc
,
@MockBean
)为存根编写单独的Web层测试,或者使用成熟的服务器。 由于测试无法控制服务器的事务,因此后者会使调试复杂并使事务与事务复杂化。 可以在
CustomerControllerServerIT
类中找到这种集成测试的示例。
单元测试
与集成测试相比,单元测试具有以下优势:
- 启动需要几毫秒;
- 小尺寸的测试单元;
- 验证大量选项很容易,因为直接调用该方法时,大大简化了数据准备。
尽管如此,单元测试本质上不能保证应用程序整体的可操作性,并且不允许您避免编写集成程序。 如果被测单元的逻辑很简单,那么将集成测试与单元测试重复将不会带来任何好处,而只会添加更多代码来提供支持。
此示例中唯一值得进行单元测试的类是
BonusPointCalculator
。 它的显着特征是业务逻辑的大量分支。 例如,假设购买者获得的奖金为产品成本的10%,乘以以下列表中的乘数不超过2个乘数:
- 产品价格超过10,000(×4);
- 产品参加广告活动(×3);
- 该产品是客户的“最爱”产品(×5);
- 客户的状态为高级(×2);
- 如果客户具有高级身份并购买了“最喜欢的”产品,则使用一个(×8)代替两个所示乘数。
当然,在现实生活中,值得设计一种灵活的通用机制来计算这些奖金,但是为了简化示例,我们将自己限于固定的实现方式。 乘数计算代码如下:
private List<BigDecimal> calculateMultipliers(Customer customer, Product product) { List<BigDecimal> multipliers = new ArrayList<>(); if (customer.getFavProduct() != null && customer.getFavProduct().equals(product)) { if (customer.isPremium()) { multipliers.add(PREMIUM_FAVORITE_MULTIPLIER); } else { multipliers.add(FAVORITE_MULTIPLIER); } } else if (customer.isPremium()) { multipliers.add(PREMIUM_MULTIPLIER); } if (product.isAdvertised()) { multipliers.add(ADVERTISED_MULTIPLIER); } if (product.getPrice().compareTo(EXPENSIVE_THRESHOLD) >= 0) { multipliers.add(EXPENSIVE_MULTIPLIER); } return multipliers; }
大量的选择导致以下事实:不限制两个或三个集成测试。 一组简单的单元测试非常适合调试此类功能。
相应的测试套件可以在
BonusPointCalculatorTest
类中找到。 以下是其中一些:
@Test public void calculate_oneProduct() { Product product = product("product").price("1.00").build(); Customer customer = customer("customer").build(); Map<Product, Long> quantities = mapOf(product, 1L); BigDecimal bonus = bonusPointCalculator.calculate(customer, list(product), quantities::get); BigDecimal expectedBonus = bonusPoints("0.10").build(); assertEquals(expectedBonus, bonus); } @Test public void calculate_favProduct() { Product product = product("product").price("1.00").build(); Customer customer = customer("customer").favProduct(product).build(); Map<Product, Long> quantities = mapOf(product, 1L); BigDecimal bonus = bonusPointCalculator.calculate(customer, list(product), quantities::get); BigDecimal expectedBonus = bonusPoints("0.10").addMultiplier(FAVORITE_MULTIPLIER).build(); assertEquals(expectedBonus, bonus); }
值得注意的是,在测试中,我们专门引用该类的公共API-
calculate
方法。 测试类合同而不是执行它可以避免由于非功能性更改和重构而导致测试失败。
最后,当我们使用单元测试检查内部逻辑时,我们不再需要将所有这些细节集成在一起。 在这种情况下,一个或多或少的代表性测试就足够了,例如:
@Test public void calculateBonusPoints_twoProductTypes_correctValueCalculated() { Product product1 = product("product1").price("1.01").build(); Product product2 = product("product2").price("10.00").build(); productRepository.save(product1); productRepository.save(product2); Customer customer = customer("customer").build(); customerRepository.save(customer); Map<Long, Long> quantities = mapOf(product1.getId(), 1L, product2.getId(), 2L); BigDecimal bonus = customerController.calculateBonusPoints( new CalculateBonusPointsRequest("customer", quantities) ); BigDecimal bonusPointsProduct1 = bonusPoints("0.10").build(); BigDecimal bonusPointsProduct2 = bonusPoints("1.00").quantity(2).build(); BigDecimal expectedBonus = bonusPointsProduct1.add(bonusPointsProduct2); assertEquals(expectedBonus, bonus); }
与集成测试一样,使用的单元测试集非常小,并且不能保证应用程序的完全正确性。 尽管如此,它的存在极大地增加了对代码的信心,促进了调试,并为本文的第一部分列出了其他好处。
实施建议
我希望前面的部分足以说服至少一个开发人员尝试在他们的项目中开始使用测试。 本章将简要列出主要建议,这些建议将有助于避免严重问题并降低初始实施成本。
尝试开始在新应用程序上实施测试。 与新创建的项目相比,在一个大型旧项目中编写第一个测试会困难得多,并且需要更多的技能。 因此,如果可能,最好从一个小的新应用程序开始。 如果不需要新的成熟应用程序,则可以尝试开发一些有用的实用程序以供内部使用。 最主要的是,任务应该或多或少地切合实际-发明的示例将无法提供全面的经验。
设置常规测试运行。 如果测试不是定期运行,那么它们不仅会停止执行其主要功能-检查代码的正确性-而且会很快过时。 因此,每次在存储库中更新代码时,至少配置最小CI管道并自动启动测试是非常重要的。
不要追逐封面。 与任何其他技术一样,首先将无法获得最佳质量的测试。 相关文献(文章末尾的链接)或胜任的导师可以为您提供帮助,但这并不能消除对自填充锥体的需求。 在这方面的测试与代码的其余部分类似:要了解它们将如何影响项目,您只能在与他们在一起一段时间后才能进行测试。 因此,为了最大程度地减少损坏,第一次最好不要追赶100%覆盖率的数字和漂亮数字。 相反,您应该将自己局限于自己的应用程序功能的主要积极情况。
不要被单元测试所困扰。 延续“数量与质量”的主题,应该注意的是,诚实的单元测试不应第一次进行,因为这很容易导致应用程序的规格过多。 反过来,这将成为后续重构和应用程序改进中的一个严重抑制因素。 仅当特定类或一组类中存在复杂的逻辑(在集成级别上不方便检查)时,才应使用单元测试。
不要对存根类和应用程序方法感到迷惑。 存根(存根,模拟)是另一个需要平衡方法并保持平衡的工具。 一方面,单元的完全隔离使您可以专注于经过测试的逻辑,而不必考虑系统的其余部分。 另一方面,这将需要额外的开发时间,并且与单元测试一样,可能会导致行为规范过多。
取消来自外部系统的集成测试。 集成测试中一个非常常见的错误是使用真实数据库,消息队列和应用程序外部的其他系统。 当然,在真实环境中运行测试的功能对于调试和开发很有用。 这种少量的测试可能是有意义的,特别是对于交互式运行。 但是,它们的广泛使用导致许多问题:
- 要运行测试,您将需要配置外部环境。 例如,在将要组装应用程序的每台机器上安装一个数据库。 这将使新开发人员难以进入项目并配置CI。
- 在运行测试之前,外部系统的状态可能在不同的机器上有所不同。 例如,数据库可能已经包含应用程序需要的表以及测试中不需要的数据。 这将导致测试中无法预测的失败,而消除它们将需要大量的时间。
- 如果多个项目正在进行并行工作,则某些项目对其他项目的影响可能不明显。 例如,为其中一个项目进行的特定数据库设置可以帮助另一个项目的功能正常工作,但是,如果在另一台计算机上的干净数据库上启动该项目,则该设置会中断。
- 测试进行了很长时间:一个完整的过程可能需要数十分钟。 这导致以下事实:开发人员仅在将更改发送到远程存储库后才停止在本地运行测试,并查看其结果。 这种行为抵消了测试的大多数优势,这些优势已在本文的第一部分中进行了讨论。
清除集成测试之间的上下文。 通常,为了加速集成测试的工作,您必须在它们之间重用相同的上下文。 即使是正式的Spring文档也提出了这样的建议。 在这种情况下,应避免测试之间的相互影响。 由于它们是以任意顺序启动的,因此此类连接的存在会导致随机的,不可复制的错误。 为了防止这种情况的发生,测试不应在上下文中留下任何变化。 例如,使用数据库进行隔离时,通常足以回退测试中提交的所有事务。 如果无法避免更改上下文,则可以使用
@DirtiesContext
注释配置其重新创建。
, . , - . , . , , — , .
. , , . , , .
TDD (Test-Driven Development). TDD , , . , , . , , .
, ?
, :
- ( )? .
- , ( , CI)? .
- ? .
- ? . , , .
, . , , - . — .
结论
, . - , . , - . — , , -. , .
, , , !
GitHub