什么是软件产品?业务代码本身,对吗?实际上,这只是其中的一部分。一个软件产品由不同的元素组成。
- 业务代码
- 文档
- CI/CD管道
- 通信规则
- 自动化测试
纯粹的代码是不够的。只有当这些部分被集成到一个坚实的系统中,它才能被称为一个软件产品。
测试在软件开发中是至关重要的。此外,"应用 "和 "测试 "之间已经没有什么区别了。这并不是因为缺少了后者会导致产品无法维护和无法使用(虽然这绝对是事实),而是由于测试指导了架构设计,保证了代码的可测试性。
单元测试只是庞大的测试理念中的一小部分。有几十种不同的测试。如今,测试是开发的基础。你可以在Semaphore的博客上阅读我关于集成测试的文章。但现在是时候深入研究单元测试了。
这篇文章的代码例子是在Java中,但给出的规则对任何编程语言都是通用的。
目录
什么是单元测试
每个开发人员都有编写单元测试的经验。我们都知道他们的目的和他们的样子。然而,要给单元测试一个严格的定义是很难的。问题在于对什么是单元 的理解。让我们先试着澄清一下。
一个单元是一个孤立的功能片段。
听起来很合理。根据这个定义,套件中的每个单元测试都应该涵盖一个单元。
看一下下面的模式。该应用程序由许多模块组成,每个模块都有一些单元。
在这种情况下,有6个单元。
- UserService
- 角色服务
- 帖子服务(PostService
- 评论服务
- 用户报告
- 角色复述
根据给定的模式,单元可以这样定义。
一个单元是一个可以从整个系统中隔离出来进行测试的类。
所以,我们可以为每个特定的单元写测试,对吗?嗯,这种说法既正确又不正确,因为单元并不是独立存在的。它们必须相互作用,否则应用程序将无法工作。
那么,我们怎么能为那些实际上无法隔离的东西写单元测试呢?我们很快就会讨论这个问题,但让我们先把另一点说清楚。
测试驱动的开发
测试驱动开发是在业务代码之前编写测试的技术实践。当我第一次听到它的时候,我很困惑。如果没有什么要测试的,怎么能写测试呢?让我们来看看它是如何工作的。
TDD声明了三个步骤。
- 为新功能写一个测试。它将会失败,因为你还没有写出所需的业务代码。
- 添加最小的代码来实现该功能。
- 如果测试通过,重构结果并回到第一步。
这个生命周期被称为 "红-绿-重构"。
一些作者提出了对该公式的改进。你可以找到有4个甚至5个步骤的例子,但想法仍然是一样的。
单位定义的问题
假设我们正在创建一个博客,作者可以写文章,用户可以留下评论。我们想建立添加新评论的功能。需要的行为包括以下几点。
- 用户提供帖子ID和评论内容。
- 如果帖子不存在,就会抛出一个异常。
- 如果评论内容超过300个字符,就会抛出一个异常。
- 如果所有的验证都通过,评论应该被成功保存。
这里是可能的Java实现。
public class CommentService {
private final PostRepository postRepository;
private final CommentRepository commentRepository;
// constructor is omitted for brevity
public void addComment(long postId, String content) {
if (!postRepository.existsByid(postId)) {
throw new CommentAddingException("No post with id = " + postId);
}
if (content.length() > 300) {
throw new CommentAddingException("Too long comment: " + content.length());
}
commentRepository.save(new Comment(postId, content));
}
}
要测试内容的长度并不难。问题是,CommentService依赖于通过构造函数传递的依赖关系。在这种情况下,我们应该如何测试这个类?我不能给你一个单一的答案,因为有两个TDD学派。底特律学派(classicist)和伦敦学派(mockist)。每一派都以不同的方式声明这个单元。
底特律学派的TDD
如果一个古典主义者想测试我们前面描述的addComment方法,服务实例化可能看起来像这样。
class CommentServiceTest {
@Test
void testWithStubs() {
CommentService service = new CommentService(
new StubPostRepository(),
new StubCommentRepository()
);
}
}
在这种情况下,StubPostRepository 和StubCommentRepository 是用于测试案例的相应接口的实现。顺便说一下,底特律学校并不限制应用于真正的商业类。
为了总结这个想法,看看下面的模式。底特律学派声明的单元不是一个单独的类,而是一个组合的类。不同的单元可以重合。
有很多测试套件都依赖于相同的实现--StubPostRepository и StubCommentRepository。
所以,底特律学校的追随者会以这种方式声明单元。
一个单元是一个可以从整个系统中隔离出来进行测试的类。任何外部依赖都应该用存根或真正的业务对象来代替。
伦敦TDD学派
另一方面,一个模拟主义者会以不同的方式测试addComment。
class CommentServiceTest {
@Test
void testWithStubs() {
PostRepository postRepository = mock(PostRepository.class);
CommentRepository commentRepository = mock(CommentRepository.class);
CommentService service = new CommentService(
postRepository,
commentRepository
);
}
}
伦敦学派将一个单元定义为一个强隔离的代码片段。每个模拟是类的依赖性的实现。每个测试案例的模拟都应该是唯一的。
一个单元是一个可以与整个系统隔离测试的类。任何外部的依赖都应该被模拟。不允许重复使用存根。禁止应用真实的业务对象。
看一下下面的模式来澄清这一点。
单元定义的总结
底特律学派和伦敦学派在他们提出的方法背后有一些论据。然而,这些论点已经超出了本文的范围。为了我们的目的,我将同时应用mock和stubs。
所以,现在是时候确定我们最终的单元定义了。请看下面的声明。
一个单元是一个可以与整个系统隔离测试的类。所有的外部依赖都应该被模拟或者用存根代替。在测试的过程中,不应该有业务对象参与。
我们不在单一单元中涉及外部业务对象。尽管底特律TDD学派允许这样做,但我认为这种方法是不稳定的。这是因为业务对象的行为会随着系统的成长而发生变化。随着它们的变化,它们可能会影响到代码的其他部分。然而,这个规则有一个例外。价值对象。这些是数据结构,封装了孤立的部分。例如,Money价值对象由金额和货币组成。FullName对象可以有名字、姓氏和父名。这些类是普通的数据持有者,没有特定的行为,所以在测试中直接应用它们是可以的。
现在我们有了一个单元的工作定义,让我们继续建立一个单元测试应该如何构建。每个单元测试都必须遵循一组定义好的要求。请看下面的列表。
- 类不应该破坏DI(依赖性反转)原则。
- 单元测试不应相互影响。
- 单元测试应该是确定性的。
- 单元测试不应依赖任何外部状态。
- 单元测试应该快速运行。
- 所有测试都应该在CI环境中运行
让我们一步一步地澄清每一点。
单元测试要求
类不应该破坏DI原则
这一点是最明显的,但值得一提的是,打破这一规则会使单元测试失去意义。
看一下下面的代码片段。
public class CommentService {
private final PostRepository postRepository = new PostRepositoryImpl();
private final CommentRepository commentRepository = new CommentRepositoryImpl();
...
}
尽管CommentService声明了外部依赖,但它们被绑定到PostRepositoryImpl和CommentRepositoryImpl。这使得我们不可能通过存根/替身/模拟来隔离验证该类的行为。这就是为什么你应该通过构造函数传递所有的依赖关系。
单元测试不应相互影响
单元测试的哲学可以用以下语句来概括。
用户可以按顺序或并行地运行所有单元测试。这不应该影响其执行的结果。为什么这很重要?假设你运行了测试A和B,一切都很正常。但是CI节点运行了测试B,然后又运行了测试A。如果测试B的结果影响了测试A,就会导致错误的负面行为。这种情况是很难跟踪和修复的。
假设我们有用于测试的StubCommentRepository。
public class StubCommentRepository implements CommentRepository {
private final List<Comment> comments = new ArrayList<>();
@Override
public void save(Comment comment) {
comments.add(comment);
}
public List<Comment> getSaved() {
return comments;
}
public void deleteSaved() {
comments.clear();
}
}
如果我们通过StubCommentRepository的同一个实例,是否能保证单元测试不互相影响?答案是否定的。你看,StubCommentRepository不是线程安全的。并行测试很可能不会得到与顺序测试相同的结果。
有两种方法来解决这个问题。
- 确保每个存根都是线程安全的。
- 为每个测试案例创建一个新的存根/模拟。
单元测试应该是确定性的
一个单元测试应该只依赖于输入参数,而不依赖于外部状态(系统时间,CPU数量,默认编码,等等)。因为不能保证团队中的每个开发人员都有相同的硬件设置。假设你的机器上有8个CPU,一个测试对此做了一个假设。你的拥有16个CPU的同事可能会因为测试在他们的机器上每次都失败而感到恼火。
让我们来看看一个例子。想象一下,我们想测试一个util方法,它可以告诉我们所提供的日期时间是否是早上。我们的测试可能是这样的。
class DateUtilTest {
@Test
void shouldBeMorning() {
OffsetDateTime now = OffsetDateTime.now();
assertTrue(DateUtil.isMorning(now));
}
}
这个测试在设计上是不确定的。只有当当前的系统时间被归类为早晨时,它才会成功。
这里的最佳做法是避免通过调用非纯函数来声明测试数据。这些数据包括
- 当前日期时间。
- 系统时区。
- 硬件参数。
- 随机数。
应该提到的是,基于属性的测试提供类似的数据生成,尽管它的工作方式有点不同。我们将在文章的最后讨论它。
单元测试不应依赖任何外部状态
这意味着每次测试运行都能保证在任何环境下都有相同的结果。琐碎吗?也许是这样。尽管现实可能更棘手。让我们看看,如果你的测试依赖于外部HTTP服务总是可用的,并且每次都返回预期的结果,会发生什么。
假设我们正在创建一个提供天气状况的服务。它接受了一个传输HTTP API调用的URL。看一下下面的代码片段。这是一个简单的测试,检查当前的天气状况是否总是存在。
class WeatherTest {
@Test
void shouldGetCurrentWeatherStatus() {
String apiRoot = "https://api.openweathermap.org";
Weather weather = new Weather(apiRoot);
WeatherStatus weatherStatus = weather.getCurrentStatus();
assertNotNull(weatherStatus);
}
}
问题是外部API可能是不稳定的。我们不能保证外部服务会一直响应。即使我们做到了,仍然有可能运行构建的CI服务器禁止HTTP请求。例如,可能有一些防火墙的限制。
重要的是,单元测试是一段坚实的代码,不需要任何外部服务就能成功运行。
所有的测试都应该在CI环境中运行
测试具有预防作用。他们应该拒绝任何不通过所述规范的代码。这意味着,没有通过单元测试的代码不应该被合并到主分支中。
为什么这很重要?假设我们合并了带有破损代码的分支。当发布时间到来时,我们需要编译主分支,构建人工制品,并继续进行部署管道,对吗?但请记住,代码有可能是坏的。生产可能会瘫痪。我们可以在发布前手动运行测试,但如果测试失败了呢?我们将不得不即时修复这些错误。这可能会导致延迟发布和客户的不满意。确定主分支已被彻底测试,意味着我们可以毫无顾虑地进行部署。
实现这一目标的最好方法是在CI环境中集成测试。Semaphore出色地做到了这一点。该工具还可以显示每个失败的测试运行,所以你不必爬进CI构建日志来追踪问题。
这里需要说明的是,所有 类型的测试都应该在CI环境中运行,即集成和E2E测试也是如此。
单元测试要求的总结
正如你所看到的,单元测试并不像它们看起来那样简单明了。这是因为单元测试不是关于断言和错误信息。单元测试验证的是行为,而不是用特定参数调用mock的事实。
你应该在测试中投入多少精力?没有通用的答案,但是当你写测试时,你应该记住这些要点。
- 测试是优秀的代码文档。如果你对系统的行为不了解,测试可以帮助你了解类的目的和API。
- 有很大的机会,你以后会回到测试中来。如果它写得不好,你将不得不花太多的时间来弄清楚它的实际作用。
对于所述的几点,还有一个更简单的公式。每次你在写测试的时候,都要记住这句话。
测试是代码中没有测试的部分。
单元测试的思维方式
单元测试背后的哲学是什么?我在文章中多次提到行为这个词。简而言之,这就是答案。单元测试检查行为,但不是直接调用函数。这听起来可能有点复杂,所以让我们来解构一下这个说法。
重构的稳定性
想象一下,你做了一些小的代码重构,而你的一堆测试突然开始失败了。这是一个令人抓狂的场景。如果没有业务逻辑的变化,我们不想破坏我们的测试。让我们用一个具体的例子来澄清这一点。
让我们假设一个用户可以删除他们存档的所有帖子。下面是可能的Java实现。
public class PostDeleteService {
private final UserService userService;
private final PostRepository postRepository;
public void deleteAllArchivedPosts() {
User currentUser = userService.getCurrentUser();
List<Post> posts = postRepository.findByPredicate(
PostPredicate.create()
.archived(true).and()
.createdBy(oneOf(currentUser))
);
postRepository.deleteAll(posts);
}
}
PostRepository是一个代表外部存储的接口。例如,它可以是PostgreSQL或MySQL。PostPredicate是一个自定义的谓词构建器。
我们如何测试该方法的正确性?我们可以为UserService和PostRepository提供模拟,并检查输入参数的权益。看一下下面的例子。
public class PostDeleteServiceTest {
// initialization
@Test
void shouldDeletePostsSuccessfully() {
User mockUser = mock(User.class);
List<Post> mockPosts = mock(List.class);
when(userService.getCurrentUser()).thenReturn(mockUser);
when(postRepository.findByPredicate(
eq(PostPredicate.create()
.archived(true).and()
.createdBy(oneOf(mockUser)))
)).thenReturn(mockPosts);
postDeleteService.deleteAllArchivedPosts();
verify(postRepository, times(1)).deleteAll(mockPosts);
}
}
when、thenReturn和eq方法是MockitoJava库的一部分。我们将在文章的末尾更多地讨论各种测试库。
我们在这里测试行为吗?实际上,我们不做。没有测试,而是我们在验证方法的调用顺序。问题是,单元测试不能容忍它所测试的代码的重构。
想象一下,我们决定将oneOf(user)替换为is(user)谓词的用法。一个例子可以是这样的。
public class PostDeleteService {
private final UserService userService;
private final PostRepository postRepository;
public void deleteAllArchivedPosts() {
User currentUser = userService.getCurrentUser();
List<Post> posts = postRepository.findByPredicate(
PostPredicate.create()
.archived(true).and()
// replaced 'oneOf' with 'is'
.createdBy(is(currentUser))
);
postRepository.deleteAll(posts);
}
}
这应该不会有什么区别,对吗?重构根本没有改变业务逻辑。但是由于这个模拟设置,测试现在会失败。
public class PostDeleteServiceTest {
// initialization
@Test
void shouldDeletePostsSuccessfully() {
// setup
when(postRepository.findByPredicate(
eq(PostPredicate.create()
.archived(true).and()
// 'oneOf' but not 'is'
.createdBy(oneOf(mockUser)))
)).thenReturn(mockPosts);
// action
}
}
每次我们做哪怕是轻微的重构,测试都会失败。这使得维护成为一个很大的负担。想象一下,如果我们做了重大改变,可能会出什么问题。例如,如果我们添加postRepository.deleteAllByPredicate方法,就会破坏整个测试设置。
之所以会这样,是因为前面的例子关注的是错误的东西。我们要测试的是行为。让我们看看如何制作一个新的测试来实现这个目的。首先,我们需要为测试目的声明一个自定义的PostRepository实现。在RAM中存储数据是可以的,重要的是PostPredicate的识别。因此,调用方法依赖于谓词被正确对待的事实。
下面是测试的重构版本。
public class PostDeleteServiceTest {
// initialization
@Test
void shouldDeletePostsSuccessfully() {
User currentUser = aUser().name("n1");
User anotherUser = aUser().name("n2");
when(userService.getCurrentUser()).thenReturn(currentUser);
testPostRepository.store(
aPost().withUser(currentUser).archived(true),
aPost().withUser(currentUser).archived(true),
aPost().withUser(anotherUser).archived(true)
);
postDeleteService.deleteAllArchivedPosts();
assertEquals(1, testPostRepository.count());
}
}
以下是改变的内容。
- 没有PostRepository嘲弄。我们引入了一个自定义的实现。TestPostRepository。它封装了存储的帖子,并保证PostPredicate的正确处理。
- 我们没有声明PostRepository返回一个帖子列表,而是将真正的对象放在TestPostRepository中。
- 我们并不关心哪些函数被调用了。我们要验证的是删除操作本身。我们知道,仓库由当前用户的2个存档帖子和另一个用户的1个帖子组成。成功的操作过程应该留下一个帖子。这就是为什么我们把assertEquals放在帖子计数上。
现在,测试与具体的方法调用检查是隔离的。我们只关心TestPostRepository实现本身的正确性。PostDeleteService如何实现这个业务案例并不重要。这不是关于 "如何",而是关于一个单元 "做什么"。此外,这种重构不会破坏测试。
你可能还注意到,UserService仍然是一个普通的模拟。这很好,因为getCurrentUser()方法被替换的概率不大。此外,该方法没有参数。这意味着我们不需要处理可能出现的输入参数不匹配的问题。Mocks没有好坏之分,只是要记住,不同的任务需要不同的工具。
关于MVC框架的几句话
绝大多数的应用程序和服务都是使用MVC框架开发的。Spring Boot是Java中最流行的一个。尽管许多作者声称你的设计架构不应该依赖于框架(例如Robert Martin),但现实并没有那么简单。现在,许多项目都是 "面向框架 "的。用一个框架代替另一个框架是很难甚至是不可能的。当然,这也会影响到测试设计。
我不完全同意你的代码应该 "与框架完全隔离 "的观点。在我看来,依靠框架的功能来减少模板并专注于业务逻辑并不是什么大事。但这是一个广泛的辩论,不在本文的讨论范围之内。
要记住的是,你的业务代码应该从框架的架构中抽象出来。这意味着任何类都应该不知道开发者将其安装在什么环境中。否则,你的测试会在不必要的细节上变得过于耦合。如果你决定在某个时候从一个框架切换到另一个,这将是一项艰巨的任务。所需的时间和精力对任何公司来说都是不可接受的。
让我们继续看一个例子。假设我们有一个XML生成器。每个元素都有一个唯一的整数ID,我们有一个服务来生成这些ID。但是,如果生成的XML的数量很大呢?如果每个XML文档中的每个元素都有一个唯一的整数ID,可能会导致整数溢出。让我们想象一下,我们在项目中使用的是Spring。为了克服这个问题,我们决定用原型范围声明IDService。因此,每次触发生成器时,XMLService应该接收一个新的IDService实例。请看下面的例子。
@Service
public class XMLGenerator {
@Autowired
private IDService idService;
public XML generateXML(String rawData) {
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
这里的问题是,XMLGenerator是一个单子(默认的Spring Bean范围)。因此,它实例化了1次,IDService没有被刷新。
我们可以通过注入ApplicationContext并直接请求bean来解决这个问题。
@Service
public class XMLGenerator {
@Autowired
private ApplicationContext context;
public XML generateXML(String rawData) {
// Creates new IDService instance
IDService idService = context.getBean(IDService.class);
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
但问题是:现在这个类被绑定到了Spring生态系统中。XMLGenerator理解有一个DI-container,并且有可能从其中检索到一个类的实例。在这种情况下,单元测试变得更加困难。因为你不能在Spring上下文之外测试XMLGenerator。
更好的方法是声明一个IDServiceFactory,如下图所示。
@Service
public class XMLGenerator {
@Autowired
private IDServiceFactory factory;
public XML generateXML(String rawData) {
IDService idService = factory.getInstance();
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
这就更好了。IDServiceFactory封装了检索IDS服务实例的逻辑。IDServiceFactory被直接注入到类域中。Spring可以做到这一点。但如果没有Spring呢?你能用普通的单元测试做到这一点吗?嗯,技术上是可以的。Java Reflection API允许你修改私有字段的值。我不打算详细讨论这个问题,但我只想说:永远不要在你的测试中使用Reflection API!这绝对是一种反模式。这绝对是一种反模式。
但有一个例外。如果你的业务代码确实可以使用反射API,那么在测试中应用反射也是可以的。
让我们回到DI上。有3种方法来实现依赖性注入。
- 字段注入
- 设置器注入
- 构造器注入
第二种和第三种方法不存在第一种方法的问题。我们可以应用其中任何一种。这两种方法都可以工作。看一下下面的代码例子。
@Service
public class XMLGenerator {
private final IDServiceFactory factory;
public XMLGenerator(IDServiceFactory factory) {
this.factory = factory;
}
public XML generateXML(String rawData) {
IDService idService = factory.getInstance();
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
现在,XMLGenerator与框架的细节完全隔离了。
单元测试思维方式总结
- 测试代码做了什么,而不是它如何做。
- 代码重构不应该破坏测试。
- 将代码与框架的细节隔离开来。
最佳实践
现在我们可以讨论一些最佳实践来帮助你提高单元测试的质量。
命名
大多数IDE通过在类的名称中添加Test的后缀来生成测试套件的名称。PostServiceTest, WeatherTest, CommentControllerTest, 等等。有时这可能是足够的,但我认为这种方法有一些问题。
- 测试的类型不是自我描述的(单元、集成、e2e)。
- 你无法知道哪些方法被测试。
在我看来,更好的方法是加强简单的Test后缀。
- 指定测试的特定类型。这将有助于我们明确测试的边界。例如,你可能对同一个类有多个测试套件(PostServiceUnitTest , PostServiceIntegrationTest , PostServiceE2ETest)。
- 添加被测试的方法的名称。例如,WeatherUnitTest_getCurrentStatus。或者CommentControllerE2ETest_createComment。
第二点是值得商榷的。一些开发者声称,每一个类都应该尽可能的稳固,通过方法名称来区分测试可能会导致将类视为假的数据结构。
这些论点确实有道理,但我认为,把方法名放在一起也有好处。
- 不是所有的类都是坚实的。即使你是领域驱动设计的最大粉丝,也不可能以这种方式构建每个类。
- 有些方法比其他方法更复杂。你可能有10个测试方法只是为了验证一个类方法的行为。如果你把所有的测试都放在一个测试套件里,你会使它变得庞大而难以维护。
你也可以根据具体的环境,采用不同的命名策略。这没有单一的正确方法,但请记住,命名是一个重要的可维护性特征。选择一种策略在你的团队中共享是一个好的做法。
断言
我听说过,每个测试应该有一个断言。如果你有更多,那么最好把它分成多个套件。
我一般不喜欢边缘意见。这个确实有道理,但我要重新表述一下。
每个测试用例应该断言一个商业案例。
有多个断言是可以的,但要确保它们能阐明一个可靠的操作。例如,请看下面的代码例子。
public class PersonServiceTest {
// initialization
@Test
void shouldCreatePersonSuccessfully() {
Person person = personService.createNew("firstName", "lastName");
assertEquals("firstName", person.getFirstName());
assertEquals("lastName", person.getLastName());
}
}
尽管有两个断言,但它们被绑定到同一个业务环境(即创建一个新的人)。
现在看看这个代码片断。
class WeatherTest {
// initialization
@Test
void shouldGetCurrentWeatherStatus() {
LocalDate date = LocalDate.of(2012, 5, 25);
WeatherStatus testWeatherStatus = generateStatus();
tuneWeather(date, testWeatherStatus);
WeatherStatus result = weather.getStatusForDate(date);
assertEquals(
testWeatherStatus,
result,
"Unexpected weather status for date " + date
);
assertEquals(
result,
weather.getStatusForDate(date),
"Weather service is not idempotent for date " + date
);
}
}
这两个断言并没有构建一个坚实的代码。我们正在测试getStatusForDate的结果以及函数调用是空闲的这一事实。最好把这个套件分成两个测试,因为被测试的两件事并没有直接联系。
错误信息
测试可以失败。这就是测试的整个理念。如果你的套件是红色的,你应该怎么做?修复代码?但是你应该怎么做呢?问题的根源是什么?如果一个断言失败了,我们会得到一个错误日志,告诉我们哪里出错了,对吗?的确,这是真的。可悲的是,这些信息并不总是有用的。你如何写你的测试可以决定你在错误日志中得到什么样的反馈。
看一下下面的代码例子。
class WeatherTest {
// initialization
@ParameterizedTest
@MethodSource("weatherDates")
void shouldGetCurrentWeatherStatus(LocalDate date) {
WeatherStatus testWeatherStatus = generateStatus();
tuneWeather(date, testWeatherStatus);
WeatherStatus result = weather.getStatusForDate(date);
assertEquals(testWeatherStatus, result);
}
}
假设weatherDates提供了20个不同的日期值。事实上,有20个测试。一个测试失败了,这里是你得到的错误信息。
expected: <SHINY> but was: <CLOUDY>
Expected :SHINY
Actual :CLOUDY
不是那么具有描述性,是吗?19/20次测试都成功了。一定是某个日期出现了问题,但是错误信息没有给出太多细节。我们可以重写测试,使我们在失败时得到更多的反馈吗?当然可以!看一下下面的代码片段。
class WeatherTest {
// initialization
@ParameterizedTest
@MethodSource("weatherDates")
void shouldGetCurrentWeatherStatus(LocalDate date) {
WeatherStatus testWeatherStatus = generateStatus();
tuneWeather(date, testWeatherStatus);
WeatherStatus result = weather.getStatusForDate(date);
assertEquals(
testWeatherStatus,
result,
"Unexpected weather status for date " + date
);
}
}
现在错误信息更清晰了。
Unexpected weather status for date 2022-03-12 ==> expected: <SHINY> but was: <CLOUDY>
Expected :SHINY
Actual :CLOUDY
很明显,日期出了问题:2022-03-12。这个错误日志给了我们一个线索,我们应该从哪里开始调查。
另外,要注意toString的实现。当你把一个对象传给assertEquals时,库会用这个方法把它转化为一个字符串。
测试数据初始化
当我们测试任何东西时,我们可能需要一些数据来测试它(例如,数据库中的行,对象,变量等)。在测试中,有3种已知的方法来初始化数据。
直接声明
假设我们有一个Post类。看一下下面的代码片段。
public class Post {
private Long id;
private String name;
private User userWhoCreated;
private List<Comment> comments;
// constructor, getters, setters
}
我们可以用构造函数创建新的实例。
public class PostTest {
@Test
void someTest() {
Post post = new Post(
1,
"Java for beginners",
new User("Jack", "Brown"),
List.of(new Comment(1, "Some comment"))
);
// action...
}
}
然而,这种方法也有一些问题。
- 参数名称不具有描述性。你必须检查构造函数的声明,才能知道每个提供的值的含义。
- 类的属性不是静态的。如果再增加一个字段呢?你将不得不在每个测试中修复每个构造函数的调用。
那设置器呢?让我们看看那会是什么样子。
public class PostTest {
@Test
void someTest() {
Post post = new Post();
post.setId(1);
post.setName("Java for beginners");
User user = new User();
user.setFirstName("Jack");
user.setLastName("Brown");
post.setUser(user);
Comment comment = new Comment();
comment.setId(1);
comment.setTitle("Some comment");
post.setComments(List.of(comment));
// action...
}
}
现在,参数名是透明的,但其他问题已经出现了。
- 声明太啰嗦了。乍一看,很难说清到底发生了什么。
- 有些参数可能是强制性的。如果我们在Post类中添加另一个字段,可能会因为对象的不一致而导致运行时的异常。
我们需要一种不同的方法来解决这些问题。
对象母模式
实际上,它只是一个简单的静态工厂,将实例化的复杂性隐藏在一个漂亮的门面后面。看一下下面的代码例子。
public class PostFactory {
public static Post createSimplePost() {
// simple post logic
}
public static Post createPostWithUser(User user) {
// simple post logic
}
}
这对简单的情况是有效的。但是Post类有很多不变量(例如:带评论的帖子、带评论和用户的帖子、带用户的帖子、带用户和多个评论的帖子,等等)。如果我们试图为每一种可能的情况都声明一个单独的方法,很快就会变成一团糟。输入测试数据生成器。
测试数据生成器模式
这个名字定义了它的目的。它是一个专门为测试数据声明而创建的构建器。让我们看看它在测试中是什么样子的。
public class PostTest {
@Test
void someTest() {
Post post = aPost()
.id(1)
.name("Java for beginners")
.user(aUser().firstName("Jack").lastName("Brown"))
.comments(List.of(
aComment().id(1).title("Some comment")
))
.build();
// action...
}
}
aPost()、aUser()和aComment()是静态方法,为相应的类创建构建器。它们封装了所有属性的默认值。调用id、name和其他方法可以重写这些值。你也可以加强默认的构建器模式的方法,让它们变得不可改变,使每个属性的改变都返回一个新的构建器实例。声明模板以减少模板也很有帮助。
public class PostTest {
private PostBuilder defaultPost =
aPost().name("post1").comments(List.of(aComment()));
@Test
void someTest() {
Post postWithNoComments = defaultPost.comments(emptyList()).build();
Post postWithDifferentName = defaultPost.name("another name").build();
// action...
}
}
如果你想真正深入研究这个问题,我写了一整篇关于以干净的方式声明测试数据的文章。你可以在这里阅读。
最佳实践总结
- 命名很重要。测试套件的名称应该是声明性的,足以理解其目的。
- 不要把没有任何共同点的断言分组。
- 具体的错误信息是快速发现错误的关键。
- 测试数据的初始化很重要。不要忽视这一点。
但关于测试,你应该记住的主要事情是。
测试应该帮助编写代码,而不是增加维护的负担。
工具
市场上有几十个测试库和框架。我将列出Java语言中最流行的那些。
JUnit
Java的事实上的标准。在大多数项目中使用。它提供了一个测试运行引擎和断言库,并将其合并为一个人工制品。
Mockito
最流行的Java嘲讽库。它提供了一个友好流畅的API来设置测试嘲讽。请看下面的例子。
public class SomeSuite {
@Test
void someTest() {
// creates a mock for CommentService
CommentService mockService = mock(CommentService.class);
// when mockService.getCommentById(1) is called, new Comment instance is returned
when(mockService.getCommentById(eq(1)))
.thenReturn(new Comment());
// when mockService.getCommentById(2) is called, NoSuchElementException is thrown
when(mockService.getCommentById(eq(2)))
.thenThrow(new NoSuchElementException());
}
}
Spock
正如文档中所说,这是一个企业级的规范框架。简而言之,它有一个测试运行器,以及断言和嘲弄工具。你用Groovy而不是Java编写Spock测试。下面是一个简单的案例,验证2+2=4。
def "two plus two should equal four"() {
given:
int left = 2
int right = 2
when:
int result = left + right
then:
result == 4
}
Vavr测试
Vavr Test需要特别注意。这个是一个属性测试库。它与普通的基于断言的工具不同。Vavr Test提供了一个输入值生成器。对于每个生成的值,它检查不变的结果。如果这是假的,测试数据的数量就会减少,直到只剩下失败的结果。请看下面的例子,它检查isEven函数是否正常工作。
public class SomeSuite {
@Test
void someTest() {
Arbitrary<Integer> evenNumbers = Arbitrary.integer()
.filter(i -> i > 0)
.filter(i -> i % 2 == 0);
CheckedFunction1<Integer, Boolean> alwaysEven =
i -> isEven(i);
CheckResult result = Property
.def("All numbers must be treated as even ones")
.forAll(evenNumbers)
.suchThat(alwaysEven)
.check();
result.assertIsSatisfied();
}
}
结论
测试是软件开发的重要组成部分,而单元测试是基础。它们代表了各种自动化测试的基础,所以以最高的质量标准编写单元测试是至关重要的。
测试的最大优势是它们可以在没有人工交互的情况下运行。要确保你在CI环境中对拉动请求中的每个变化都运行测试。如果你不这样做,你的项目的质量将受到影响。Semaphore CI是一个出色的CI/CD工具,用于自动化和运行测试和部署,所以请试一试。
就这样吧!如果你有任何问题或建议,你可以给我发短信或在这里留言。谢谢你的阅读!
The postA Deep Dive into Unit Testingappeared first onSemaphore.