微服务模式下的TDD实践指南

1 概述

1.1 单元测试和TDD
  • 单元测试: 单元测试的重点是测试被测类或被测方法的的逻辑行为。其中,逻辑行为包含判断、循环、选择、计算及业务执行过程等。
  • TDD: 测试驱动开发的重点是通过编写测试代码来驱动业务代码的编写,以用户和业务视角来编写软件。编写代码的过程中最重要的出发点是:作为程序的使用方会通过什么样的方式来使用程序。例如:作为接口或组件的提供方,优先考虑的是接口或组件的调用方会通过什么样的方式来调用程序。先明确接口或组件的调用方式、输入及期望的输出结果,进一步再去完善业务逻辑,从而完成被开发的业务功能。
1.2 实施TDD的目的
  • 实现代码整洁: 通过测试驱动开发,减少了很多冗余逻辑,使得所写的业务逻辑都是测试方法中的预期行为。测试用例完善的话,最终的业务代码就是满足了所有测试用例的最小化集合,从而实现代码的整洁。
  • 实现自动化测试: 测试代码成为项目资产,可以被重复使用,提升回归测试的效率,为应用程序提供了一层保护网。对团队而言,测试的行为不再分散在每个开发人员的Postman中,而是组成了整个系统的测试集合,减少了开发及测试人员复测的时间,从而提升测试效率,加快上线进程。
1.3 微服务中的测试
  • 接口测试: 接口测试主要关注点是软件的外部行为,即对外提供的API。接口测试的最大优点在于,编写的测试代码不依赖微服务的内部实现,只是通过服务调用者的视角来编写测试代码。当微服务内部的结构进行了调整,如果需求没发生变化,则不应该影响接口的行为。在微服务模式下,对接口进行测试要求达到全覆盖,对每个对外提供的API都必须进行测试;对每个接口中的测试场景,要求覆盖核心场景,对于校验、组合等场景尽可能的覆盖。
  • 单元测试: 在接口测试已经覆盖的场景下,不要求再对内部代码块做单元测试。在微服务中可考虑从service层入手来编写测试代码。对于组件和模块,适宜通过单元测试进行验证,如:算法类、工具类、数据解析等。可根据项目实际情况决定是否需要增加单元测试。

2 开发过程

2.1 基本过程:

在这里插入图片描述

2.2 过程说明
  • 用户故事: 用户故事是开发人员的工作输入。任务开始前,开发人员需要与BA、测试人员一起梳理用户故事卡,明确用户故事中的需求描述,并对该用户故事卡中的验收点达成一致理解。即所谓的“开卡过程”。
    用户故事示例:

    用户注册
    作 为: 某某系统的用户
    我希望: 能够注册成为该平台的用户
    以便于: 能够使用某某平台的功能
    
    AC-1:注册时提供的用户信息为:
        手机号:字符 20位 必填
        登录密码:字符 8-16位 必填
        真实姓名:字符 20位 必填
        身份证号:字符 18位 必填
    AC-2:
        2.1 如果该用户已注册,提示:"当前用户已注册"
        2.2 如果用户实名认证信息不通过,提示:“实名认证失败”
    AC-3:用户注册成功后,通过短信提示用户成为该平台的注册用户
    
  • 测试用例分析: 拿到用户故事卡以后,开发人员应该以用户视角来分析问题,对后端开发人员来说,既要分析业务,也要考虑前端开发人员会怎样使用自己的接口。如果对用户故事中的理解有疑问或问题,需要与BA进行沟通和反馈,并最终形成一致的理解。以接口测试为例,可参考以下方面进行分析:
    在这里插入图片描述

  • 任务拆分: 通过对测试用例进行分析,可以梳理出任务列表。每一个任务都描述了对应的一种测试场景。对于任务的描述,可以采用“Given…When…Then…”的方式。Given对应了测试场景中的前置条件,When对应被测试的方法,Then对应方法执行的结果。对于较简单的用户故事,可直接将分析后的测试场景转换成测试类中的测试方法。

    Given 正确的注册信息 When 注册 Then 注册成功

  • 测试代码编写: 任务拆分完成后,可将每个任务转换为一个测试方法。把任务描述中的Given…When…Then… 转换成以should…when…given… 形式命名的测试方法。一次只编写一个测试方法,当前测试方法编译通过以后,再编写对应的业务代码。

    @Test
    void should_return_user_when_register_given_correct_info() {
        given().contentType("application/json")
                .body(body()
                        .put("phoneNo", "13800138000")
                        .put("password", "password")
                        .put("realName", "张建国")
                        .put("idType", 1)
                        .put("idCode", "11111111111111111")
                        .build())
                .when().post("/api/user/register")
                .then().log().body()
                .body("id", notNullValue())
                .statusCode(200);
    }
    
  • 业务代码编写: 在有测试保护的情况下,可以在编写业务代码的过程中,随时运行测试。只编写能够让测试通过的代码即可,业务代码尽可能简单,不要编写多余的业务逻辑。业务代码完成且通过测试,即可开始另一个任务。

  • 通过所有测试: 当所有的任务都开发完成,并且通过了所有的测试,应该就满足了用户故事要求的功能要求。

  • 重构: 业务功能完成后,需要重新审查一下代码。一方面要满足编码规范要求,另一方面要审查是否有代码的坏味道,并运用重构手法对代码进行重构。代码坏味道主要包含以下方面:

    味道解释重构方法
    Duplicated Code重复代码提取公共方法
    Long Method过长方法拆分成若干方法
    Large Class过大的类拆分成若干类
    Long Parameter List过长参数列表将参数封装结构或类
    Divergent Change发散式变化改动很多需求时都会改动到它,可将一起变化的内容拆分
    Shotgun Surgery霰弹式修改改某个需求的时候,要改非常多类,可将各个改动点,抽象成一个新类
    Feature Envy特性依恋过多使用其它类的成员,可将这个函数挪到那个类里面
    Data Clumps数据泥团把数据封装成一个新类
    Primitive Obsession基本类型偏执一系列基本类型參数,可换成类
    Switch Statementsswitch语句可修改为state/strategy模式
    Parallel Inheritance Hierarchies平行继承体系可去掉一个类的继承关系
    Lazy Class冗余类不再重要的类,可合并到相关类,删掉旧的
    Speculative Generality夸夸其谈未来性删除
    Temporary Field临时字段在特定环境下使用的变量,可将这些暂时变量集中到一个新类中管理
    Message Chains消息链过度耦合,可以拆函数或者移动函数
    Middle Man中间人过多的使用了代理来处理,用继承替代托付
    Inappropriate Intimacy过度亲密划清职责,拆分或合并,或改成单项依赖
    Alternative Classes with Different Interfaces接口不同的等效类重命名,移动函数,或抽象子类
    Incomplete Library Class不完整的类库包一层函数或包成新的类
    Data Class数据类将相关操作封装进去,降低public成员变量
    Refused Bequest被拒绝的馈赠子类仅使用有限的父类,用代理替代继承关系
    Comments注释过多通过重构使代码逻辑清晰
