30、提升测试代码质量的方法与实践

提升测试代码质量的实践

提升测试代码质量的方法与实践

1. 测试质量的重要性与重构的意义

在测试代码的编写过程中,随着测试代码库规模的增长,对代码进行重构的需求也会相应增加。而且,当对实际规模的测试类进行重构时,重构的好处会更加明显。

2. 提升测试代码质量的具体方法

2.1 使用有意义的命名

良好的命名对于测试代码至关重要。我们已经讨论过测试类名、测试方法名的命名模式,以及测试替身的命名方案。合理的命名能让代码更易理解。

例如,在一个安全模块的测试中,最初的代码如下:

@DataProvider
public static Object[][] userHasPermissions() {
    return new Object[][]{
        {"user_1", Permission.READ},
        {"user_1", Permission.WRITE},
        {"user_1", Permission.REMOVE},
        {"user_2", Permission.WRITE},
        {"user_2", Permission.READ},
        {"user_3", Permission.READ}
    };
}
@Test(dataProvider = "userHasPermissions")
public void shouldReturnTrueIfUserHasPermission(
        String username, Permission permission) {
    assertTrue(sut.hasPermission(username, permission));
}

这段代码存在问题,对于初次查看测试的人来说,不清楚 user_2 是否应被授予 READ 权限,需要分析之前注册用户的数据。

改进后的代码如下:

@DataProvider
public static Object[][] usersPermissions() {
    return new Object[][]{
        {ADMIN, Permission.READ},
        {ADMIN, Permission.WRITE},
        {ADMIN, Permission.REMOVE},
        {LOGGED, Permission.WRITE},
        {LOGGED, Permission.READ},
        {GUEST, Permission.READ}
    };
}
@Test(dataProvider = "usersPermissions")
public void shouldReturnTrueIfUserHasPermission(
        String username, Permission permission) {
    assertTrue(sut.hasPermission(username, permission));
}

改进后的代码清晰明了, ADMIN 应拥有所有权限,普通登录用户可以读写但不能删除,访客用户只能查看。代码本身就能说明问题,无需查阅文档。

2.2 让测试代码一目了然

测试代码的可读性可以通过多种方式提升。例如,创建 MockServer 对象的代码:

server = new MockServer(responseMap, true,
                new URL(SERVER_ROOT).getPort(), false);

这段代码很难理解 server 变量的属性和创建的服务器类型。可以通过引入有意义的常量来改进:

private static final boolean NO_SSL = false;
private static final boolean RESPONSE_IS_A_FILE = true;
server = new MockServer(responseMap, RESPONSE_IS_A_FILE,
                    new URL(SERVER_ROOT).getPort(), NO_SSL);

改进后的代码表明该服务器以文件形式响应,且不使用 SSL。另外,还可以使用测试数据构建器模式来提高代码可读性。

2.3 让无关数据清晰可见

如果改变一个值不会影响要检查的行为结果,那么这个值就是该测试的无关数据。例如:

@Test
public void kidsNotAllowed() {
    Person kid = new Person("Johnny", "Mnemonic");
    kid.setAge(12);
    assertFalse(kid.isAdult(), kid + " is a kid!");
}

这段代码中不清楚 firstname lastname 对测试逻辑是否重要。可以通过赋予它们明确表示无关的名称来改进:

@Test
public void kidsNotAllowed() {
    Person kid = new Person("ANY_NAME", "ANY_SURNAME");
    kid.setAge(12);
    assertFalse(kid.isAdult(), kid + " is a kid!");
}

如果测试失败,错误信息也能清楚显示重要数据。如果某个值在多个测试方法中重复使用,可以将其提取为常量:

private static final String ANY_NAME = "ANY_NAME";
private static final String ANY_SURNAME = "ANY_SURNAME";
@Test
public void kidsNotAllowed() {
    Person kid = new Person(ANY_NAME, ANY_SURNAME);
    kid.setAge(12);
    assertFalse(kid.isAdult(), kid + " is a kid!");
}

在编写测试时,就应该明确哪些数据是重要的,及时引入合适的变量名和值。

2.4 避免一次测试多个内容

之前编写的测试通常只验证一件事,但有时会出现一次测试多个场景的情况。例如:

