1 此事已有定论

Robert C.Martin在他的程序员的职业素养一书中明确提出:

关于TDD,也就是测试驱动开发 此事已有定论,无须争议

笔者对此深以为然,但这并不是信口雌黃的结论,也不是因为谁说了就认定他是对的,这是基于笔者自己在TDD上的一些实践的经验得出来的结论。而且笔者关于TDD的一些细节,可能也与Robert C.Martin的看法并不一致,这一点后续笔者会再在专门阐述TDD的文章中再来说明。但整体上笔者对TDD是深信不疑的。

2 我与TDD

这几年,我在工作上的重心其实并不在于后端开发,而更多的是在移动端与基于TypeScript与React的前端及桌面端的一些开发上面。

很可惜的是,我刚开始做Android时,属于初次入门做移动端,还没有这种要实施TDD的心态,而后又负责iOS,但是接手一个现成的代码,并不是从头开始,所以也压根没有想过实施TDD。

而2020我在做基于TypeScript与React桌面端的开发时,虽然成功把一个领域驱动思想的风格应用到这个项目中,但没有实施TDD,虽然知道前端有jest这个测试框架,但考虑到时间及因为第一次尝试使用前端技术栈,对技术的掌握的成熟度等因素,也并未将TDD实施到这上面。

但有幸的是,过去两年,分别在19年公司的一个项目及20年自己的一个业余项目中尝试完整的应用了TDD的做法,所以也基于此得出了一些心得。也坚定了自己对TDD的信念。

2.1 TDD实践项目经验

2.1.1 2019年的TDD实践

19年时,当时在公司曾经有一段时间负责过一个技术中台的项目,因为这个项目并不大,当时公司是让笔者一个人负责这个项目的后端开发。那个时候笔者刚刚从移动端开发中出来,有些时间没搞过后端开发了。所以在开发时,也考虑过该用什么样的技术及怎么来做。后面还是选择了Spring Boot来完成这个项目,因为毕竟Spring Boot的稳定性及可靠性都是可以信任的。

由于当时是笔者一个人负责,在技术上自由操作的范围较大,也不用考虑其它同事或团队的人的接受程度 ,因此第一次尝试完整的将TDD应用这个项目。这是一个好的开始。取得了不错的效果。

TDD测试驱动开发的实践心得_json

如图所示,笔者在19年项目中单元测试覆盖率约为78.8%

2.1.2 2020年的TDD实践

20年时,由于需要为自己的家人开发一个系统实际应用到公司业务上,所以对质量更加尤为关注,在19年的经验之上,再次将TDD连同领域驱动设计理念一并应用到这个项目。整体感觉还是非常好的。

而且这一次,自己对各方面的质量要求更高。

TDD测试驱动开发的实践心得_结对编程_02

2.2 实践TDD的一些心得

虽然项目不多,每年只搞了一个,但也已经对我的编程理念产生了重大的影响,至此为止,我已深信TDD的作用是非常有效,而且也是一个优秀的程序员必须也应该去做到的。

接下来说一些自己的心得

2.2.1 TDD是加快编码的唯一方式

其实做为程序员的我们遇到的一个最大问题,就是技术的一个最大矛盾点。

这个矛盾点就是:事是我们在做,但很多时候做决策的并不是我们。

相信大家都会或多或少的遇到一些场景,比如客户,领导,或项目经理,要不就是产品经理,我把这些人统称为技术门外汉,这群人并不了解技术,但又时时刻刻能替我们做决策。当然,关于产品形态或其它方面由他们来做决策无可厚非,但很多时候一个在于技术上需要多久这个他们认为他们懂但实质上并没有太多概念的也能为我们做决策。

> 这个功能,2周就可以完成了,也必须完成

虽然程序员感觉实质上2周不能完成,但通常这种时候,程序员大多说不上话。技术不可能比销售,对客户的承诺或其它任何可以说的上的理由重要。这是程序员职业生涯中始终要面对并且无法逃避的一个困境。

而大多在这种时候,很多程序员下意识的决策就是牺牲代码不可见的质量以加快代码可见的质量的进度。

也只有我们技术人员能理解,同样一个看起来功能运行起来差不多的两份代码,在不可见的质量方面能相差巨大,之所以很多项目到后面越来越难以推进的关键原因也就是不可见的质量上的不断牺牲以至于越来越难以维系所导致的。

而TDD是唯一可以解决和改善这个问题的方式,但可惜的是,我发现国内大部分程序员压根不来这一套,很多程序员自己都认同一个观点:

> 编写单元测试,会延长功能完成所需要的时间