2.3 TDD实践

TDD开发人员,可遵循上述开发过程,每次只关注一个事情,经常切换三顶帽子(红帽子、绿帽子、蓝帽子),TDD基本过程如下:

  • 红:(编写测试)测试代码编译通过即可,此时并没有真正的业务逻辑可以测试,只要求测试代码能运行,此时测试结果为失败。
  • 绿:(编写实现)编写能满足需求的最简单代码,并使测试代码能够运行通过。
  • 蓝:(重构)在所有测试都能通过的情况下,可对代码进行重构。重构可以在任何必要和适当的时机进行,但是不能同时在编写测试代码或业务代码时进行重构,一次只能关注一件事情。重构过程中随时可以运行测试,重构完成时,要保证所有的测试都运行通过。
    在这里插入图片描述

3.常用测试组件

  • Rest Assured: Rest Assured是基于REST服务的Java测试组件。在实际开发过程中,可替代Postman进行接口测试,并将测试场景代码化。
    主要使用方法如下:
    1)pom.xml文件中增加如下依赖:

    	<dependency>
    		<groupId>io.rest-assured</groupId>
    		<artifactId>rest-assured</artifactId>
    		<version>3.0.5</version>
    		<scope>test</scope>
    	</dependency>
    

    2)设定测试基类,其他测试类可以继承基类而无需为每个测试设定@SpringBootTest注解。

    @SpringBootTest(webEnvironment = RANDOM_PORT, classes = Application.class)
    @ExtendWith(SpringExtension.class)
    public abstract class BaseTest {
    
        @LocalServerPort
        protected int port;
    
        @BeforeEach
        public void Before() throws SQLException {
            RestAssured.port = port;
            RestAssured.basePath = "/";
        }
    }
    

    3)编写测试类及方法

    public class UserTest extends BaseTest {
        @Test
        void should_return_user_when_register_given_correct_info() {
     		......
        }
    }
    

    详细使用文档可参考:
    英文文档:https://github.com/rest-assured/rest-assured/wiki/Usage
    中文文档:https://testerhome.com/topics/7060

  • Mockito: Mockito是一种测试替身技术,是用Java编写的模拟对象框架。在测试过程中,它可以帮助我们解决测试过程中的依赖问题。在基于Springboot的测试类中,可直接使用@MockBean注解来生成一个Mock对象。
    示例场景: RequestFilter中依赖了一个外部接口服务DemoService,在没有外部系统的情况下,如果调用DemoService的forward()方法是无法正常运行的。以下代码可以模拟DemoService的行为,并模拟出方法的返回对象。
    代码说明: 使用setField方法将Mock对象注入到requestFilter中,并使用Mockito的when()方法改变forard()的返回结果。这样,在测试代码运行过程中调用demoService.forward()方法时,代码会以我们期望的方式正常运行。

        @Autowired
        RequestFilter requestFilter;
    
        @MockBean
        DemoService demoService;
    
        @Before
        public void setUp()  {
            setField(requestFilter, "demoService", this.demoService);
    
            DemoDto demoDto = new DemoDto();
            demoDto.setData("** Data");
            when(demoService.forward(any(), any())).thenReturn(demoDto);
        }
    

