小程序中的大道理之四

在讨论领域模型之前,先继续说下关于测试方面的内容,前面为了集中讨论相应主题而对此作了推迟,下面先补上关于测试方面的。

测试覆盖(Coverage)

先回到之前的一些步骤上,假设我们现在写好了getPattern方法,而getLineContent还处于TODO状态,如下:

public String getPattern(int lineCount) {
        if (lineCount < 1) {
            throw new IllegalArgumentException("行数不能小于1!");
        }
        if (lineCount > 20) {
            throw new IllegalArgumentException("行数不能大于20!");
        }
        
        StringBuilder pattern = new StringBuilder();
        for (int lineNumber = 0; lineNumber < lineCount; lineNumber++) {
            pattern.append(getLineContent(lineCount, lineNumber));
        }
        return pattern.toString();
    }

    private String getLineContent(int lineCount, int lineNumber) {
        // TODO Auto-generated method stub
        return null;
    }

显然,getPattern已经是ok的了,那么我们也应该为它写上一些测试了。

有人可能会想,现在到底能测试什么?毕竟它所调用的getLineContent还没有实现呢,这里好像没有什么业务逻辑可测试的。

但这里其实还是有些逻辑可测试的,最明显的,前面的两个前提条件,它是否能如我们所愿拦住那些错误的参数呢?对第一个条件让我们来测试一下:

    @Test(expected = IllegalArgumentException.class)
    public void testGetPatternSmallerThan1() {
        Pattern p = new Pattern();
        p.getPattern(0);
    }

这里用了一个小于1的参数“0”去调用它,并期待它能抛出相应的异常。

如果还想验证它的异常信息,可以这样写:

    @Test
    public void testGetPatternBiggerThan20() {
        Pattern p = new Pattern();
        try {
            p.getPattern(21);

            // 如果没有抛出异常,测试失败
            fail();
        } catch (Exception e) {
            // 检查抛出异常的类型及信息
            assertThat(e instanceof IllegalArgumentException).isTrue();
            assertThat(e.getMessage()).isEqualTo("行数不能大于20!");
        }
    }

当然,异常信息很简单,就是从源码中拷贝过来而已。可以让它带上所输入的参数,这样提示更有意义,从而也让我们的测试更有意义,如下:

assertThat(e.getMessage()).isEqualTo("行数不能大于20!输入值:21");

那么现在测试自然是不通过了,可以再运行一次来确认。那么现在再修改一下源码,在抛出异常信息的地方改成:

    public String getPattern(int lineCount) {
        if (lineCount < 1) {
            throw new IllegalArgumentException("行数不能小于1!输入值:" + lineCount);
        }
        if (lineCount > 20) {
            throw new IllegalArgumentException("行数不能大于20!输入值:" + lineCount);
        }
        
		// ......
    }

保存,再运行测试,如果这次通过了,那么你基本可确认你已经实现了需求。

以上实践已经非常接近测试驱动开发(TDD:Test Driven Development)所倡导的方式:

  1. 根据需求先写一些测试,而所测试的方法还没有实现这些需求,因此这些测试还不能通过;

  2. 接着再写源码实现那些需求并让测试通过。

这就是所谓的测试驱动。

说完了异常方面的测试,还有什么可测试的呢?这里真的没有其它业务逻辑可测试了吗?

没错,getLineContent确实还是空的,但不要纠结于这里,比方说:输入一个3,你调用了4次getLineContent,这不就错了吗?(可能的原因是在for循环部分的边界判断上没有写好)

那么怎么确切地去证明你的代码里只会不多不少只调用了3次呢?可以借助Mockito中的行为测试来验证这些逻辑:

    @Test
    public void testGetPatternTimes() {
        Pattern pattern = Mockito.spy(new Pattern());
        pattern.getPattern(3);
        
        // 验证方法调用的次数,但不关心方法的参数
        Mockito.verify(pattern, Mockito.times(3)).getLineContent(Mockito.anyInt(), Mockito.anyInt());
    }

以上代码中,用Mockito来构建了一个pattern,并调用了getPattern方法,接着再断言getLineContent被调用了3次(Mockito.times(3))

至于用Mockito.spy而不是用Mockito.mock,原因是mock方式会让所有方法被覆盖,除非显式使用when…then来指定方法的行为;而spy

则会保留原有方法的行为,除非显式when…then来显式指定新的行为。现在我们想测试getPattern方法,所以用spy。

如果你对Mockito还不太熟悉,也没关系,你只要明白这里在验证方法调用的次数就够了。可以改变一下,比如改成times(4),再跑下就会发现以下错误提示:

image

另一方面,你可能已经注意代码中的Mockito.anyInt方法,你大概也能猜出这表示不考虑具体传递的参数是什么,但传递的参数其实也是很重要的逻辑。虽然在调用次数上正确了,但如果没有传递正确的参数,自然也不能算正确调用了方法。让我们来验证这一点:

    @Test
    public void testGetPatternParam() {
        Pattern pattern = Mockito.spy(new Pattern());
        pattern.getPattern(3);

        // 这里会验证方法调用的参数,但并不会验证方法调用的顺序
        Mockito.verify(pattern).getLineContent(3, 2);
        // 等价于Mockito.verify(pattern, Mockito.times(1)).getLineContent(3, 2);
        
        Mockito.verify(pattern).getLineContent(3, 0);
        Mockito.verify(pattern).getLineContent(3, 1);
    }

请注意,我们这里假定行号从0开始,这与之前的约定一致。因此三次调用的参数分别是(3,0),(3,1)和(3,2)。

