Day925.如何提升遗留系统代码的可测试性 -系统重构实战

如何提升遗留系统代码的可测试性

Hi,我是阿昌,今天学习记录的是关于如何提升遗留系统代码的可测试性的内容。

自动化测试不仅可以提高效率,还可以提高软件的质量。但是,当面临一个没有任何自动化测试的遗留系统时,该如何落地自动化测试呢?

这里面有一个绕不开的问题,就是如何提高遗留系统代码的可测试性?这些场景应该不陌生。

  • 代码将所有的逻辑都堆砌在一个方法内部,很难模拟测试数据进行测试。
  • 系统直接依赖外部的服务,测试执行耗时长、不稳定。
  • 陷入“代码不可测就不写测试,然后不写测试又加剧代码不可测”的循环之中。
  • ……

这也是为什么我们说遗留系统可测试性低的原因。

对于这些场景,很难按照之前的方法直接覆盖中小型自动化测试,所以这个时候要先用一些特殊的招式来解决代码不可测的问题。


一、第一招:暴露接缝,“水到渠成”

《修改代码的艺术》一书中提到了“接缝”的概念。接缝是指在不修改代码的条件下,可以改变代码行为的地方。

那么这个接缝和代码可测性又有什么关系呢?通常,设计一个测试用例需要三个关键步骤。

  • 第一步,准备测试数据。
  • 第二步,触发被测试的方法或行为。
  • 第三步,断言程序执行的结果和用例设计预期是否一致。

可以看出,这其中的前置条件就是,要将准备好的测试数据设置到被测试的方法或行为中。

如果原来的软件中没有任何接缝可以让设置数据,或者设置这些数据的成本非常高,那么就说代码的可测性低,这个时候编写自动化测试的难度会很高。为登陆示例编写了不同范围的自动化测试,现在继续沿用这个示例。

不过会将代码调整为遗留系统最初的样子,但不会破坏原有的代码逻辑,代码是后面这样。


public class LoginActivity extends AppCompatActivity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);
        final EditText usernameEditText = findViewById(R.id.username);
        final EditText passwordEditText = findViewById(R.id.password);
        final Button loginButton = findViewById(R.id.login);
        loginButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                boolean isLoginSuccess = false;
                String username = usernameEditText.getText().toString();
                String password = passwordEditText.getText().toString();
                boolean isUserNameValid;
                if (username == null) {
                    isUserNameValid = false;
                } else {
                    Pattern pattern = Pattern.compile("\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*");
                    Matcher matcher = pattern.matcher(username);
                    if (username.contains("@")) {
                        isUserNameValid = matcher.matches();
                    } else {
                        isUserNameValid = !username.trim().isEmpty();
                    }
                }
                if (!isUserNameValid || !(password != null && password.trim().length() > 5)) {
                    isLoginSuccess = false;
                } else {
                    //通过服务器判断账户及密码的有效性
                    if (username.equals("123@163.com") && password.equals("123456")) {
                        //登录成功后保存用户信息到本地
                        SharedPreferencesUtils.put(LoginActivity.this, username, password);
                        isLoginSuccess = true;
                    }
                }
                if (isLoginSuccess) {
                    //登录成功跳转主界面
                    startActivity(new Intent(LoginActivity.this, MainActivity.class));
                } else {
                    //登录失败进行提示
                    Toast.makeText(LoginActivity.this, "login failed", Toast.LENGTH_LONG).show();
                }
            }
        });
    }
}

你看,这个代码将所有的逻辑都堆砌在一个方法内部了,覆盖小型测试的账户密码校验逻辑也被淹没在了这个大方法中。

这段代码的接缝是什么呢?有哪些地方可以在不修改代码的条件下,改变代码行为呢?

答案是可以通过模拟 UI 上的操作,输入不同的账户密码来验证代码的不同行为。

但因为 UI 的操作需要依赖设备并且执行时间也很长,所以我们认为此时的测试成本是比较高的,代码的可测性也比较差。那怎么解决这个问题呢?

很简单,就是通过暴露更多的接缝,提高代码的可测性,让编写测试的成本更低。


再看看关于账户密码的校验逻辑代码,这段代码的接缝又是什么呢?

 private boolean isUserNameValid(String username) {
        if (username == null) {
            return false;
        }
        if (username.contains("@")) {
            return Patterns.EMAIL_ADDRESS.matcher(username).matches();
        } else {
            return !username.trim().isEmpty();
        }
  }
  private boolean isPasswordValid(String password) {
        return password != null && password.trim().length() > 5;
  }

可以看到,这些逻辑都被抽取到了独立的类和方法中,并且提供了参数类型的接缝,让可以设置不同的测试数据来验证代码的行为。

除了上述例子中展示的情况外,还有一些开发中常见的暴露接缝的形式。

在这里插入图片描述

总之,通过暴露接缝,可以让测试代码更加方便地设置不同的测试数据来验证代码的行为,从而提高代码的可测试性。


二、第二招:测试替身,“以假乱真”

在实际开发中,经常需要访问远端的服务器获取数据、持久化数据,有时候还需要依赖第三方的服务。

这些行为的特点就是具有不稳定性时效性,例如,服务随时都可能不可用或者出现异常,这非常容易导致测试失败。

