开发者剖析 Spring petclinic 测试:移除框架耦合,测试代码减半
来源标题:Dissecting Spring petclinic (Part 1)
一位开发者对 Spring petclinic 项目的测试进行改造,移除所有框架耦合后删掉约 250 行代码,相当于全部测试代码的 50%。改造后测试约慢 250ms(约 15%),但每个测试的保真度都得到提升,冗余测试也被清除。作者指出 Spring 以 DI 容器为中心的设计会左右测试写法,这是他最不满的地方之一。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
那个被忽略的测试是我无法准确转换的。缺失的那个已经不再需要了(我想是这样)。所以我们看到了一个差异——往宽了说——250毫秒。或者说测试慢了大约15%。但我至少还能再删掉一个测试,因为它现在100%冗余了。就这样。这就是你赢得“一种风格比另一种慢”这场争论的机会。这就是如今我们谈论的开销。好的一面是,我提升了每一个测试的保真度,移除了对框架的所有耦合,并删掉了250行代码,看起来这占了全部测试代码的50%。
多个测试也可以合并成几个基于selenium(或类似工具)的测试。你总归需要那些点击测试,那为什么不顺便消除冗余呢。
在测试Spring项目时,有一个显而易见却无人提及的问题,它悄无声息地引导你所有的操作,并微妙地驱动你所有的设计。这个框架以DI容器为中心的设计——它决定了你如何编写测试。我越想越觉得,这实际上是我在大多数Spring项目中最大的抱怨之一,也对整体代码的实际设计产生了最大的影响(而且是往坏的方向)。
为什么你的应用或框架非得深度集成到一个本就多才多艺的测试框架(比如JUnit)中呢?你见过不用@SpringBootTest就能启动Spring应用的测试吗?可能吗?当然可能。容易做到吗?不容易。因为所有的配置、所有的组件都是隐式地连接在一起的,你基本上得反复试错,才能搞清楚需要什么上下文和配置才能把东西启动起来。把这和显式的构造函数调用对比一下,你就会看到差别。当然,当你已经有823472649个几乎没什么意义的微小部件,它们依赖于彼此注入(再加上Java这门没有命名参数和默认参数的语言……),你就做不到了。这是框架架构的后果。它导致了那些测试支持注解的必要性,而这些注解只能应用在类级别上,因为启动整个容器太消耗资源,所以你被期望在测试之间复用容器。或者你根本不启动整个容器,只启动整个应用的一个切片。同样,这是架构的后果。在理想的世界里,开发者不应该为了测试而关心应用的“切片”。而现在你需要知道相当多的测试注解,@DirtiesContext大概是最受喜爱的一个吧?还有大量细节需要理解。
当然,这些注解又导致了另一个后果;因为注解是静态的,你无法真正对它们进行抽象。比如,如何把配置值传入测试?通过注解。但当这些值只有在运行时才知道呢,因为你需要配置一个映射的数据库端口?又有一个支持注解给你,@DynamicPropertiesSource。但哦,这必须是一个静态方法。所以再一次,你无法对它进行抽象——你可以在我这里的commit中看到一个限制,这导致不得不在测试类体中一遍又一遍地声明testcontainers,而不是把它们放到基类中,并利用JUnit对非静态afterAll方法的出色支持。
如何从你的应用中获取一个值,比如它正在使用的端口?你猜对了,又是一个你需要知道的特殊注解,而且只有当你在另一个测试支持注解中同时传入了正确的环境配置参数时它才有效。
我们必须意识到我们已经陷得有多深了。让我们看看一个Web应用的简单、显式引导可以是什么样子:
public static void main(String[] args) { var app = createApp(new NoteRepository.InMemoryNoteRepository()); app.start(7070); public static Javalin createApp(NoteRepository noteRepository) { return Javalin.create() .get("/notes/{id}", ctx -> { var id = Integer.parseInt(ctx.pathParam("id")); Optional<Note> note = noteRepository.find(id); note.ifPresentOrElse(ctx::json, () -> ctx.status(404)); }) .get("/notes", ctx -> { List<Note> notes = noteRepository.getAll(); ctx.json(notes); }); 以及一个测试
void existingNoteIsFound() { NoteRepository repository = new InMemoryNoteRepository(); repository.addAll(new Note(0, "asd"), new Note(1, "ftz"), new Note(2, "jek")); JavalinTest.test(createApp(repository), (server, client) -> { assertThat(client.get("/notes/2").code()).isEqualTo(200); }); 这将轻而易举地允许对每个测试(方法!!)的整个容器引导进行抽象,并在毫秒或秒内运行完成。
我观察到的是,开发者不会去做那些体验糟糕的事情。而且我必须说,他们这么做是对的。我的同事完全拒绝使用testcontainers,因为他们不得不在Spring测试中应用大量样板代码,却无法妥善地将其抽象掉。他们拒绝做黑盒测试,因为框架带来的资源消耗,而把mock糊在他们脸上让这个决定变得更容易。我认为这是一个大问题。
我在第一步中所做的所有更改都可以在这个commit中找到。
正如本文所强调的,测试是我们工作中如此重要的一个方面。最大化测试的保真度并在应用的最外层边界进行测试,对好的项目至关重要。然而,Spring并没有做多少来帮助我们实现这一点。情况恰恰相反。整个框架的测试支持都集中在为框架强加的性能损失提供创可贴,以及那些与原生语言构造(如lambda)配合不佳的架构原则。
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。