虽然我认为这些程序员很可能压根没有实施过,是仅凭感觉这么说的。但在这种理念下,连程序员自己都这么认为,那更不可能让那群技术门外汉来认同这个理念,所以单元测试这个事压根从前到后无人在意。

但实际上,从笔者的实际经验来看,这是个压根不成立的结论。事实上,笔者发现,没有比编写单元测试更好的方式来加快代码的开发。而且笔者认为一个优秀的程序员只需要少数时间,就能适应并且快速熟悉单元测试的工作。

当然,这篇文章并不是详细阐述TDD的,所以这个点到此为止,笔者后续会就TDD再来专门阐述为什么TDD会加快代码开发。

2.2.2 保持单元测试足够小并且快

一个项目或产品,完整的测试包括很多维度,包括单元测试,集成测试,专业的黑白盒测试,性能压力测试等。那在这其中,单元测试的作用很明显,它是程序员自己验证自己代码的一种方式,它需要区分开来其它几种测试,要保持足够小而且快。

如果我们项目或产品比喻成建房子,那单元测试的作用就是保证每一块砖的质量,这就是单元测试的作用。用单元测试来保证每一块砖的质量,才有可能有后面的好的房子的可能性。

所以,单元重试的重点是关注你写的每一个逻辑的正确性。用代码来说就是保证你写的每一个方法逻辑上的正确性。如果代码中每一个方法的逻辑性都正确,才有可能有后面的把这些方法整合起来的质量保证可言,否则就如同房子建立在不可靠的砖上面,期望这种房子具有稳固性,简单是天方夜谈。

2.2.3 善用工具或技术框架

事实上,在编码的一些技术选型中,我通常会把基于这种技术的单元测试是否容易编写做为一个重要考量。

比如,在Java后端开发中,我通常会喜欢用JPA而不是Mybatis或其它JDBC等技术,虽然这些可能在性能上会稍有优势,但从可维护性,以及支持单元测试的方便性上来说,显然JPA更好。

我通常都会使用H2内存数据库做为单元测试的标准数据库,它的一个最大优点在于可以在任何环境,任何时间运行,而不需要一个类似MySQL的服务在那支持,而且我可以设定它每次执行一个单元测试数据库都是全新的这种场景来测试。这样可以尽量减少其它干扰的情况下来测试自己的方法逻辑上的正确性。

> 如果你认为这种测试不能反应实际情况,实际上很可能是有很多数据的,那我就再阐述一次,测试包含很多维度,单元测试并不关注你担心的这个维度上的事情。

还有一个重要的工具就是sonarqube,如同我上面两个图所示,这是一个很好的开源软件,

在敏捷软件开发的理念中,结对编程是一个很重要的点,当然这个点基本我认为在国内不太可能实施,这种模式会让领导觉得用2个技术人员做1个人的事,很难想像我们国内的决策者会认同这种搞法。

所以,我认为国内有两种可替代的方案:

  1. 使用代码审查来替代结对编程
  2. 使用Sonar这种自动化的工具

第一种在国内的很多环境下也不太好使。所以我基本只考虑第2种,就是把自己的代码放到Sonar上去跑,让它来告诉我哪里写的不好,单元测试覆盖率是多少,哪些代码没有覆盖到等。虽然它的很多规则是死的,并不灵活,但至少也能在一定程度上检测自己的代码,特别是在单元测试上提醒自己是否做的足够。

所以,如果你要应用TDD,一定需要这样的工具。

2.2.4 学会使用Mock或桩

单元测试中还有一个非常重要的点,就是要学会Mock或桩,不同的语言上对这个的称呼并不一致,但大致意思就是模拟一个实现的概念。很多时候,我们的代码依赖一些第三方或我们在这个测试中不关心另一个维度的东西的实际运行情况,在单元测试的场景中,我们需要覆盖如下场景:

  • 假设一个第三方功能返回正常下,我们的代码逻辑如何

  • 又假设一个第三方功能返回错误的情况下,我们的代码逻辑如何