如果你再用一个(3,3)去验证呢?显然,代码中不会产生这样的调用,因此将报错:

image

&#160;

另外,你可能还注意到了,代码中先验证了(3, 2),Mockito.verify并不关心方法调用的顺序,它只关注方法是否按照给定的参数被调用。但方法调用的顺序自然也是逻辑正确与否的一个重要方面,怎么去确保这一点呢?

因为getPattern方法有返回值,我们正好可利用这一点:

    @Test
    public void testGetPatternOrder() {
        Pattern pattern = Mockito.spy(new Pattern());
        
        // getLineContent尚未实现,我们先模拟它的行为
        Mockito.when(pattern.getLineContent(3, 1)).thenReturn("world");
        Mockito.when(pattern.getLineContent(3, 2)).thenReturn("!");
        Mockito.when(pattern.getLineContent(3, 0)).thenReturn("hello ");
        
        // 因为方法有返回值,且由所调用方法的返回值顺序组装而成,因此可以间接利用来验证调用的顺序
        String content = pattern.getPattern(3);

        assertThat(content).isEqualTo("hello world!");
    }

这里体现了用Mockito.spy的好处,一方面我们保留了getPattern方法的行为,因为这是我们想测试的;另一方面我们又可以去指定其它方法的行为,比如getLineContent的行为。需要注意的是,指定getLineContent的行为必须在调用getPattern方法之前。

在上面的测试中,我们用了一些比较随意的内容,你当然可以模拟得更加正式一些,如下:

    @Test
    public void testGetPattern() {
        Pattern pattern = Mockito.spy(new Pattern());
        
        // 可以模拟得很像,但通常是没必要的。因为在验证时的result也是由你来给出的。
        // 对getPattern方法而言,getLineContent究竟返回什么并不重要
        // 重要的getPattern是否以正确的顺序,正确的参数去调用了getLineContent
        Mockito.when(pattern.getLineContent(3, 0)).thenReturn("  *" + System.lineSeparator());
        Mockito.when(pattern.getLineContent(3, 1)).thenReturn(" ***" + System.lineSeparator());
        Mockito.when(pattern.getLineContent(3, 2)).thenReturn("*****" + System.lineSeparator());
        
        String content = pattern.getPattern(3);
        
        String result = "  *" + System.lineSeparator() 
                      + " ***" + System.lineSeparator() 
                      + "*****" + System.lineSeparator();
        
        assertThat(content).isEqualTo(result);
    }

但正如注释中所说的那样,在这里所进行的测试,关注的其实是getPattern的逻辑。在这一层面上,我们假定getLineContent能正常工作,然后考察依赖于它的getPattern方法的行为是否正确,比方说是否以正确的参数进行了调用,是否正确处理了返回的结果等等,这些显然都是getPattern方法的职责。

如果我们通过Mock方式已经测试到了getPattern的方方面面,理论上而言,只要getLineContent正确了,最终结果也会是正确的。更重要的是,当我们断言getPattern能正常工作时,我们并不依赖于getLineContent的任何具体实现,正如最开始时那样,getLineContent甚至可以是尚未实现的。

关注点的分离(SoC:Separation of Concerts)

我们说前面的测试关注的是getPattern的逻辑,但首先,getPattern必须专注于自己的逻辑。在代码中,我们正是这么做的,我们没有让getPattern方法大包大揽,而是把生成每一行具体内容这一关注点分离到了getLineContent中,从而让getPatternt专注于集成getLineContent返回的内容上。

SoC是一种重要的设计原则,你或许更常在AOP(Aspect-Oriented Programming,面向切面编程)的实践中听到所谓的横切关注点(cross-cutting concerns),也即所谓的切面了。自然,AOP也实践了SoC这一原则,但SoC本身是一个更宽泛的原则,你当然可以怀疑套用在这里是否有点牵强,但我认为不必过于狭隘地去理解它。

单一职责原则(SRP:Single Responsibility Principle)

可以看到,getPattern方法并没有过多的职责,生成每一行具体内容的职责被委托到了getLineContent上。正如我们前面用一个“hello world!”形式去验证那样,具体返回什么那已经是getLineContent的职责了,getPattern做好自己的事情就行了,它不受其它变化的影响。

SRP同样也是一种重要的设计原则,你更常听到的可能是一个类或一个模块应该具有单一的职责。在这里我们说的是方法,你当然可以继续怀疑套用在这里是否有点牵强,但我还是那句话,不必过于狭隘地去理解它。重要的是领会这些思想的精神实质,你或许还能隐约感受它与SoC有点关系。

Robert C. Martin把“职责”定义成“更改的原因”(reason to change), 认为一个类或一个模块应该有且只有一个更改的理由(a class or module should have one, and only one, reason to change.)

实际上,Mockito不赞成使用spy方法,它认为,如果你要用spy,你的设计可能存在一些问题。事实上,如果增加一个叫Line的类,并把getLineContent移到它的里面(或许名字还可改成更短的getContent),让Pattern类依赖于这一Line类,那么就可以用Mockito.mock来构造Line的实例去测试Pattern类,正像前面测试PatternFile与Pattern时那样,Pattern类也能因此变得更加简单。

当然,由于这是一个很小的例子,你可以怀疑是否值得这么去做。但在现实中,如果你发现一个类正在不断膨胀,你或许应该停下来好好想想它是否承担了过多的职责,也许你已经到了一个值得拆分它的时间点。

你可能感兴趣的:(mockito,SOC,测试覆盖,SRP,行为测试)