测试基础 让单元测试更有效的 4 种方法

TesterHome小助手 · 2023年02月03日 · 最后由 49875183 回复于 2023年02月06日 · 4438 次阅读

编译:TesterHome
原文标题:4 Ways to Make Unit Tests More Effective
作者:Lorenzo Dell'Arciprete,Expedia 公司软件工程师

作者的话

在工作过程中,我(作者)注意到一些常见的错误步骤,它们使单元测试变得无效、冗长、难以维护,而且写起来很麻烦。
这篇文章提供了一些建议,以避免一些可能被忽视的错误,特别是对于经验不足的开发者。
请注意,本文中提到的一些例子是在 Java 中,另一些是在 Javascript 中,但其原则大多是可以互换的。

重置 Mocks

许多单元测试需要使用一些模拟来模拟外部状态或行为。初始化这些 mocks 往往涉及到一些常见的步骤,然后配置每个测试所需的状态/行为。
但是,如果不小心,你的测试就会开始混在一起。参考一下这个例子:

const mockExternalStore = {
    isValid: true
};

describe('UnderTest', () => {
    it('is valid if external store is', () => {
        const underTest = new UnderTest(mockExternalStore);
        expect(underTest.isValid()).to.be.equal(true);
    });

    it('is not valid if external store is not', () => {
        mockExternalStore.isValid = false;
        const underTest = new UnderTest(mockExternalStore);
        expect(underTest.isValid()).to.be.equal(false);
    });
}

如果按照顺序运行,这些测试将通过。但是如果它们以相反的顺序执行会发生什么呢?第二个测试仍然会通过,但第一个测试会失败。这是因为写这个测试时假设 mockExternalStore.isValid == true,就像其初始状态一样。但是第二个测试改变了 mock 的状态,新的状态会潜移默化地影响到在它之后运行的每个测试。

大多数测试框架,包括 Java 和 Javascript,都提供了一些设置函数,通常命名为 beforeEach。我建议你使用它们,以确保每一个测试都从相同的上下文开始。然后,这个例子就变成了:

let mockExternalStore;

describe('UnderTest', () => {
    beforeEach(() => {
        mockExternalStore = {
            isValid: true
        }; 
    });

    it('is valid if external store is', () => {
        // I assume mockExternalStore.isValid == true is the agreed starting scenario, so don't assign it again
        const underTest = new UnderTest(mockExternalStore);
        expect(underTest.isValid()).to.be.equal(true);
    });

    it('is not valid if external store is not', () => {
        mockExternalStore.isValid = false;
        const underTest = new UnderTest(mockExternalStore);
        expect(underTest.isValid()).to.be.equal(false);
     });
}

慎重对待异常

有时我们想测试一些非合规的情况是否会导致一个特定的异常被抛出。这可以很容易做到,但这需要审慎一点。考虑一下这个例子:

@Test
public void whenCallingForbiddenMethodThenThrowCustomException() {
    try {
        underTest.forbiddenMethod();
    } catch (Exception e) {
        assertTrue(e instanceof MyCustomException);
    }
}

这看起来差不多:如果除了 MyCustomException 之外的任何东西被抛出,测试就会失败。但是如果 forbiddenMethod 根本就没有抛出呢?测试仍然会通过,这很可能不是你想要的。

你可以通过两种方式解决这个问题。一些测试框架,如 JUnit,为你提供了一个专门设计的功能。那个测试可以像这样固定和简化:

@Test(expectedException = MyCustomException.class)
public void whenCallingForbiddenMethodThenThrowCustomException() {
    underTest.forbiddenMethod();
}

如果你没有这样的功能可用,或者如果你想对抛出的异常进行特定检查,则应记住测试失败,以防它没有遵循预期的过程:

@Test
public void whenCallingForbiddenMethodThenThrowCustomException() {
    try {
        underTest.forbiddenMethod();
        fail();
    } catch (MyCustomException e) {
        assertTrue(e.getCause() instanceof AnotherCustomException);
    }
}

倾向于 Explicit 测试数据

几个测试可以用不同的输入参数进行复制,以涵盖更多的常规和边缘案例。无论你是手动操作还是使用一些花哨的@DataProvider,你必须提供一组输入参数。

想象一下,必须测试一个基于常量密钥的散列函数。

public final String MY_KEY = "4e9aa2e2-0c29-4d01-a4fe-b464fd89ef74";

// Hashes input together with MY_KEY
public String hash(String input) {
    ...
}

你的测试可能看起来像这样:

@Test(dataProvider = "hashSamples")
public void hashingTest(String input, String expectedDigest) {
    assertEquals(underTest.hash(input), expectedDigest);
}

@DataProvider
private Object[][] hashSamples() {
    return new Object[][]{
        {"input1", "E6d212KuLc0XvXsc"},
        {"loooooonginpuuuuuuuuuut", "SCNb9HHscUPzCNHL"},
        {"input+with-symbol$", "5ePJWwMwYwQBxLf9"},
        {"", "aixwFwUnRmU1405D"},
        {null, "4aHg05q4ftFzn7dX"}};
}

但现在你可能会想,如果有人改变了 MY_KEY,你不希望测试中断。你可能会想用一些代码来自动生成这些参数。

@Test(dataProvider = "hashSamples")
public void hashingTest(String input, String expectedDigest) {
    assertEquals(underTest.hash(input), expectedDigest);
}

@DataProvider
private Object[][] hashSamples() {
    return new Object[][]{
        buildSample("input1", MY_KEY},
        buildSample("loooooonginpuuuuuuuuuut", MY_KEY},
        buildSample("input+with-symbol$", MY_KEY}, 
        buildSample("", MY_KEY}, 
        buildSample(null, MY_KEY}};
}

public Object[] buildSample(String input, String key) {
    String digest = ... //Does this code look familiar?!?
    return new Object[]{input, digest};
}

现在你基本上是在测试中复制了生产代码,这就违背了测试本身的目的。

这是一个相当极端的例子,但这种情况在较小的范围内也会发生,除非你注意到这一点。

不要为了测试目的而改变生产代码

在通常的实践中,开发代码的人有时候也写测试。这就导致了一个错误的假设,即这两者应该是一起成型的。

虽然写测试的困难确实经常反映了代码设计的问题,但相反的情况并不总是如此。

考虑一下这段代码:

public class BlackBox {
    public doHouseCleaning() {
        sweepTheFloor()
        // ... some more code
        doTheLaundry()
        // ... some more code
        doTheDishes()
        // ... some more code
    }

    private sweepTheFloor() {
        // ... a long method
    }

    private doTheLaundry() {
        // ... a very long method
    } 

    private doTheDishes() {
        // ... an extremely long method
    } 
}

问题:通过 doHouseCleaning() 测试 BlackBox 的所有细微差别是非常困难的。
解决方案:把所有的私有方法变成包私有,这样你就可以独立地测试较小的部分了!

...等等,什么?这听起来不对,不是吗?最有可能的是,你应该把责任分散到多个类中。例如:

public class HouseCleaner {
    public doHouseCleaning() {
        broom.sweepTheFloor()
        // ... some more code
        washingMachine.doTheLaundry()
        // ... some more code
        dishWasher.doTheDishes()
        // ... some more code
    }
}

public class Broom {
    public sweepTheFloor() {
        // ...
    }
}

public class WashingMachine {
    public doTheLaundry() {
        // ...
    }
}

public class DishWasher {
    public doTheDishes() {
        // ...
    }
}

这只是一个 “修复” 生产代码的例子,以使单元测试开发更容易。

所以,当你在编写测试时遇到挑战,试着找出潜在的问题,并修复它,而不是在考虑测试代码的情况下改变生产代码。

共收到 1 条回复 时间 点赞
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册