跳转至

03 测试落地:三招提高遗留系统代码的可测试性

你好,我是黄俊彬。

上节课我们介绍了开发新特性时需要用到的小、中、大型自动化测试实践,也认识到了自动化测试的重要性。自动化测试不仅可以帮我们提高效率,还可以提高软件的质量。

但是,当我们面临一个没有任何自动化测试的遗留系统时,该如何落地自动化测试呢?这里面有一个绕不开的问题,就是如何提高遗留系统代码的可测试性?

我想这些场景你应该不陌生。

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

这也是为什么我们说遗留系统可测试性低的原因。对于这些场景,我们很难按照上节课介绍的方法直接覆盖中小型自动化测试,所以这个时候我们要先用一些特殊的招式来解决代码不可测的问题。

结合这个思路,今天我将给你分享解决遗留系统代码不可测的三个大招。

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

《修改代码的艺术》一书中提到了“接缝”的概念。接缝是指在不修改代码的条件下,可以改变代码行为的地方。那么这个接缝和代码可测性又有什么关系呢?通常,设计一个测试用例需要三个关键步骤。

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

可以看出,这其中的前置条件就是,要将准备好的测试数据设置到被测试的方法或行为中。如果原来的软件中没有任何接缝可以让你设置数据,或者设置这些数据的成本非常高,那么我们就说代码的可测性低,这个时候编写自动化测试的难度会很高。

你是不是感觉有点抽象?下面我通过一段代码再给你具体讲一讲。上节课,我们为登陆示例编写了不同范围的自动化测试,现在我们继续沿用这个示例。不过我会将代码调整为遗留系统最初的样子,但不会破坏原有的代码逻辑,代码是后面这样。

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

最后,因为遗留系统通常在一开始没有覆盖任何自动化测试,而我们又得先进行重构,所以我建议的策略是针对遗留系统,首先考虑覆盖中大型的测试,然后进行代码重构。重构完成后再及时补充中小型的测试,最后逐步将自动化测试的比例演化为金字塔模型比例。

思考题

感谢你学完了今天的内容,今天的思考题是这样的:你有没有在项目中遇到过可测性非常低的代码?你是如何来解决这个问题呢?

欢迎你在留言区与我交流讨论,也欢迎你把它分享给你的同事或朋友,我们一起来高效、高质量交付软件!

精选留言(6)
  • liner 👍(4) 💬(1)

    有代码仓库吗,又可以运行演示的代码吗

    2023-02-15

  • 蓝色之海 👍(1) 💬(2)

    老师,使用 Ui-Automator 和 Espresso 无法获取到 Dialog 中的 UI 元素,我在网上查了很久也没找到解决方案,老师有遇到过类似的问题嘛?如何解决的呢? 我们工程中是实现了一个 Dialog 的子类,通过创建子类实例,然后执行 show 方法来显示 Dialog 的

    2023-03-13

  • 墨水 👍(0) 💬(1)

    遇到可测性很低的项目,主要是被测试的类依赖于第三方服务\网络访问,但没有裂缝,无法使用测试替身或者Mock+stub的方法测试

    2023-07-06

  • wh 👍(0) 💬(1)

    老师,请问下 Observable.from 导入的什么包?我点不出来。报错

    2023-02-24

  • York 👍(0) 💬(1)

    老师,我们在重构系统的时候,打算前后端一起重构,前端页面也做了新的设计和布局。这个时候,我们又有什么好办法保证核心业务逻辑的正确?之前想通过对比新老API返回的数据来验证,但是我们也打算对返回前端的数据结构重新设计整理,所以感觉这个也行不通。请教下老师有没有什么好的建议。谢谢

    2023-02-20

  • peter 👍(0) 💬(1)

    请教老师两个问题: Q1:第二招的“Mock+Stub”有点不太理解。 文中有这句:“这种方式是用 Mockito 框架来 Mock 一个 LoginService 的假实现,然后进行 Stub”,从这句话以及其对应的代码来看,是用Mockito框架来模拟LoginService,即模拟被测试对象。但表格中对Mock的解释是“将所依赖的对象替换为。。。”,即Mock是模拟依赖对象,在登录的例子中,LoginService是被测试对象,其中的网络服务是依赖对象,所以Mock应该是模拟网络服务。似乎有点矛盾啊。 Q2:专栏的示例代码是用Java还是kotlin?

    2023-02-15