public class PhoneSearchTest {
    @DataProvider
    public Object[][] getData() {
        return new Object[][] {
                { "48", true }, { "+48", true }, { "++48", true },
                { "+48503", true }, { "+4", false }, { "++4", false },
                { "", false }, { null, false }, { "  ", false }
        };
    }
    @Test(dataProvider = "getData")
    public void testQueryVerification(String prefix, boolean expected) {
        PhoneSearch ps = new PhoneSearch(prefix);
        assertEquals(ps.isValid(), expected);
    }
}

这个测试存在以下问题:
- 测试失败时,不清楚哪个功能出了问题。
- 测试方法名和数据提供者名不够明确。
- 测试代码过于复杂,使用布尔标志参数引入了逻辑,且使用了最通用的 assertEquals() 方法。

重构后的代码如下:

@DataProvider
public Object[][] validPrefixes() {
    return new Object[][] {
            { "48" }, { "48123" },
            { "+48" }, { "++48" }, { "+48503" }};
    }
@Test(dataProvider = "validPrefixes")
public void shouldRecognizeValidPrefixes(String validPrefix) {
    PhoneSearch ps = new PhoneSearch(validPrefix);
    assertTrue(ps.isValid());
}

@DataProvider
public Object[][] invalidPrefixes() {
    return new Object[][] {
            { "+4" }, { "++4" },
            { "" }, { null }, { "  " } };
}
@Test(dataProvider = "invalidPrefixes")
public void shouldRejectInvalidPrefixes(String invalidPrefix) {
    PhoneSearch ps = new PhoneSearch(invalidPrefix);
    assertFalse(ps.isValid());
}

重构后的测试有两个方法,分别验证有效前缀和无效前缀,移除了布尔标志,使用了更具意图的断言方法,每个测试方法有自己的数据提供者,方法名更明确。虽然测试代码变长了,但质量更高,失败时能清楚知道要修复的部分。

2.5 调整方法顺序

为了提高测试代码的可读性,可以调整方法顺序,使其在多个测试中保持一致。常见的测试类结构如下:
1. 私有字段
2. 数据提供者
3. 设置方法
4. 测试方法
5. 私有方法

通常在编写测试时就可以确定这种结构,无需等到重构阶段。

2.6 不要过度重构

单元测试的重构目标与主线代码有所不同。主线代码重构的目标是模块化和消除紧密耦合关系,而单元测试的主要目标是创建简单、易读的测试。

虽然代码冗余通常是不好的,但测试代码应尽可能简单、易读,避免引入逻辑。一些适用于生产代码的重构方法对测试代码可能不太有用。例如,过度使用私有辅助方法可能会使测试代码难以理解。在测试代码中,需要在 DRY 原则和测试的表达性之间找到平衡。

3. 提升测试质量的总结

为了实现高质量测试,可以遵循以下行动要点:
- 从一开始就将测试质量视为首要问题。
- 认真对待 TDD 的重构阶段。
- 像对待生产代码一样对测试代码进行代码审查。
- 及时修复测试代码中的问题,避免问题扩散。
- 认真思考所需的测试用例,采用 TDD 方法覆盖所有重要功能。
- 使用代码覆盖率工具发现未测试的区域。
- 遵循编程最佳实践,如单一职责原则和编写简短专注的方法。
- 保持测试的命名和结构一致。

4. 测试代码练习

下面是一个 Fridge 类的简单实现和测试代码,目前测试代码存在很多问题,需要进行清理和重构。

4.1 Fridge 类实现

public class Fridge {
    private Collection<String> food = new HashSet<String>();
    public boolean put(String item) {
        return food.add(item);
    }
    public boolean contains(String item) {
        return food.contains(item);
    }
    public void take(String item) throws NoSuchItemException {
        boolean result = food.remove(item);
        if (!result) {
            throw new NoSuchItemException(item + " not found in the fridge");
        }
    }
}

4.2 原测试代码

