在整个本文中,我将在代码片段中使用Java,同时还将使用JUnit和Mockito 。
本文旨在提供示例测试代码,这些示例可以是:
- 难以阅读
- 难以维护
在这些示例之后,本文将尝试提供替代方法,这些替代方法可用于增强测试的可读性,从而有助于使其在将来更易于维护。
创建良好的示例具有挑战性,因此,作为读者,我鼓励您将示例仅用作了解本文基本信息的工具,以力求实现可读的测试代码。
1.通用测试名称
您可能已经看到了如下命名的测试
@Test
void testTranslator() {String word = new Translator().wordFrom(1);assertThat(word, is("one"));
}
现在这是非常通用的,不会通知代码的读者测试实际上正在测试什么。 Translator
可能有多种方法,我们如何知道测试中正在使用哪种方法? 通过查看测试名称并不清楚,这意味着我们必须查看测试本身才能看到。
我们可以做得更好,因此可以看到以下内容:
@Test
void translate_from_number_to_word() {String word = new Translator().wordFrom(1);assertThat(word, is("one"));
}
从上面我们可以看到,它在解释该测试实际上在做什么方面做得更好。 此外,如果你的名字你的测试文件类似TranslatorShould
你可以当你把测试文件和单个测试名称形成在你心目中是合理的一句话: Translator should translate from number to word
。
2.测试设置中的变异
在测试中很有可能会希望将测试中使用的对象构造为处于特定状态。 有不同的方法,下面显示了一种这样的方法。 在此代码段中,我们基于该对象中包含的信息来确定某个字符是否实际上是“ Luke Skywalker”(想象这就是isLuke()
方法的作用):
@Test
void inform_when_character_is_luke_skywalker() {StarWarsTrivia trivia = new StarWarsTrivia();Character luke = new Character();luke.setName("Luke Skywalker");Character vader = new Character();vader.setName("Darth Vader");luke.setFather(vader);luke.setProfession(PROFESSION.JEDI);boolean isLuke = trivia.isLuke(luke);assertTrue(isLuke);
}
上面构造了一个Character
对象来表示“ Luke Skywalker”,此后发生的事涉及相当比例的突变。 它继续在随后的行中设置名称,父母身份和职业。 当然,这忽略了与我们的朋友“达斯·维达”发生的类似事情。
这种突变水平分散了测试中正在发生的事情。 如果我们再回顾一下我先前的句子:
在测试中很有可能您希望将测试中使用的对象构造为处于特定状态
但是,上述测试实际上发生了两个阶段:
- 构造对象
- 使其处于某种状态
这是不必要的,我们可以避免。 可能有人建议,为了避免发生突变,我们可以简单地将所有内容都移植并转储到构造函数中,以确保我们以给定状态构造对象,从而避免发生突变:
@Test
void inform_when_character_is_luke_skywalker() {StarWarsTrivia trivia = new StarWarsTrivia();Character vader = new Character("Darth Vader");Character luke = new Character("Luke Skywalker", vader, PROFESSION.JEDI);boolean isLuke = trivia.isLuke(luke);assertTrue(isLuke);
}
从上面我们可以看到,我们减少了代码行的数量以及对象的变异。 但是,在此过程中,我们已经失去了Character
(现在为Character
参数)在测试中表示的含义。 为了使isLuke()
方法返回true,我们传入的Character
对象必须具有以下内容:
- “卢克·天行者”的名字
- 有一个父亲叫“达斯·维达”
- 成为绝地武士
但是,从这种情况的测试中还不清楚,我们必须检查Character
的内部以了解这些参数的用途(或者您的IDE会告诉您)。
我们可以做的更好,我们可以利用Builder模式在所需状态下构造一个Character
对象,同时还可以保持测试的可读性:
@Test
void inform_when_character_is_luke_skywalker() {StarWarsTrivia trivia = new StarWarsTrivia();Character luke = CharacterBuilder().aCharacter().withNameOf("Luke Skywalker").sonOf(new Character("Darth Vader")).employedAsA(PROFESSION.JEDI).build();boolean isLuke = trivia.isLuke(luke);assertTrue(isLuke);
}
通过上面的内容,可能还会有几行内容,但是它试图解释测试中的重要内容。
3.断言疯狂
在测试期间,您将断言/验证系统中是否发生了某些事情(通常位于每次测试结束时)。 这是测试中非常重要的一步,可能很想添加许多断言,例如断言返回的对象的值。
@Test
void successfully_upgrades_user() {UserService service = new UserService();User someBasicUser = UserBuilder.aUser().withName("Basic Bob").withAge(23).withTypeOf(UserType.BASIC).build();User upgradedUser = service.upgrade(someBasicUser);assertThat(upgradedUser.name(), is("Basic Bob"));assertThat(upgradedUser.type(), is(UserType.SUPER_USER));assertThat(upgradedUser.age(), is(23));
}
(在上面的示例中,我向构建器提供了其他信息,例如名称和年龄,但是如果对测试不重要,通常不会包含此信息,请在构建器中使用明智的默认值)
如我们所见,存在三个断言,在更极端的示例中,我们谈论的是数十行断言。 我们不一定需要执行三个断言,有时我们可以合而为一:
@Test
void successfully_upgrades_user() {UserService service = new UserService();User someBasicUser = UserBuilder.aUser().withName("Basic Bob").withAge(23).withTypeOf(UserType.BASIC).build();User expectedUserAfterUpgrading = UserBuilder.aUser().withName("Basic Bob").withAge(23).withTypeOf(UserType.SUPER_USER).build();User upgradedUser = service.upgrade(someBasicUser);assertThat(upgradedUser, is(expectedUserAfterUpgrading));
}
现在,我们将升级后的用户与我们希望对象在升级后的外观进行比较。 为此,您将需要比较的对象( User
)具有覆盖的equals
和hashCode
。
4.神奇的价值观
您是否曾经看过数字或字符串并想知道它代表什么? 我已经过了,那些不得不解析代码行的宝贵时间可以很快加起来。 我们在下面有这样的代码示例。
@Test
void denies_entry_for_someone_who_is_not_old_enough() {Person youngPerson = PersonBuilder.aPerson().withAgeOf(17).build();NightclubService service = new NightclubService(21);String decision = service.entryDecisionFor(youngPerson);assertThat(decision, is("No entry. They are not old enough."));
}
阅读以上内容,您可能会遇到一些问题,例如:
-
17
是什么意思? -
21
在构造函数中是什么意思?
如果我们可以向代码的读者表示它们的含义,那不是很好,那么他们不必考虑太多吗? 幸运的是,我们可以:
private static final int SEVENTEEN_YEARS = 17;
private static final int MINIMUM_AGE_FOR_ENTRY = 21;
private static final String NO_ENTRY_MESSAGE = "No entry. They are not old enough.";@Test
void denies_entry_for_someone_who_is_not_old_enough() {Person youngPerson = PersonBuilder.aPerson().withAgeOf(SEVENTEEN_YEARS).build();NightclubService service = new NightclubService(MINIMUM_AGE_FOR_ENTRY);String decision = service.entryDecisionFor(youngPerson);assertThat(decision, is(NO_ENTRY_MESSAGE));
}
现在,当我们看以上内容时,我们知道:
-
SEVENTEEN_YEARS
是用来表示17年的值,毫无疑问,我们已经在读者的脑海中留下了疑问。 不是秒或分钟,而是年。 -
MINIMUM_AGE_FOR_ENTRY
是必须允许某人进入夜总会的值。 读者甚至不必关心该值是什么,而只是了解测试背景下的含义。 -
NO_ENTRY_MESSAGE
是返回的值,表示不允许某人进入夜总会。 从本质上讲,字符串通常具有更好的描述性,但是请始终检查代码以找出可以改进的地方。
这里的关键是减少代码阅读器尝试解析代码行所花费的时间。
5.难以阅读的测试名称
@Test
void testingNumberOneAndNumberTwoCanBeAddedTogetherToProduceNumberThree() {...
}
您花了多长时间阅读以上内容? 它易于阅读吗?您能快速了解一下此处正在测试的内容吗?还是需要解析许多字符?
幸运的是,我们可以尝试以更好的方式命名测试,方法是将测试减少到实际测试的水平,并删除试图添加的华夫饼:
@Test
void twoNumbersCanBeAdded() {...
}
它的阅读效果更好吗? 我们减少了这里的单词数量,更易于解析。 如果我们可以更进一步,问我们是否可以放弃使用骆驼箱怎么办:
@Test
void two_numbers_can_be_added() {...
}
这是一个优先事项,应该由对给定代码库做出贡献的人员同意。 使用蛇形小写字母(如上所述)可以帮助提高测试名称的可读性,因为您很可能打算模仿书面句子。 因此,蛇形格的使用紧随普通书面句子中存在的物理空间。 但是,Java不允许在方法名称中使用空格,这是我们所拥有的最好的方法,缺少使用Spock之类的东西。
6.依赖注入的设置器
通常,对于测试,您希望能够为给定对象(也称为“协作对象”或简称为“协作者”)注入依赖关系。 为了达到这个目的,您可能已经看到了类似以下内容的内容:
@Test
void save_a_product() {ProductService service = new ProductService();TestableProductRepository repository = mock(TestableProductRepository.class);service.setRepository(repository);Product newProduct = new Product("some product");service.addProduct(newProduct);verify(repository).save(newProduct);
}
上面使用了setter方法,即setRepository()
,以便注入TestableProductRepository
的模拟,因此我们可以验证服务和存储库之间是否发生了正确的协作。
类似于围绕突变的观点,这里我们对ProductService
进行突变,而不是将对象构造为所需的状态。 可以通过将协作者注入构造函数中来避免这种情况:
@Test
void save_a_product() {TestableProductRepository repository = mock(TestableProductRepository.class);ProductService service = new ProductService(repository);Product newProduct = new Product("some product");service.addProduct(newProduct);verify(repository).save(newProduct);
}
因此,现在我们将协作者注入了构造函数中,现在我们在构造时就知道对象将处于什么状态。但是,您可能会问“在此过程中我们是否没有丢失某些上下文?”。
我们已经从
service.setRepository(repository);
至
ProductService service = new ProductService(repository);
前者更具描述性。 因此,如果您不喜欢这种上下文丢失的情况,则可以选择类似构建器的内容,并创建以下内容:
@Test
void save_a_product() {TestableProductRepository repository = mock(TestableProductRepository.class);ProductService service = ProductServiceBuilder.aProductService().withRepository(repository).build();Product newProduct = new Product("some product");service.addProduct(newProduct);verify(repository).save(newProduct);
}
该解决方案使我们能够避免在通过withRepository()
方法记录协作者注入的情况下改变ProductService
。
7.非描述性验证
如前所述,您的测试通常会包含验证语句。 不用自己动手,您通常会利用库来执行此操作。 但是,您必须注意不要掩盖验证的意图。 要了解我在说什么,请看以下示例。
@Test
void no_error_is_shown_when_user_is_valid() {UIComponent component = mock(UIComponent.class);User user = mock(User.class);when(user.isValid()).thenReturn(true);LoginController controller = new LoginController();controller.attemptLogin(component, user);verifyZeroInteractions(component);
}
现在,如果您看上面的内容,您是否立即知道该断言表明没有错误显示给用户? 可能是因为它是测试的名称,但是您可能不将该代码行与测试名称相关联 。 这是因为它是Mockito的代码,并且通用以适应许多不同的用例。 它按照它说的做,检查与UIComponent
的模拟是否没有交互。
但是,这意味着您的测试有所不同。 我们如何努力使其更加清晰。
@Test
void no_error_is_shown_when_user_is_valid() {UIComponent component = mock(UIComponent.class);User user = mock(User.class);when(user.isValid()).thenReturn(true);LoginController controller = new LoginController();controller.attemptLogin(component, user);verify(component, times(0)).addErrorMessage("Invalid user");
}
这样会更好一些,因为此代码的读者有很大的潜力可以快速了解此行的工作。 但是,在某些情况下,可能仍然很难阅读。 在这种情况下,请按照以下说明提取一种方法,以更好地解释您的验证。
@Test
void no_error_is_shown_when_user_is_valid() {UIComponent component = mock(UIComponent.class);User user = mock(User.class);when(user.isValid()).thenReturn(true);LoginController controller = new LoginController();controller.attemptLogin(component, user);verifyNoErrorMessageIsAddedTo(component);
}private void verifyNoErrorMessageIsAddedTo(UIComponent component) {verify(component, times(0)).addErrorMessage("Invalid user");
}
上面的代码并不完美,但是在当前测试的范围内,它肯定可以提供我们正在验证的内容的高层次概述。
结束语
我希望您喜欢这篇文章,下次您完成编写测试时将花费一到两个重构步骤。 在下一次之前,我给你以下报价:
“必须编写程序供人们阅读,并且只能偶然地使机器执行。” ― Harold Abelson,计算机程序的结构和解释
翻译自: https://www.javacodegeeks.com/2019/08/seven-testing-sins-and-how-to-avoid-them.html