另外,对于一些动态的信息展示,由于数据的随机性,测试很难写具体的断言。所以,有时候需要权衡测试的保真度和维护成本。

如果测试依赖网络通信,就意味着它具有更高的保真度。但是测试可能需要更长的运行时间,一旦网络出现故障,还可能会导致错误。

遇到这种情况,除了可以选择重构解除具体的依赖外,还可以选择一种成本更低的方式,那就是使用测试替身

顾名思义,测试替身就是替换被测系统的依赖的等价实现,常见的测试替身方式有 6 种。

在这里插入图片描述

以登录为例,演示一下怎么使用测试替身。

假如现在登录走的是网络的请求,代码是后面这样。

interface LoginService {
  @GET("/login")
  Observable<User> login(String username,String password);
}
Retrofit retrofit = new Retrofit.Builder()
    // 服务可能挂掉,或者还没实现,或者网络延时、中断
    .baseUrl("https://xxx.com/")
    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
    .build();
    
LoginService myService = retrofit.create(LoginService.class)
myService.login()
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(user -> view.load(user));

这段代码的主要问题是依赖了远端的服务,运行具有不稳定性,例如服务可能挂掉、网络延时或者中断。

对此,有两种常用的测试替身方式可以提高测试的稳定性:

  • 一种是使用 Mock+Stub
  • 另一种是用 Fake 完整模拟一个远端的假服务。

1、Mock+Stub 应用示例

这种方式是用 Mockito 框架来 Mock 一个 LoginService 的假实现,然后进行 Stub。

当触发 login 方法时,返回预期的测试数据。

LoginService loginService = Mockito.mock(LoginService.class);
Mockito.when(loginService.login(anyString(),anyString())).thenReturn(Observable.from(new User()));

2、Fake 应用示例

还可以在本地 Fake 一个假的服务,当请求的是设置好的 url 时,就返回预先设置好的数据。

MockWebServer mockWebServer = new MockWebServer();
Retrofit retrofit = new Retrofit.Builder()
    .baseUrl(mockWebServer.url("/"))
    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
    .build();
LoginService myService = retrofit.create(LoginService.class)

//读取本地文件模拟“假”的 Response
MockResponse response = new MockResponse()
        .setResponseCode(HttpURLConnection.HTTP_OK)
        .setBody(readContentFromFilePath());
mockWebServer.enqueue(response);

总体来说,测试替身可以替换被测系统的依赖,它是一种成本更低的方式。

通过使用测试替身技术可以隔离被测试代码、加速测试执行、确定执行变更、模拟特殊情况,从而让测试代码覆盖得更全、执行得更加高效稳定。


三、第三招:测试策略,“轻重缓急”

从小到大的自动化测试执行所需要的时间越来越长,但是测试会越来越贴近用户的使用场景。

如下图所示,沿着金字塔逐级向上,从小型测试到大型测试,各类测试的保真度逐级提高,但维护和调试工作所需的执行时间和工作量也逐级增加。

在这里插入图片描述
在开发者官网中,谷歌针对应用开发的测试策略给出的建议是:

  • 小型测试占比 70%
  • 中型测试占比 20%
  • 大型测试占比 10%

这对于新开发的应用来说是一个非常好的策略,但对遗留系统来说,由于没有覆盖任何类型的自动化测试,并且代码的可测试性比较低,一开始很难按照这个策略覆盖 70% 的小型测试。如果要提高代码可测性,就意味着要进行代码重构

那么问题来了,如何来保障重构的安全性呢?答案是针对遗留系统,首先考虑覆盖中大型的测试,然后进行代码重构;重构完成后再及时补充中小型的测试;最后逐步将自动化测试的比例演化为金字塔模型比例。

以开头那个遗留系统的登录界面为例,测试策略应该是这样的:首先把覆盖大型的 UI 测试作为重构的安全防护网。

注意,这个时候因为测试都是针对 UI 元素的操作,所以并不需要关注代码里的具体实现逻辑,这样能有效降低重构后重新对用例的调整频率。

当重构完成,拆分出了独立的 LoginLogic 等逻辑后,再继续补充核心的 login 方法和账户密码的校验逻辑。


四、总结

如果原来的软件中没有任何接缝让去设置数据,或者说设置这些数据的成本非常高,那么这个时候就说代码的可测性低,编写自动化测试的难度就更高。

可以通过下面这六种方式来暴露程序的接缝。

在这里插入图片描述

其次,可以通过测试替身来替换被测系统的依赖。常见的测试替身有六种,分别为 Dummy、Stub、Spy、Mock、Fake 及 Shadow。

通过使用测试替身技术可以隔离被测试代码、加速测试执行、确定执行变更、模拟特殊情况,从而让测试代码覆盖得更全、执行得更加高效稳定。

最后,因为遗留系统通常在一开始没有覆盖任何自动化测试,而又得先进行重构,所以建议的策略是针对遗留系统,首先考虑覆盖中大型的测试,然后进行代码重构。

重构完成后再及时补充中小型的测试,最后逐步将自动化测试的比例演化为金字塔模型比例。


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿昌喜欢吃黄桃

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值