public void testFridge() {
    Fridge fridge = new Fridge();
    fridge.put("cheese");
    assertEquals(fridge.contains("cheese"), true);
    assertEquals(fridge.put("cheese"), false);
    assertEquals(fridge.contains("cheese"), true);
    assertEquals(fridge.contains("ham"), false);
    fridge.put("ham");
    assertEquals(fridge.contains("cheese"), true);
    assertEquals(fridge.contains("ham"), true);
    try {
        fridge.take("sausage");
        fail("There was no sausage in the fridge!");
    }
    catch(NoSuchItemException e) {
        // ok
    }
}

public void testPutTake() {
    Fridge fridge = new Fridge();
    List<String> food = new ArrayList<String>();
    food.add("yogurt");
    food.add("milk");
    food.add("eggs");
    for (String item : food) {
        fridge.put(item);
        assertEquals(fridge.contains(item), true);
        fridge.take(item);
        assertEquals(fridge.contains(item), false);
    }
    for (String item : food) {
        try {
            fridge.take(item);
            fail("there was no " + item + " in the fridge");
        }
        catch(NoSuchItemException e) {
            assertEquals(e.getMessage().contains(item), true);
        }
    }
}

在重构时,需要注意以下几点:
- 对测试类、方法和变量进行正确命名。
- 使用参数化测试。
- 消除重复代码。
- 解决隐藏的其他问题。同时,要确保不丢失任何测试用例。

4.3 测试代码改进思路流程图

graph LR
    A[原测试代码] --> B[命名优化]
    B --> C[参数化测试]
    C --> D[消除重复代码]
    D --> E[其他问题解决]
    E --> F[高质量测试代码]

通过以上方法和实践,可以有效提升测试代码的质量,使测试更加可靠和易于维护。

5. 原测试代码问题分析

5.1 命名问题

原测试代码中的方法名和变量名缺乏明确的含义,不能清晰地表达测试的意图。例如, testFridge() testPutTake() 这样的方法名过于笼统,无法让人直观地了解测试的具体内容。在变量命名方面,像 food 这样的变量名虽然能表示是食物列表,但不够具体,没有体现出这些食物在测试中的作用。

5.2 代码重复问题

原测试代码中存在明显的代码重复。在 testFridge() testPutTake() 方法中,都有创建 Fridge 对象的代码,这部分代码可以提取出来避免重复。另外,在 testPutTake() 方法中,对每个食物项的放入、取出和验证操作有重复的代码逻辑。

5.3 缺乏参数化测试

原测试代码没有使用参数化测试,导致测试代码冗余。例如,在 testFridge() 方法中,对不同食物的操作是逐个编写的,没有利用参数化测试来简化代码。

5.4 问题总结表格

问题类型 具体表现 影响
命名问题 方法名和变量名不明确 难以理解测试意图,降低代码可读性
代码重复 创建 Fridge 对象和食物操作逻辑重复 增加代码量,维护困难
缺乏参数化测试 逐个编写食物操作测试 代码冗余,可维护性差

6. 重构测试代码示例

6.1 命名优化

首先,对测试类、方法和变量进行命名优化,使其更具描述性。以下是优化后的代码:

public class FridgeTest {
    @Test
    public void shouldAddAndCheckFoodInFridge() {
        Fridge fridge = new Fridge();
        String cheese = "cheese";
        fridge.put(cheese);
        assertTrue(fridge.contains(cheese));
        assertFalse(fridge.put(cheese));
        assertTrue(fridge.contains(cheese));

        String ham = "ham";
        assertFalse(fridge.contains(ham));
        fridge.put(ham);
        assertTrue(fridge.contains(cheese));
        assertTrue(fridge.contains(ham));

        String sausage = "sausage";
        try {
            fridge.take(sausage);
            fail("There was no " + sausage + " in the fridge!");
        } catch (NoSuchItemException e) {
            // ok
        }
    }

    @Test
    public void shouldPutAndTakeFoodFromFridge() {
        Fridge fridge = new Fridge();
        List<String> testFoods = Arrays.asList("yogurt", "milk", "eggs");
        for (String food : testFoods) {
            fridge.put(food);
            assertTrue(fridge.contains(food));
            fridge.take(food);
            assertFalse(fridge.contains(food));
        }
        for (String food : testFoods) {
            try {
                fridge.take(food);
                fail("There was no " + food + " in the fridge!");
            } catch (NoSuchItemException e) {
                assertTrue(e.getMessage().contains(food));
            }
        }
    }
}