这种场景下,我们就需要Mock技术了。通常各种语言都会有类似的框架,你只需去找就可以了。在后端Java系中,最著名的也就是笔者用到的,就是Mockito了。

    void testDisableDocument(){
        String json = "{\"name\":\"AAA.mp3\",\"mediaId\":\"AAA\"}";
        ResponseEntity<baseresponse<documentdto>&gt; responseEntity = restTemplate.exchange(baseUrl() + "/v1/documents",HttpMethod.POST,createHttpEntityFromString(json),new ParameterizedTypeReference&lt;&gt;() {});
        Assertions.assertTrue(responseEntity.getStatusCode().is2xxSuccessful());
        Assertions.assertTrue(Objects.requireNonNull(responseEntity.getBody()).getResult().getId() &gt; 0);

        //这是一个MOCK,假设当前用户是超级管理员用户,则可以禁用文档
        Mockito.when(applicationAuth.isSuper()).thenReturn(true);
        ResponseEntity<baseresponse> deleteResponseEntity = restTemplate.exchange(baseUrl() + "/v1/documents/" + responseEntity.getBody().getResult().getId(), HttpMethod.DELETE, createEmptyHttpEntity(), new ParameterizedTypeReference&lt;&gt;() {});
        Assertions.assertTrue(deleteResponseEntity.getStatusCode().is2xxSuccessful());
        Assertions.assertTrue(Objects.requireNonNull(deleteResponseEntity.getBody()).isResultSuccess());


        //这又是一个Mock,假设当前用户不是超级管理员,则应该不能彬文档
        Mockito.when(applicationAuth.isSuper()).thenReturn(false);
        deleteResponseEntity = restTemplate.exchange(baseUrl() + "/v1/documents/" + responseEntity.getBody().getResult().getId(), HttpMethod.DELETE, createEmptyHttpEntity(), new ParameterizedTypeReference&lt;&gt;() {});
        Assertions.assertTrue(deleteResponseEntity.getStatusCode().is2xxSuccessful());
        Assertions.assertFalse(Objects.requireNonNull(deleteResponseEntity.getBody()).isResultSuccess());
    }

如上述代码所示,灵活的使用Mock会让你的单元测试更纯粹,只关注当前测试代码的逻辑的正确性与否,屏蔽其它相关逻辑的影响。

它的另一个非常大的优势是使单元测试非常小及纯粹,如果没有类似的Mock框架支撑,运行这个单元测试,我需要一个完整的权限体系的代码跑起来支撑,这是一个非常麻烦的事,而且会让单元测试变得很重而且不可控。

2.2.5 单元测试需要考虑正常及异常路径

在早期一些时候,我写单元测试基本只写正常路径。什么叫正常路径?就是哗哗哗一路运行下去,结果正常。比如新增一个用户,最终新增成功。这就叫正常路径。

后面我意识到了这样的问题,这样的覆盖率其实非常少,所以我就开始尝试把不正常的路径添加上去。这样会得出更好的单元测试。

本代码摘自笔者的myddd-vertx框架,基于Vert.x与Kotlin的响应式领域驱动实现

    fun testRefreshToken(testContext: VertxTestContext){
        executeWithTryCatch(testContext){
            GlobalScope.launch {
                
                //不正常的路径,空用户肯定不允许刷新Token
                try {
                    oAuth2Auth.refresh(null).await()
                    testContext.failNow("空用户不能刷新TOKEN")
                }catch (e:Exception){
                    testContext.verify { Assertions.assertNotNull(e) }
                }
                
                //不正常的路径,用户不正确,不允许刷新Token
                try {
                    oAuth2Auth.refresh(OAuth2UserDTO()).await()
                    testContext.failNow("不正确的User不能刷新TOKEN")
                }catch (e:Exception){
                    testContext.verify { Assertions.assertNotNull(e) }
                }

                val createdClient = createClient().createClient().await()
                val user = oAuth2Auth.authenticate(JsonObject().put("clientId",createdClient.clientId)
                    .put("clientSecret",createdClient.clientSecret)).await()
                
                //正常路径,使用正确的用户可以刷新Token
                val token = oAuth2Auth.refresh(user).await()
                testContext.verify {
                    Assertions.assertNotNull(token)
                }
                
                //不正常的路径,refreshToken不对,不能刷新Token
                try {
                    val oauthUser = user as OAuth2UserDTO
                    oauthUser.tokenDTO?.refreshToken = UUID.randomUUID().toString()
                    oAuth2Auth.refresh(oauthUser).await()
                    testContext.failNow("不正确的refreshToken不能刷新Token")
                }catch (e:Exception){
                    testContext.verify { Assertions.assertNotNull(e) }
                }

                testContext.completeNow()
            }
        }
    }

如上代码所示,编写单元测试需要考虑不同条件。

3 让TDD驱动我的编码

得益于几个项目的实际经验,并且效果较好,所以我现在对TDD非常认同。

所以,2021年开始,在TDD方面,我给自己的约定是:

自己的项目不能少于80%的覆盖率,而如果是公司的,则根据实际自己能控制的程度来决定。

以下展现我正在完善中的myddd-vertx,基于Vert.x与Kotlin的响应式领域驱动实现的相关数据.

TDD测试驱动开发的实践心得_可维护性_03

笔者编写此文,也是希望国内有更多程序员能开始尝试去实践TDD,只有这样,才有可能越来越多的提升代码的质量及可维护性。

除此之外,绝无它法!!!