测试驱动开发(TDD)的经验和注意事项

2021年3月8日16:23:00 发表评论 958 次浏览

本文概述

前言

前几篇文章中,我有详细讨论到测试驱动开发(TDD)以及相关的实例,如果你还不是很清楚,可以回头看看。我们知道,TDD主要就是测试和断言优先,但是有一个问题:应该如何设计外部接口?——就是你首先写测试用例的时候的调用接口,这似乎不是一个简单的问题。

因为我遇到这样的问题,当使用TDD开发的时候,似乎可以任意new对象,任意调用方法,似乎你怎么写都是可以的——这很明显是不行的,这只是好写代码,并不能说明这是一个好设计。这样纯粹的TDD,我觉得是越写越糟糕,但是它是一个好写代码的方法。

总的来说,遇到不少问题,因此我得有必要回头好好再研究TDD,看看如何设计接口更好,分析一下存在的问题。

语义化的接口:流行的方式

语义化的意思是,设计类名、方法和使用方法都是语义化的,你一眼就能看出来这是在干什么。

例如一个文档转换器的主要API可以这样设计:

@Test
void testConverter() {
    Converter converter = new Converter();
    boolean isOK = converter.sourceDocument(html)
             .sourceType(Type.HTML)
             .destDocument(pdf)
             .destType(Type.PDF)
             .convert()
             .save(filePath);
    assertTrue(isOK);
}

这是一个好的设计——实际上有的开源转换器项目,其接口就是这样设计的。或者你有没有用过OKHttp?它的接口形式也和这个类似。

我查了下,这种方式在设计模式里属于建造者设计模式,例如OKHttp,图片处理框架Glide,网络请求框架Retrofit等都使用了此模式。

这样设计有什么问题?问题是你要会例如建造者设计模式、或者要求你要有足够多的经验以致你懂得这样写,你得知道它的一般实现方式:就是这个模式的相关类的构造,但是这样可能会忘记了:需求、数据结构和算法。

问题:第一,并不是你会设计模式,你就用得了,因为那种标准的设计模式例子并不特别常见,用起来很多都是变体;第二,会或不会设计模式都好,这会让你在编码上陷入困境:这究竟是在符合设计模式而编码,还是在设计?我到底有没有在实现需求?我似乎并没有在考虑数据结构和算法,还是我只是想把代码写得尽量好看?我似乎陷入了代码可以写得100%完美的困境——这绝对是错误的,没有100%的东西!

数据结构和算法

我在往下编码的时候就遇到了这个问题,因为往下我开始考虑数据结构和算法了,我个人是比较侧重数据结构和算法的,我觉得你可以不会其它的,但是不能不会这两个,它可以保证你实现得了需求,以及获得一个较好、更好的性能——即使代码写得很丑陋都好!

当侧重数据结构和算法的时候,似乎就和TDD不兼容了,因为前者的外部接口形式似乎并没有那么随便,而TDD随便你怎么写,能实现就行了。还是这只是我的问题:我认为首先确定一种数据结构,然后有数据域、结点以及算法操作所在类,数据域就是普通Java对象,结点的设计决定了这个数据结构是怎么样的,算法是处理这个数据结构的。

例如上面的例子中:实现一个文档转换器,这不就好了吗?还要什么数据结构和算法,主要还不是用第三方库吗?——不是呀,我想把所有文档内容使用一个通用的数据结构储存,而不只是转换文档,那只是你自己没考虑到罢了!——不明确需求,不知道自己在实现什么,那你还写什么代码呢?

结构化你的数据

一个简单的包含数据属性、getter和setter方法的Java对象,这个通常是用来封装数据的,还不能组成数据结构——这个称为数据域。

数据域封装你要处理的数据的基本形式,它也是结构化。但是你要清楚你要处理的是什么数据,如果是文档处理器,那么其数据域就是文档的内容——将内容用一个简单对象表示,或者将内容表示成单个简单对象的组合——例如文档结构树,树上每一个结点表示内容的每一项。

体现出数据结构的设计,通常需要设计一个结点,结点包含数据域和结点之间的关系。这依赖于你选择何种数据结构来储存你的数据——但是首先你要从需求文档中分析出项目需要的所有数据。

首先准备好需求文档

上面说到的很多问题,产生原因都有因为需求不明确,不明确就是为了写代码而写代码了,这压根不知道在写什么。

所以,无论你是开发已有的公司项目、还是开发新建的项目或者自己开发,千万记得需求文档很重要。例如自己开发的话,你可以任意写:你觉得这个项目需要达到什么样的效果,就写出来,简单明了说明就行。我最近想用TDD写项目就忘记需求文档了,写的一团糟,不知道自己在写什么。

类的设计

即使使用TDD,数据结构和算法还是不能省略掉的,以后优化代码也是从数据结构和算法上入手的。但是TDD似乎并不能驱动开发人员注意到这方面的设计——这就要自己时刻注意了。

如果我们对class来分类,类主要有:具体的算法操作、管理其它类的算法操作(或中介)、数据域类、结点类。在结合数据结构和算法设计的一般形式里,基本包括:数据域、结点(包含数据域)和算法操作,这个结点的存在一般是由于我们处理的数据非常多而需要结点组合起来,但是也有可能处理单一的数据,这时候可以不需要结点——所以你要分析需求文档,找出你要处理的数据,看看他们是不是能够单一表示并处理,还是需要多个结点组合起来表示。

如果是像上面的代码那样,那就是语义化的,例如这个Converter类,它究竟是具体的算法封装还是作为中介封装了别人的算法?

函数或方法的设计

