为了具有足够的代码覆盖范围,并创建新功能并重构旧功能而又不担心破坏某些内容,测试必须是可维护的并且易于阅读。在本文中,我将讨论我多年来收集的许多用Java编写单元测试和集成测试的技术。我将依靠现代技术:JUnit5,AssertJ,Testcontainers,而且我也不会忽略Kotlin。有些技巧对您来说似乎很明显,而另一些技巧可能与您在软件开发和测试方面所读的内容背道而驰。
简而言之
- 使用助手功能,参数化,AssertJ库的各种原语,简明扼要地编写测试,不要滥用变量,仅检查与测试功能相关的内容,并且不要将所有非标准案例都放在一个测试中
- , ,
- , -,
- KISS DRY
- , , , in-memory-
- JUnit5 AssertJ —
- : , , Clock - .
Given, When, Then (, , )
测试必须包含三个块,并用空白行分隔。每个块应尽可能短。使用本地方法来保持紧凑。
给定/给定(输入):测试准备,例如,数据创建和模拟配置。
当(动作):调用测试的方法时
/到(输出):检查接收值的正确性
//
@Test
public void findProduct() {
insertIntoDatabase(new Product(100, "Smartphone"));
Product product = dao.findProduct(100);
assertThat(product.getName()).isEqualTo("Smartphone");
}
使用前缀“实际*”和“预期的*”
//
ProductDTO product1 = requestProduct(1);
ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(product1).isEqualTo(product2);
如果要在匹配测试中使用变量,请在这些变量上添加“实际”和“预期”前缀。这将提高代码的可读性并阐明变量的用途。这也使它们在比较时更难以混淆。
//
ProductDTO actualProduct = requestProduct(1);
ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(actualProduct).isEqualTo(expectedProduct); //
使用预设值而不是随机值
避免将随机值输入测试输入。这可能导致测试闪烁,该死的很难调试。此外,如果您在错误消息中看到随机值,则将无法将其追溯到发生错误的位置。
//
Instant ts1 = Instant.now(); // 1557582788
Instant ts2 = ts1.plusSeconds(1); // 1557582789
int randomAmount = new Random().nextInt(500); // 232
UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad
为所有内容使用不同的预定义值。这样,您将获得完全可重复的测试结果,并通过错误消息快速在代码中找到正确的位置。
//
Instant ts1 = Instant.ofEpochSecond(1550000001);
Instant ts2 = Instant.ofEpochSecond(1550000002);
int amount = 50;
UUID uuid = UUID.fromString("00000000-000-0000-0000-000000000001");
您可以使用辅助函数将其编写得更短(请参见下文)。
编写简洁而具体的测试
尽可能使用辅助功能
将重复的代码隔离到局部函数中,并为其赋予有意义的名称。这将使您的测试紧凑,一目了然。
//
@Test
public void categoryQueryParameter() throws Exception {
List<ProductEntity> products = List.of(
new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),
new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),
new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)
);
for (ProductEntity product : products) {
template.execute(createSqlInsertStatement(product));
}
String responseJson = client.perform(get("/products?category=Office"))
.andExpect(status().is(200))
.andReturn().getResponse().getContentAsString();
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
}
//
@Test
public void categoryQueryParameter2() throws Exception {
insertIntoDatabase(
createProductWithCategory("1", "Office"),
createProductWithCategory("2", "Office"),
createProductWithCategory("3", "Hardware")
);
String responseJson = requestProductsByCategory("Office");
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
}
- 使用辅助函数创建数据(对象)(
createProductWithCategory()
)和复杂的检查。仅将那些参数传递给此测试中相关的帮助器函数;对于其余参数,请使用适当的默认值。在Kotlin中有为此的默认参数值,在Java中您可以使用方法调用链和重载来模拟默认参数。 - 可变长度参数列表将使您的代码更加优雅(
ìnsertIntoDatabase()
) - 辅助函数也可以用于创建简单值。Kotlin通过扩展功能甚至做得更好。
// (Java)
Instant ts = toInstant(1); // Instant.ofEpochSecond(1550000001)
UUID id = toUUID(1); // UUID.fromString("00000000-0000-0000-a000-000000000001")
// (Kotlin)
val ts = 1.toInstant()
val id = 1.toUUID()
Kotlin中的辅助函数可以这样实现:
fun Int.toInstant(): Instant = Instant.ofEpochSecond(this.toLong())
fun Int.toUUID(): UUID = UUID.fromString("00000000-0000-0000-a000-${this.toString().padStart(11, '0')}")
不要过度使用变量
程序员的条件反射是将常用的值移动到变量中。
//
@Test
public void variables() throws Exception {
String relevantCategory = "Office";
String id1 = "4243";
String id2 = "1123";
String id3 = "9213";
String irrelevantCategory = "Hardware";
insertIntoDatabase(
createProductWithCategory(id1, relevantCategory),
createProductWithCategory(id2, relevantCategory),
createProductWithCategory(id3, irrelevantCategory)
);
String responseJson = requestProductsByCategory(relevantCategory);
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly(id1, id2);
}
las,这是非常的代码重载。更糟糕的是,看到错误消息中的值将无法追溯到发生错误的位置。
“吻比干更重要”
//
@Test
public void variables() throws Exception {
insertIntoDatabase(
createProductWithCategory("4243", "Office"),
createProductWithCategory("1123", "Office"),
createProductWithCategory("9213", "Hardware")
);
String responseJson = requestProductsByCategory("Office");
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("4243", "1123");
}
如果您试图编写尽可能紧凑的测试(无论如何我都会热烈推荐),那么重用的值就清晰可见。代码本身变得更紧凑,更易读。最后,错误消息将引导您到发生错误的确切行。
不要将现有测试扩展为“再添加一件小事”
//
public class ProductControllerTest {
@Test
public void happyPath() {
// ...
}
}
在现有的测试中添加特殊情况以验证基本功能总是很诱人的。但是结果是,测试变得越来越大,越来越难以理解。散布在一大堆代码中的特殊情况很容易忽略。如果测试失败,您可能不会立即了解到底是什么原因造成的。
//
public class ProductControllerTest {
@Test
public void multipleProductsAreReturned() {}
@Test
public void allProductValuesAreReturned() {}
@Test
public void filterByCategory() {}
@Test
public void filterByDateCreated() {}
}
而是使用描述性名称编写一个新测试,以使其立即清楚所测试代码的预期行为。是的,您必须在键盘上键入更多字母(与此相反,请记住,辅助功能非常有用),但是您将获得一个简单且可理解的测试,并且结果可预测。顺便说一下,这是记录新功能的好方法。
仅检查您要测试的内容
考虑一下您正在测试的功能。避免仅仅因为可以就做不必要的检查。此外,请记住先前编写的测试中已经测试过的内容,不要重新测试。测试应紧凑,预期行为应显而易见,并且没有不必要的细节。
假设我们要测试一个返回产品列表的HTTP句柄。我们的测试套件应包含以下测试:
1.一个大型映射测试,用于验证来自数据库的所有值均已在JSON响应中正确返回并以正确的格式正确分配。如果正确实现方法,我们可以使用AssertJ包中的函数
isEqualTo()
(对于单个项目)或containsOnly()
(对于多个项目)轻松编写此代码equals()
...
String responseJson = requestProducts();
ProductDTO expectedDTO1 = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED));
ProductDTO expectedDTO2 = new ProductDTO("2", "envelope", new Category("smartphone"), List.of(States.ACTIVE));
assertThat(toDTOs(responseJson))
.containsOnly(expectedDTO1, expectedDTO2);
2.几个检查“类别”参数正确行为的测试。在这里,我们只想检查过滤器是否正常工作,而不是属性值,因为我们之前做过。因此,我们足以检查收到的产品ID的匹配项:
String responseJson = requestProductsByCategory("Office");
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
3.还有另外一些检查特殊情况或特殊业务逻辑的测试,例如,正确计算了响应中的某些值。在这种情况下,我们只对整个JSON响应中的几个字段感兴趣。因此,我们在测试中记录了这种特殊的逻辑。显然,我们在这里不需要这些字段。
assertThat(actualProduct.getPrice()).isEqualTo(100);
独立测试
不要隐藏相关参数(在辅助函数中)
//
insertIntoDatabase(createProduct());
List<ProductDTO> actualProducts = requestProductsByCategory();
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));
使用辅助函数来生成数据和检查条件很方便,但是必须使用参数来调用它们。接受测试中有意义且需要从测试代码进行控制的所有内容的参数。不要强迫读者跳入帮助程序功能以理解测试的含义。一个简单的规则:在查看测试本身时,测试的含义应该清楚。
//
insertIntoDatabase(createProduct("1", "Office"));
List<ProductDTO> actualProducts = requestProductsByCategory("Office");
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));
将测试数据保留在测试内部
一切都应该在里面。极有可能将某些数据传输到方法中
@Before
并从那里重用。但这将迫使读者在文件中来回跳动,以了解此处到底发生了什么。同样,帮助程序功能将帮助您避免重复,并使测试更易于理解。
使用合成代替继承
不要建立复杂的测试类层次结构。
//
class SimpleBaseTest {}
class AdvancedBaseTest extends SimpleBaseTest {}
class AllInklusiveBaseTest extends AdvancedBaseTest {}
class MyTest extends AllInklusiveBaseTest {}
这样的层次结构使理解变得更加复杂,您很可能会很快发现自己正在编写基础测试的下一个继任者,在其中隐匿了很多垃圾,根本不需要当前测试。这会使读者分神,并导致细微的错误。继承不是灵活的:您认为可以使用一个类的所有方法
AllInclusiveBaseTest
,但不能使用其父类吗?AdvancedBaseTest?
此外,读者将不得不不断在不同的基类之间跳转以了解全局。
“复制代码比选择错误的抽象更好”(Sandi Metz)
我建议改用合成。为每个与夹具相关的任务编写小片段和类(启动测试数据库,创建模式,插入数据,启动模拟服务器)。在方法中
@BeforeAll
或通过将创建的对象分配给测试类的字段来重用这些部分。这样,您将能够从这些空白(如Lego零件)构建每个新的测试类。结果,每个测试将具有其自己可理解的一组夹具,并确保没有任何事情发生。该测试变得自给自足,因为它包含了您所需的一切。
//
public class MyTest {
//
private JdbcTemplate template;
private MockWebServer taxService;
@BeforeAll
public void setupDatabaseSchemaAndMockWebServer() throws IOException {
this.template = new DatabaseFixture().startDatabaseAndCreateSchema();
this.taxService = new MockWebServer();
taxService.start();
}
}
//
public class DatabaseFixture {
public JdbcTemplate startDatabaseAndCreateSchema() throws IOException {
PostgreSQLContainer db = new PostgreSQLContainer("postgres:11.2-alpine");
db.start();
DataSource dataSource = DataSourceBuilder.create()
.driverClassName("org.postgresql.Driver")
.username(db.getUsername())
.password(db.getPassword())
.url(db.getJdbcUrl())
.build();
JdbcTemplate template = new JdbcTemplate(dataSource);
SchemaCreator.createSchema(template);
return template;
}
}
再来一次:
“吻比干更重要”
简单的测试是好的。将结果与常量进行比较
不要重复使用生产代码
测试应该验证生产代码,而不是重用它。如果您在测试中重复使用战斗代码,则可能会错过该代码中的错误,因为您不再对其进行测试。
//
boolean isActive = true;
boolean isRejected = true;
insertIntoDatabase(new Product(1, isActive, isRejected));
ProductDTO actualDTO = requestProduct(1);
//
List<State> expectedStates = ProductionCode.mapBooleansToEnumList(isActive, isRejected);
assertThat(actualDTO.states).isEqualTo(expectedStates);
而是在编写测试时考虑输入和输出。该测试将数据输入到输入,并将输出与预定义的常数进行比较。大多数时候,不需要代码重用。
// Do
assertThat(actualDTO.states).isEqualTo(List.of(States.ACTIVE, States.REJECTED));
不要将业务逻辑复制到测试中
当测试将战斗代码中的逻辑引入自身时,对象映射就是一个很好的例子。假设我们的测试包含一个方法
mapEntityToDto()
,其结果用于检查结果DTO是否包含与在测试开始时添加到基础中的元素相同的值。在这种情况下,您很可能会将战斗代码复制到测试中,其中可能包含错误。
//
ProductEntity inputEntity = new ProductEntity(1, "envelope", "office", false, true, 200, 10.0);
insertIntoDatabase(input);
ProductDTO actualDTO = requestProduct(1);
// mapEntityToDto() , -
ProductDTO expectedDTO = mapEntityToDto(inputEntity);
assertThat(actualDTO).isEqualTo(expectedDTO);
正确的解决方案是
actualDTO
将其与具有指定值的手动创建的参考对象进行比较。它非常简单,直接,可以防止潜在的错误。
//
ProductDTO expectedDTO = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED))
assertThat(actualDTO).isEqualTo(expectedDTO);
如果您不想创建并检查整个参考对象的匹配项,则可以检查子对象,或者通常只检查与测试相关的对象属性。
不要写太多逻辑
让我提醒您,测试主要是关于输入和输出。提交数据并检查返回给您的内容。无需在测试中编写复杂的逻辑。如果在测试中引入循环和条件,则会使它变得更难以理解,并且更容易出错。如果您的验证逻辑很复杂,请使用许多AssertJ函数为您完成这项工作。
在类似战斗的环境中运行测试
测试尽可能完整的组件包
通常建议使用模拟单独测试每个类。但是,这种方法有缺点:这样,就不会测试类之间的交互,并且通用实体的任何重构都会一次破坏所有测试,因为每个内部类都有自己的测试。另外,如果您为每个类编写测试,则它们太多了。
每个类别的隔离单元测试
相反,我建议重点关注集成测试。“集成测试”是指将所有类收集在一起(如在生产中),并测试整个捆绑包,包括基础架构组件(HTTP服务器,数据库,业务逻辑)。在这种情况下,您要测试行为而不是实现。这样的测试更准确,更接近真实世界,并且能够抵抗内部组件的重构。理想情况下,一类测试就足够了。
集成测试(=将所有类放在一起并测试捆绑包)
不要使用内存数据库进行测试
使用内存中的库,您可以在可以运行代码的不同环境中进行测试;
使用内存中的库(H2,HSQLDB,Fongo)进行测试,则会牺牲其有效性和范围。这样的数据库通常表现不同,并产生不同的结果。这样的测试可能成功通过,但不能保证应用程序在生产中正确运行。此外,由于它们未在内存数据库中实现或行为不同,因此,在无法使用或测试基础的某些行为或功能特性的情况下,您很容易发现自己。
解决方案:使用与实际操作相同的数据库。精彩的测试容器库 为Java应用程序提供了丰富的API,使您可以直接从测试代码管理容器。
Java / JVM
用 -noverify -XX:TieredStopAtLevel=1
始终
JVM -noverify -XX:TieredStopAtLevel=1
在配置中添加选项以运行测试。这将为您节省运行测试之前启动虚拟机的1-2秒。在测试的早期,当您经常从IDE运行它们时,这尤其有用。
请注意,由于Java 13已
-noverify
被弃用。
提示:将这些参数添加到IntelliJ IDEA中的“ JUnit”配置模板中,这样就不必在每次创建新项目时都这样做。
使用AssertJ
AssertJ是一个功能强大且成熟的库,具有丰富而安全的API,以及丰富的值验证功能和信息丰富的测试错误消息。许多便捷的验证功能使程序员无需在测试主体中描述复杂的逻辑,从而使测试变得简洁。例如:
assertThat(actualProduct)
.isEqualToIgnoringGivenFields(expectedProduct, "id");
assertThat(actualProductList).containsExactly(
createProductDTO("1", "Smartphone", 250.00),
createProductDTO("1", "Smartphone", 250.00)
);
assertThat(actualProductList)
.usingElementComparatorIgnoringFields("id")
.containsExactly(expectedProduct1, expectedProduct2);
assertThat(actualProductList)
.extracting(Product::getId)
.containsExactly("1", "2");
assertThat(actualProductList)
.anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));
assertThat(actualProductList)
.filteredOn(product -> product.getCategory().equals("Smartphone"))
.allSatisfy(product -> assertThat(product.isLiked()).isTrue());
避免使用assertTrue()
和assertFalse()
使用简单
assertTrue()
或assertFalse()
导致神秘的测试错误消息:
//
assertTrue(actualProductList.contains(expectedProduct));
assertTrue(actualProductList.size() == 5);
assertTrue(actualProduct instanceof Product);
expected: <true> but was: <false>
请改用AssertJ调用,这些调用可以立即返回清晰,有用的消息。
//
assertThat(actualProductList).contains(expectedProduct);
assertThat(actualProductList).hasSize(5);
assertThat(actualProduct).isInstanceOf(Product.class);
Expecting:
<[Product[id=1, name='Samsung Galaxy']]>
to contain:
<[Product[id=2, name='iPhone']]>
but could not find:
<[Product[id=2, name='iPhone']]>
如果需要检查布尔值,请使用
as()
AssertJ方法使消息更具描述性。
使用JUnit5
JUnit5是用于(单元)测试的出色库。它正在不断开发中,并为程序员提供许多有用的功能,例如参数化测试,分组,条件测试,生命周期控制。
使用参数化测试
参数化测试允许您使用一组不同的输入值运行相同的测试。这使您可以检查多种情况,而无需编写额外的代码。在JUnit5因为这是很好的工具
@ValueSource
,@EnumSource
,@CsvSource
和@MethodSource
。
//
@ParameterizedTest
@ValueSource(strings = ["§ed2d", "sdf_", "123123", "§_sdf__dfww!"])
public void rejectedInvalidTokens(String invalidToken) {
client.perform(get("/products").param("token", invalidToken))
.andExpect(status().is(400))
}
@ParameterizedTest
@EnumSource(WorkflowState::class, mode = EnumSource.Mode.INCLUDE, names = ["FAILED", "SUCCEEDED"])
public void dontProcessWorkflowInCaseOfAFinalState(WorkflowState itemsInitialState) {
// ...
}
我强烈建议充分利用此技巧,因为它使您可以以最小的努力测试更多的案例。
最后,我想引起您对
@CsvSource
和的关注@MethodSource
,该参数可用于更复杂的参数设置,在此您还需要控制结果:您可以将其传递给参数之一。
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"5, 3, 8",
"10, -20, -10"
})
public void add(int summand1, int summand2, int expectedSum) {
assertThat(calculator.add(summand1, summand2)).isEqualTo(expectedSum);
}
@MethodSource
与包含所有所需参数和预期结果的单独测试对象结合使用时特别有效。不幸的是,在Java中,此类数据结构(所谓的POJO)的描述非常繁琐。因此,我将举一个使用Kotlin数据类的示例。
data class TestData(
val input: String?,
val expected: Token?
)
@ParameterizedTest
@MethodSource("validTokenProvider")
fun `parse valid tokens`(data: TestData) {
assertThat(parse(data.input)).isEqualTo(data.expected)
}
private fun validTokenProvider() = Stream.of(
TestData(input = "1511443755_2", expected = Token(1511443755, "2")),
TestData(input = "151175_13521", expected = Token(151175, "13521")),
TestData(input = "151144375_id", expected = Token(151144375, "id")),
TestData(input = "15114437599_1", expected = Token(15114437599, "1")),
TestData(input = null, expected = null)
)
小组测试
@Nested
来自JUnit5的
注释对于将测试方法进行分组很方便。从逻辑上讲,是有意义的组一起某些类型的测试(如InputIsXY
,ErrorCases
),或者您的组中,以收集各试验的方法(GetDesign
和UpdateDesign
)。
public class DesignControllerTest {
@Nested
class GetDesigns {
@Test
void allFieldsAreIncluded() {}
@Test
void limitParameter() {}
@Test
void filterParameter() {}
}
@Nested
class DeleteDesign {
@Test
void designIsRemovedFromDb() {}
@Test
void return404OnInvalidIdParameter() {}
@Test
void return401IfNotAuthorized() {}
}
}
可读的测试名称@DisplayName
在Kotlin中带有反引号或反引号
在Java中,可以使用注释
@DisplayName
为测试赋予更多可读性的名称。
public class DisplayNameTest {
@Test
@DisplayName("Design is removed from database")
void designIsRemoved() {}
@Test
@DisplayName("Return 404 in case of an invalid parameter")
void return404() {}
@Test
@DisplayName("Return 401 if the request is not authorized")
void return401() {}
}
在Kotlin中,您可以通过在函数名称中使用反引号将其引起来并使用空格。这样,您无需使用代码冗余即可获得结果的可读性。
@Test
fun `design is removed from db`() {}
模拟外部服务
为了测试HTTP客户端,我们需要模拟它们访问的服务。为此,我经常使用OkHttp中的MockWebServer。替代品是来自Testcontainers的WireMock或Mockserver。
MockWebServer serviceMock = new MockWebServer();
serviceMock.start();
HttpUrl baseUrl = serviceMock.url("/v1/");
ProductClient client = new ProductClient(baseUrl.host(), baseUrl.port());
serviceMock.enqueue(new MockResponse()
.addHeader("Content-Type", "application/json")
.setBody("{\"name\": \"Smartphone\"}"));
ProductDTO productDTO = client.retrieveProduct("1");
assertThat(productDTO.getName()).isEqualTo("Smartphone");
使用等待性测试异步代码
Awaitility是用于测试异步代码的库。您可以指定在宣布测试失败之前重试检查结果的次数。
private static final ConditionFactory WAIT = await()
.atMost(Duration.ofSeconds(6))
.pollInterval(Duration.ofSeconds(1))
.pollDelay(Duration.ofSeconds(1));
@Test
public void waitAndPoll(){
triggerAsyncEvent();
WAIT.untilAsserted(() -> {
assertThat(findInDatabase(1).getState()).isEqualTo(State.SUCCESS);
});
}
无需解决DI依赖关系(春季)
DI框架初始化需要几秒钟的时间才能开始测试。这减慢了反馈循环,尤其是在开发的早期阶段。
因此,我尽量不要在集成测试中使用DI,而是手动创建必要的对象并将它们“绑定”在一起。如果使用构造函数注入,这是最简单的。通常,在测试中,您将验证业务逻辑,并且您不需要DI。
而且,从2.2版开始,Spring Boot支持Bean的延迟初始化,这大大加快了使用DI的测试速度。
您的代码必须是可测试的
不要使用静态访问。决不
静态访问是一种反模式。首先,它混淆了依赖关系和副作用,使整个代码难以阅读,并且容易出现细微的错误。其次,静态访问妨碍了测试。您不能再替换对象,但是在测试中,您需要使用具有不同配置的模拟或真实对象(例如,指向测试数据库的DAO对象)。
与其静态地访问代码,不如将其放入非静态方法中,实例化该类,然后将结果对象传递给构造函数。
//
public class ProductController {
public List<ProductDTO> getProducts() {
List<ProductEntity> products = ProductDAO.getProducts();
return mapToDTOs(products);
}
}
//
public class ProductController {
private ProductDAO dao;
public ProductController(ProductDAO dao) {
this.dao = dao;
}
public List<ProductDTO> getProducts() {
List<ProductEntity> products = dao.getProducts();
return mapToDTOs(products);
}
}
幸运的是,像Spring这样的DI框架提供了一些工具,这些工具不需要我们的参与即可通过自动创建和链接对象来使静态访问成为不必要的工具。
参数化
该类的所有相关部分必须可以在测试侧进行配置。可以将此类设置传递给类构造函数。
例如,想象一下,您的DAO固定有每个请求1000个对象的限制。要检查此限制,您需要在测试之前将1001个对象添加到测试数据库。使用构造函数参数,您可以使该值可自定义:在生产中,将1000保留下来,在测试中,将其减少为2。因此,要检查限制的工作,您只需要在测试数据库中添加3条记录即可。
使用构造函数注入
字段注入是有害的,并导致较差的代码可测试性。您需要在测试之前初始化DI或做一些怪异的反射魔术。因此,最好在测试过程中使用构造函数注入来轻松控制依赖对象。
在Java中,您必须编写一些额外的代码:
//
public class ProductController {
private ProductDAO dao;
private TaxClient client;
public ProductController(ProductDAO dao, TaxClient client) {
this.dao = dao;
this.client = client;
}
}
在Kotlin中,同样的东西写得更加简洁:
//
class ProductController(
private val dao: ProductDAO,
private val client: TaxClient
){
}
请勿使用Instant.now()
或new Date()
如果要测试此行为,则 无需通过调用
Instant.now()
或new Date()
在生产代码中获取当前时间。
//
public class ProductDAO {
public void updateDateModified(String productId) {
Instant now = Instant.now(); // !
Update update = Update()
.set("dateModified", now);
Query query = Query()
.addCriteria(where("_id").eq(productId));
return mongoTemplate.updateOne(query, update, ProductEntity.class);
}
}
问题是测试无法控制所花费的时间。您将无法将获得的结果与特定值进行比较,因为它始终不同。改用
Clock
Java中的类。
//
public class ProductDAO {
private Clock clock;
public ProductDAO(Clock clock) {
this.clock = clock;
}
public void updateProductState(String productId, State state) {
Instant now = clock.instant();
// ...
}
}
在此测试中,您可以为创建一个模拟对象
Clock
,将其传递给,ProductDAO
并配置该模拟对象以使其返回相同的时间。调用之后,updateProductState()
我们将能够检查指定的值是否已进入数据库。
将异步执行与实际逻辑分开
测试异步代码很棘手。诸如Awaitility之类的图书馆有很大的帮助,但是这个过程仍然很复杂,我们可能会经过一次闪烁的测试。如果可能,将业务逻辑(通常是同步的)和异步基础结构代码分开是有意义的。
例如,通过将业务逻辑放置在ProductController中,我们可以轻松地对其进行同步测试。所有异步和并行逻辑将保留在ProductScheduler中,可以对其进行隔离测试。
//
public class ProductScheduler {
private ProductController controller;
@Scheduled
public void start() {
CompletableFuture<String> usFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.US));
CompletableFuture<String> germanyFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.GERMANY));
String usResult = usFuture.get();
String germanyResult = germanyFuture.get();
}
}
科特林
我的文章Kotlin中的单元测试最佳实践包含许多Kotlin特定的单元测试技术。(请注意翻译:如果您对本文的俄语翻译感兴趣,请在评论中写出)。