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("[email protected]") && 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));
这段代码的主要问题是依赖了远端的服务,运行具有不稳定性,例如服务可能挂掉、网络延时或者中断。
对此,有两种常用的测试替身方式可以提高测试的稳定性:
这种方式是用 Mockito 框架来 Mock 一个 LoginService 的假实现,然后进行 Stub。
当触发 login 方法时,返回预期的测试数据。
LoginService loginService = Mockito.mock(LoginService.class);
Mockito.when(loginService.login(anyString(),anyString())).thenReturn(Observable.from(new User()));
还可以在本地 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% 的小型测试。如果要提高代码可测性,就意味着要进行代码重构
。
那么问题来了,如何来保障重构的安全性呢?答案是针对遗留系统,首先考虑覆盖中大型的测试,然后进行代码重构;重构完成后再及时补充中小型的测试;最后逐步将自动化测试的比例演化为金字塔模型比例。
以开头那个遗留系统的登录界面为例,测试策略应该是这样的:首先把覆盖大型的 UI 测试作为重构的安全防护网。
注意,这个时候因为测试都是针对 UI 元素的操作,所以并不需要关注代码里的具体实现逻辑,这样能有效降低重构后重新对用例的调整频率。
当重构完成,拆分出了独立的 LoginLogic 等逻辑后,再继续补充核心的 login 方法和账户密码的校验逻辑。
如果原来的软件中没有任何接缝让去设置数据,或者说设置这些数据的成本非常高,那么这个时候就说代码的可测性低,编写自动化测试的难度就更高。
可以通过下面这六种方式来暴露程序的接缝。
其次,可以通过测试替身来替换被测系统的依赖。常见的测试替身有六种,分别为 Dummy、Stub、Spy、Mock、Fake 及 Shadow。
通过使用测试替身技术可以隔离被测试代码、加速测试执行、确定执行变更、模拟特殊情况,从而让测试代码覆盖得更全、执行得更加高效稳定。
最后,因为遗留系统通常在一开始没有覆盖任何自动化测试,而又得先进行重构,所以建议的策略是针对遗留系统,首先考虑覆盖中大型的测试,然后进行代码重构。
重构完成后再及时补充中小型的测试,最后逐步将自动化测试的比例演化为金字塔模型比例。