从软件工程的层面讲,单元测试的重要性毋庸置疑,跑通单元测试本身的作用不仅限于验证我们代码能否在指定的路径下符合预期的运行,在我们修改代码后可以通过单元测试快速验证我们的修改是否影响了其它模块。当然,跑通单元测试不一定表示我们的代码没有问题,但是没有跑通单元测试是肯定能说明我们的修改是存在一些问题的。单元测试还在一定程度反应了我们代码质量。由于单元测试对解耦有较高的要求,这样能促使我们开发的时候能够写出耦合度更低的代码。要想构建容易单元测试的项目,解耦是最核心的部分。
本篇不会详细介绍如何编写单元测试以及如何使用常见的单元测试框架。而是主要介绍如何编写出易于单元测试的代码以及整理一些实战中常见的不利于单元测试的编码习惯和规避方案。
解耦使得代码易于单元测试
解耦的目的是什么?
-
方便单元测试:
解耦降低了类与类之间的直接关联,通常变成了类与接口之间的关联,这样我们可以在单元测试中通过实现接口来辅助我们进行单元测试。 -
保持程序的扩展性:
解耦要求我们使用接口和抽象类完成对象之间的协同工作,那么越高层的抽象在业务中的表现越稳定,在需求发生变化的时候,高层的抽象发生的变化也是最小的。所以我们的代码要尽量依赖高层的抽象来写,这样需求变化时对代码的影响范围就会比较小。
如何实现解耦?
通常遵循接口与实现分离,控制反转两个原则。
1. 接口与实现分离
以MVP模式为例,来描述用户登录的场景,接口与实现分离最直观的地方就是Model,View,Presenter都有接口定义。这些接口抽象了登录的过程,以及对对这个过程的实现进行了职责划分。
View层接口定义
public interface ILoginView extends IBaseView<UserContract.ILoginPresenter> {
void startLogin(); //开始登陆
void endLogin(); //登陆结束
void loginSucceed(LoginInfo info); //登陆成功
void loginError(Throwable e); //发生错误
}
Presenter层接口定义
public interface ILoginPresenter extends IBasePresenter {
void login(String username, String password); //用户登陆业务
}
Model层接口定义
public interface UserDataSource {
Single<LoginInfo> login(String username, String password); //登录获取用户信息
void saveLoginInfo(LoginInfo info); //存储登录信息
}
下面我们按照接口实现分离的方式分别来实现Model和Presenter.
Model实现
public class UserDataSourceImpl implements UserDataSource {
private ApiService apiService;
private LocalUserDataSource localUserDataSource;
public UserDataSourceImpl(){
this.apiService = new RetrofitService("https://www.test.com/").create(ApiService.class);
this.localUserDataSource = new LocalUserDataSourceImpl();
}
@Override
public Single<LoginInfo> login(String username, final String password) {
return apiService
.login(username,password)
.subscribeOn(Schedulers.io())
.doOnSuccess(loginInfo -> saveLoginInfo(loginInfo));
}
@Override
public void saveLoginInfo(LoginInfo loginInfo) {
localUserDataSource.saveLoginInfo(loginInfo);
}
}
通常Model是依赖于多个DataSource的,但是简单的情况下可以直接使用DataSource充当Model层
Presenter实现
public class LoginPresenter extends BasePresenter implements UserContract.ILoginPresenter {
private UserContract.ILoginView mLoginView;
private UserDataSource mUserDataSource;
public LoginPresenter(UserContract.ILoginView loginView) {
super(loginView);
this.mLoginView = loginView;
this.mLoginView.setPresenter(this);
this.mUserDataSource = new UserDataSourceImpl();
}
@Override
public void login(String username, String password) {
mUserDataSource.login(username, password)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new SingleObserver<LoginInfo>() {
@Override
public void onSubscribe(Disposable d) {
mLoginView.startLogin();
}
@Override
public void onSuccess(LoginInfo loginInfo) {
mLoginView.endLogin();
mLoginView.loginSucceed(loginInfo);
}
@Override
public void onError(Throwable e) {
e.printStackTrace();
mLoginView.endLogin();
mLoginView.loginError(e);
}
});
}
}
上面LoginPresenter
在定义字段(我们可以认为这些字段是依赖)的时候都是引用了接口类型,这样LoginPresenter
里面的方法就只能使用接口中定义的方法,避免了直接使用实现类的方法。但是这样存在两个问题,
从单元测试的角度上来讲,假如我们需要对LoginPresenter
这个类进行单元测试,由于UserDataSource
的实例的创建是在LoginPresenter
构造函数里面进行的,那么我们很难在不修改UserDataSourceImpl
代码的情况下去模拟UserDataSource
的行为,这样就很难针对LoginPresenter
进行单元测试。
从耦合的角度来讲,虽然上面LoginPresenter
内部没有直接依赖实现类UserDataSourceImpl
,但是这个实现类却是在LoginPresenter
构造函数中创建的。它形成了一个间接的依赖,假如某一天我们对UserDataSourceImpl
的构造函数进行了修改,那么我们不得不同时修改所有实例化UserDataSourceImpl
的地方,这样不仅增加了工作量,还引入了更多的风险。
为了解决这两个问题,我们可以遵循控制反转的原则对代码进行修改。
2. 控制反转(IOC)
控制反转主要核心思想是,对象的创建不再由依赖方负责,转而交给容器或外部负责。控制反转这个思想里面反转的就是对象的创建权力。实现控制反转最常用的方式是依赖注入(DI)和依赖查找(DL),在Android项目里面,依赖注入用的比较多。
根据这个原则,我们将创建对象的权利交出去,可以简单的通过构造方法将所需要的对象传进来。
public class UserDataSourceImpl implements UserDataSource {
private ApiService apiService;
private LocalUserDataSource localUserDataSource;
public UserDataSourceImpl(ApiService apiService,LocalUserDataSource localUserDataSource){
this.apiService = apiService;
this.localUserDataSource = localUserDataSource;
}
...
}
public class LoginPresenter extends BasePresenter implements UserContract.ILoginPresenter {
private UserContract.ILoginView mLoginView;
private UserDataSource mUserDataSource;
public LoginPresenter(UserContract.ILoginView loginView,UserDataSource userDataSource) {
super(loginView);
this.mLoginView = loginView;
this.mLoginView.setPresenter(this);
this.mUserDataSource = userDataSource
}
...
}
实例化LoginPresenter
的代码如下:
public class LoginActivity extends Activity implements UserContract.ILoginView {
private UserContract.ILoginPresenter mPresenter;
@Override
protected void onCreate(Bundle savedInstanceState) {
...
mPresenter = new LoginPresenter(this, new UserDataSourceImpl(new RetrofitService("https://www.test.com/").create(ApiService.class),new LocalUserDataSourceImpl()));
...
}
}
这样我们就可以在单元测试里面通过Mock ILoginView
以及UserDataSource
来对LoginPresenter
进行完整的单元测试。同时LoginPresenter
不再间接的依赖于UserDataSourceImpl
而是完全依赖于UserDataSource
接口。
现在我们还面临一个问题,那就是当UserDataSourceImpl
的构造函数发生变化,那么所有初始化这个实例的地方都需要修改。比如我们现在给它添加一个HttpConfig
的依赖来获取依赖于环境的属性,比如系统语言,软件版本等等。
public class UserDataSourceImpl implements UserDataSource {
private ApiService apiService;
private LocalUserDataSource localUserDataSource;
private HttpConfig httpConfig;
public UserDataSourceImpl(ApiService apiService,LocalUserDataSource localUserDataSource,HttpConfig httpConfig){
this.apiService = apiService;
this.localUserDataSource = localUserDataSource;
this.httpConfig = httpConfig;
}
...
}
那么我们的LoginActivity
以及其它实例化UserDataSourceImpl
的地方就需要进行对应的修改。
为了避免这种修改我们可以创建一个工具类来专门创建那些可能在多个地方初始化的类的对象。
public class Repository {
public static RetrofitService getRetrofitService(String baseUrl){
return new RetrofitService(baseUrl);
}
public static ApiService getApiService(){
return getRetrofitService("https://www.test.com/").create(ApiService.class);
}
public static LocalUserDataSource getLocalUserDataSource(){
return new LocalUserDataSourceImpl();
}
public static HttpConfig getHttpConfig(){
return new HttpConfigImpl(MvpApplication.getInstance());
}
public static UserDataSource getUserDataSource(){
return new UserDataSourceImpl(getApiService(),getLocalUserDataSource(),getHttpConfig());
}
}
这样实例化LoginPresenter
的代码会变得简介很多,而且后续如果UserDataSourceImpl
的构造函数发生了变更,我们只需要修改Repository.getUserDataSource()
方法即可。
public class LoginActivity extends Activity implements UserContract.ILoginView {
private UserContract.ILoginPresenter mPresenter;
@Override
protected void onCreate(Bundle savedInstanceState) {
...
mPresenter = new LoginPresenter(this, Repository.getUserDataSource());
...
}
}
使用工具类来手动创建和管理对象是比较简单易懂的实现依赖注入的方式。除此之外我们还可以使Dagger2这样强大的框架来进行依赖注入的工作。这样你基本不需要改动其它任何地方就可以直接在实现类里面新增和删除对象的依赖。当然只有明白了控制反转的思想,才能更好的使用这类依赖注入的框架提升我们的开发效率和代码质量。总之,要想使得我们写出的代码容易进行单元测试,我们在编写需要单元测试的模块的时候需要遵守的原则是:对外部模块或者组件的依赖都应该从外部注入,只管使用不管创建。
常见问题及解决方案
1. 平台依赖
...
public UserDataSourceImpl(ApiService apiService,Context context){
this.apiService = apiService;
this.context = context;
}
...
@Override
public Single<LoginInfo> login(String username, final String password) {
return apiService
.login(username,password,context.getString(R.string.language))
.subscribeOn(Schedulers.io())
.doOnSuccess(loginInfo -> saveLoginInfo(loginInfo));
}
逻辑处理层(Model和Presenter)里面包含对平台接口的依赖,最常见的是直接依赖Context。比如上面这段代码,构造函数直接依赖于Context,并且在方法里面直接调用了context.getString方法来获取当前的语言。虽然直接依赖Context并不会造成单元测试无法进行,但是由于对平台的依赖会导致我们不得不在真机上去跑单元测试或者采用其它模拟的方案,这样会明显降低单元测试的效率。
我们可以将那些获取语言或者版本号这类依赖于平台的数据抽象出一些接口,使用这些接口而不是直接调用平台的方法。
public interface AppConfig {
String getLanguage();
String getVersion();
}
...
public UserDataSourceImpl(ApiService apiService, LocalUserDataSource localUserDataSource, AppConfig appConfig){
this.apiService = apiService;
this.localUserDataSource = localUserDataSource;
this.appConfig = appConfig;
}
...
@Override
public Single<LoginInfo> login(String username, final String password) {
return apiService
.login(username,password,appConfig.getLanguage())
.subscribeOn(Schedulers.io())
.doOnSuccess(loginInfo -> saveLoginInfo(loginInfo));
}
2. 静态方法间接的依赖平台
public class PackageUtils{
public static String getPackageName(){
return App.getInstance().getPackageName();
}
}
这个工具类间接的调用了Application单例,如果我们在其它模块间接调用了这个方法,则很容易导致单元测试的失败。所以我们编写工具类尽量不要在工具类里面调用单例,并且工具类里面依赖的实例需要在方法中声明。
3. 直接调用单例
单例是单元测试的一大障碍,无论是直接调用单例还是间接调用单例都是需要避免的。单例和核心目的是为了减少对象创建开销、维持对象状态,而不是方便调用的。在单例的使用上我们仍然需要采用IOC的思想,将对应的实例注入对象而不是直接调用。
总结
本篇主要是讲解如何在项目里面编写出易于单元测试的代码、解耦对于单元测试的意义以通常使用的解耦思路。除了这些我们还需要了解以下几点,来提高我们对单元测试认识:
- 毫无疑问,单元测试对于提升代码质量具有重要的意义和作用,同时单元测试也会给我们带来很多额外的工作量。要降低单元测试给开发带来的工作量,需要我们在项目开始的时候就要考虑好如何方便后续进行单元测试,并且编写完对应的单元或者模块之后立即进行单元测试的编写,而不是在项目后期补充。
- 如果我们的项目里面的单元有修改和调整则需要同步调整对应的单元测试,所以单元测试的编写和维护是持续的。
- 单元测试同样会存在质量问题,覆盖率100%也并不能说明代码或者单元测试是合理的。覆盖只能说明某部分部分代码被执行过,这部分代码是否应该在指定的条件下执行需要我们针对性的进行单元测试验证。