在这个优化后的代码中, shouldAddAndCheckFoodInFridge() shouldPutAndTakeFoodFromFridge() 方法名清晰地表达了测试的意图。变量名如 cheese ham sausage testFoods 也更具描述性。

6.2 参数化测试

为了进一步简化代码,我们可以使用参数化测试。以下是使用 JUnit 的 @ParameterizedTest @CsvSource 进行参数化测试的示例:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.*;

public class FridgeTest {
    @ParameterizedTest
    @CsvSource({
            "cheese, true",
            "ham, true",
            "sausage, false"
    })
    public void shouldCheckFoodExistenceInFridge(String food, boolean expected) {
        Fridge fridge = new Fridge();
        fridge.put("cheese");
        fridge.put("ham");
        assertEquals(expected, fridge.contains(food));
    }

    @ParameterizedTest
    @CsvSource({
            "yogurt",
            "milk",
            "eggs"
    })
    public void shouldPutAndTakeSingleFoodFromFridge(String food) {
        Fridge fridge = new Fridge();
        fridge.put(food);
        assertTrue(fridge.contains(food));
        fridge.take(food);
        assertFalse(fridge.contains(food));
    }
}

通过参数化测试,我们可以将不同的测试数据作为参数传递给测试方法,减少了代码的重复,提高了代码的可维护性。

6.3 提取公共代码

为了避免创建 Fridge 对象的代码重复,我们可以使用 JUnit 的 @BeforeEach 注解来提取公共代码:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.*;

public class FridgeTest {
    private Fridge fridge;

    @BeforeEach
    public void setUp() {
        fridge = new Fridge();
    }

    @ParameterizedTest
    @CsvSource({
            "cheese, true",
            "ham, true",
            "sausage, false"
    })
    public void shouldCheckFoodExistenceInFridge(String food, boolean expected) {
        fridge.put("cheese");
        fridge.put("ham");
        assertEquals(expected, fridge.contains(food));
    }

    @ParameterizedTest
    @CsvSource({
            "yogurt",
            "milk",
            "eggs"
    })
    public void shouldPutAndTakeSingleFoodFromFridge(String food) {
        fridge.put(food);
        assertTrue(fridge.contains(food));
        fridge.take(food);
        assertFalse(fridge.contains(food));
    }
}

这样,在每个测试方法执行之前,都会自动创建一个新的 Fridge 对象,避免了代码的重复。

7. 重构后的测试代码优势

7.1 可读性提升

重构后的测试代码通过优化命名和使用参数化测试,大大提高了代码的可读性。方法名和变量名能够清晰地表达测试的意图,使得其他开发者能够快速理解测试的内容。

7.2 可维护性增强

通过消除代码重复和使用参数化测试,重构后的测试代码更易于维护。当需要添加新的测试数据或修改测试逻辑时,只需要在参数化测试的数据源中进行修改,而不需要修改大量的测试代码。

7.3 测试覆盖度保证

在重构过程中,我们确保了不丢失任何测试用例,保证了测试的覆盖度。通过参数化测试,还可以方便地添加更多的测试数据,进一步提高测试的覆盖度。

优势对比表格

对比项 原测试代码 重构后测试代码
可读性 低,命名不明确,代码逻辑复杂 高,命名清晰,代码结构简洁
可维护性 低,代码重复,修改困难 高,消除重复,参数化测试便于修改
测试覆盖度 保证基本覆盖,但代码冗余 保证覆盖且便于扩展测试数据

8. 总结与建议

8.1 总结

通过对测试代码的重构,我们解决了原测试代码中存在的命名问题、代码重复问题和缺乏参数化测试的问题,提高了测试代码的质量。重构后的测试代码具有更好的可读性、可维护性和测试覆盖度。

8.2 建议

  • 在编写测试代码时,要注重命名的规范性和描述性,让代码能够自解释。
  • 及时发现并消除代码重复,使用参数化测试来简化测试代码。
  • 像对待生产代码一样重视测试代码的质量,定期进行代码审查和重构。

通过遵循这些建议,可以不断提升测试代码的质量,确保软件的可靠性和稳定性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值