4.如何加快开发速度

对于TDD的初学者,很多人会觉得写测试会占用较多开发时间。一方面是因为对测试组件不熟悉,使用测试组件会遇到一些障碍。另一方面是因为对编码技巧不够熟练,如:重构手法、快捷方式等。

  • 熟悉测试技术: 对于接口测试技术(RestAssured),可通过上文中的链接文档进行学习;对于单元测试技术,可通过相关书籍进行系统的学习,如《有效的单元测试》《单元测试的艺术》等。
  • 编码技巧: 编码技巧的提升,需要持续、刻意的练习,特别是对IDEA快捷键的使用,日常工作中掌握了以下快捷键,可大幅提高编码效率。对于其他常用的快捷键,可通过Intellij中的 tip of the day 强化记忆,每次打开Intellij时,刻意的记一下。
    项目使用方式Mac快捷键Windows快捷键
    快速创建一个新类定位到项目结构的目录上时,按快捷键⌘+NCtrl+Alt+Insert
    快速创建一个测试方法光标在类主体范围内时,按快捷键,然后选择 Test Method,按回车⌘+NAlt+Insert
    对已有测试类创建测试方法光标在被测试类名所在的行,按快捷键,选择Create Test,按回车⌥+⏎Alt+Enter
    运行当前测试类所有测试方法光标在测试类所在行,按快捷键,按回车⌥+⏎Alt+Enter
    运行当前测试方法光标在测试方法所在行,按快捷键,按回车⌥+⏎Alt+Enter
    快速运行上一个测试类⌃+RShift+F10
    快速运行上一个测试类(调试模式)⌃+DShift+F9

5.测试代码的重构

为了使测试代码能够加简洁、清晰,我们有时需要对测试代码也进行重构,如:在进行接口测试时,对于同一个接口会有多种不同的测试场景,这样就会在不同的测试方法中重复的执行given()…when().post(…)的代码,这些测试方法的主要差别在于它们构造的传入参数不同。对于这种情况,我们可以将测试场景进行重构。让测试方法关注输入参数和验证结果,让测试场景类负责不同的接口请求,从而最大程度的降低了代码重复。示例代码如下:

    //重构前
    @Test
    void should_return_user_when_register_given_correct_info() {
        given().contentType("application/json")
                .body(body()
                        .put("phoneNo", "13800138000")
                        .put("password", "password")
                        .put("realName", "张建国")
                        .put("idType", 1)
                        .put("idCode", "11111111111111111")
                        .build())
                .when().post("/api/user/register")
                .then().log().body()
                .body("id", notNullValue())
                .body("password", equalTo("password"))
                .statusCode(200);

    }
    //其他测试方法中,会重复以上代码 given()...when().post()......
    ....
    
	//重构后
    @Test
    void should_return_user_when_register_given_correct_info() {
        UserScenario
                .builder()
                .phoneNo("13800138000")
                .password("password")
                .realName("张建国")
                .idType("1")
                .idCode("1111111111111111111")
                .build()
                .register(assertStatus(200));

    }

其他被重构的代码如下:

@Builder
public class UserScenario {
    private String phoneNo;
    private String password;
    private String realName;
    private String idType;
    private String idCode;

    public void register(Consumer<Response> assertion) {
        Response response = given()
                .contentType("application/json")
                .body(body()
                        .put("phoneNo", phoneNo)
                        .put("password", password)
                        .put("realName", realName)
                        .put("idType", idType)
                        .put("idCode", idCode)
                .build())
        .when().post("/api/user/register");

        assertion.accept(response);
    }
}
public class Assertions {
    public static Consumer<Response> assertCode(String code) {
        return (response) -> response.then().log().body()
                .body("code", is(code));
    }

    public static Consumer<Response> assertStatus(int code) {
        return (response) -> response.then().log().body()
                .statusCode(code);
    }
    .......
}

6.代码示例

持续完善中…
https://github.com/WangShuai822/tdd-with-springboot

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值