除了一些简单的中介类,项目最主要的是那些封装具体逻辑算法的类,数据结构和算法是跑不掉的,没有适当用到,项目效果可能不大理想。

一个封装算法的类,其操作就是增删改查(CRUD),其它框架也是一样的,只是它可能改了一些语义化的名称了;另外就是增删改查的复合体,你应该也使用过,但是别人的时候也使用了语义化的名称了。怎么说,这些语义化设计有时会让我忘记了设计本身,所以不用语义化的名称其实问题也不大。

使用数据结构和算法——例如JDK中的数据结构——其标准形式设计的接口,其实也是符合TDD的。如果你还不熟练TDD,可以先尝试不要那么随便地设计接口,参考数据结构操作的形式——这样的接口其实也很标准不是吗?

改进你的设计:TDD+数据结构和算法

本人推荐使用TDD的形式开发,并且在开发过程中结合数据结构和算法的思想进行。

这是什么意思呢?我们不是写任何代码之前都要写测试用例吗?那好,不要使用语义化的方式,使用CRUD和复合CRUD的方式(如果你熟练了才推荐写得语义化一些)。

例如上面说到的文档处理器,第一步在需求文档上写清楚你要实现的需求,你要什么,就写出来吧。然后根据需求分析所需的所有数据和操作,选择一个适当的数据结构处理你的数据。

一个项目可能不止处理一类数据,将数据分类是比较好的方式,一类数据可以对应一个数据结构一个算法操作,

例如,如果这个文档处理器需要设计成桌面客户端,那么这时可能有一个用户注册的需求,当然,这不算难。但是我想说的是,你可能依赖一些更为抽象的数据的时候,也是可以这样设计的。

这样一来,这个文档处理器可以这样设计:

@Test
void test() {
    // 文档的简单CRUD
    DocumentTree html = new DocumentTree(Type.HTML);
    html.add(4, new Paragraph("p", "hello world"));
    html.delete(4);
    html.update(8, new Paragraph("div", "secions"));
    html.get(8);

    // 文档的复合处理
    DocumentTree pdf = DocumentTree.from("/sample.pdf", Type.PDF);
    // 1. 实现了文档转换的功能, progressListener用于监听处理进度
    pdf.to("/sample.html", Type.HTML, progressListener);

    // 2. 将文档转换为任意一种格式的文档
    pdf.to("/sample.html", Type.PUBLIC);

    // 3. 遍历整个文档
    pdf.traverse((Node node) -> {
        logger.log(node.parent());
        logger.log(node.childrenSize());
        logger.log(node.paragraph().name());
        logger.log(node.paragraph().text());
    });
}

你可以很清楚看到,DocumentTree就代表了Tree数据结构,并且里面包含了一些操作算法,这些算法主要分为简单的CRUD操作、以及复合的CRUD操作。

你要是想也可以将DocumentTree改为Document,这也更为简单,或者将相关函数语义化,但要记得它本质的操作(语义化可能会让你忘记本质的操作是什么)。

不就是名称吗?不!这很重要,这对于使用者来说非常重要,使用者压根不管你详细实现是如何的,只管接口上的简单使用,如果你设计的接口非常复杂,使用者会一脸懵逼。所以为什么一直说语义化,因为语义化让使用者用起来更方便。偏向数据结构和算法的接口,没有很语义化,算是刚刚好,因为我们都有使用数据结构的习惯,所以这样设计也算清晰明了。

接着就是写断言进行相关测试了,所以我推荐你先设计好接口,再使用断言测试,这样简单明了,而不是写一句代码就写一个断言,否则这让你有可能不知道自己在测试什么鬼。

往下设计如何遵守TDD原则

像上面那样,先设计好标准的接口,接着写断言测试,然后开始往下实现或重构代码。往下重构代码的时候,有两种情况:

  • 第一种是你写的代码超级高内聚,主要依赖JDK或第三方,而不需要新建类或接口,这样你直接写就行了,不需要再写测试。
  • 第二种是你写的代码处理依赖JDK或第三方,还要依赖自己新建的类或接口实现,这样你就需要新建一个测试类来测试这些新建的东西。

新建的类,最复杂无非也是一个算法封装类、需要结点或数据域,复杂的就是有多个结点需要处理,简单的可能就只需要处理一个数据,或者压根就只是一个工具类(但是即使是工具类你也要写测试)。并且很多时候,这个数据结构可能你不需要重新实现的了,例如你将数据用表储存,那么你可以使用List,除非JDK中没有你需要的数据结构,你才需要自己重新实现。

谨记:需求文档和放弃语义化

虽然说了很多次,也是要提醒自己,但是还是要说:一定要先有需求文档,没有需求文档,这压根就不知道自己在写什么。

第二个是:TDD新手先放弃语义化,使用数据结构和算法的方式进行设计,这样让你使用了TDD的开发流程,又注重了设计。

总结

本文的一些关于测试驱动开发(TDD)的经验,除了分享给大家,也是为了理清我自己的开发思维,目前我得出一个开发模式:使用数据结构和算法的设计思维、采取TDD的开发流程。

本文的重点有:

  • 确定你的需求文档,任何你想要实现的东西,简单明了写清楚。
  • 首先设计API接口,再来写断言。
  • 设计API接口,不要使用语义化的思维,请使用数据结构和算法的思维。
  • 一样的TDD开发流程,创建任何属于自己项目的代码,先写测试。
  • 程序主要就是处理数据,你处理的数据要不没有、要不只有一个、要不需要用一个数据结构进行组织。
  • 项目中的类主要有两种:封装真实算法、管理其它算法类的类、数据域、结点。

这是我对近期开发项目的总结,下次再看看有什么问题,再写文章仔细分析一下。

木子山

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: