Android-架构5-测试Clean-Architecture
译者注:本文是android架构系列第五篇,请先阅读前四篇文章:
Android 架构:Part 1 —— 那些年我们犯过的错
Android 架构:Part 2 —— 介绍 Clean Architecture
Android 架构:Part 3 —— 应用 Clean Architecture
Android 架构:Part 4 —— 实践 Clean Architecture(含源码)
为什么我们要关心测试?开发者和普罗大众一样,都会犯错误,还记得上个月写一个实现时候忘了考虑一种特殊情况吗,或者传一个空字符当方法的参数后引发的你想象不到的后果。
每次对代码更改以后都要对着app尝试各种不同的点击,手势,切换方向来确保程序不会出问题的做法也太low了。我猜你想不到当设备旋转时连续点击三次屏幕的右上角,程序会崩溃引发空指针异常,用户就会轻易的给你复现了这个bug。我们的上帝什么事都做的出来,开发者需要确保每个类做它该做的事情,程序的每一个模块都能完成我们交给它的任务。
这就是为什么我们要做自动化测试。
1.测试clean architecture
Clean Archetectur 的优点就是可维护性和方便测试。Clean Architecture的每一个模块都有它明确的单一的功能,我们只需要了解它的功能并且确保各个模块每次都完成了自己的任务。
现在想一想我们应该具体测些什么东西? 我的回答是所有的东西都要测试,如果你把代码结构梳理的很好,你可以测试所有的东西,决定权在你手里,但是,通常没有时间和经历测试所有的东西。
可测试性是第一步的,第二步是怎么优雅的测试,先重温有关测试的FIRST原则:
Fast -快捷-测试应该尽可能的快捷和节省时间,花数分钟或者几个小时的时间在测试上是没有意义的,如果这样的花,没人愿意做这样的测试。
Isolated-孤立性-测试应用程序的一个单元时,写好左右要在这个单元上测试的内容,写下断言,点击测试按钮,完事,不应该需要做别的事情了。
Repeatable –可重复性,每次测试的结果应该是完全一样的,结果不应该依赖一些不确定的数据。
Self-validating –自动验证-测试框架应该知道测试是否通过。不应该有任何需要人工检验测试结果的操作,只需要看下测试结果的进度条变绿没有,就是这么简单。
Timely –实时性-测试应该是和开发同时进行的,或者应该写完所有的功能代码前就写完测试的代码!
现在,我们开发了一个可以测试的app,并且知道了怎么测试,测试用例该怎么命名呢?
2.测试的命名
测试的命名重要吗?它潜在价值比直接价值更重要,它反应了你对待测试的态度,和你对测试的思维方式。
上代码:
1 | public final class DeleteFeedUseCase implements CompletableUseCaseWithParameter { |
2 | |
3 | @Override |
4 | public Completable execute(final Integer feedId) { |
5 | |
6 | //implementation |
7 | |
8 | } |
9 | } |
天真的小伙子会这样写测试:
1 | @Test |
2 | public void executeWhenDatabaseReturnsTrue() throws Exception { |
3 | } |
4 | @Test |
5 | public void executeWithErrorInDatabase() throws Exception { |
6 | } |
这种方式是实现式命名。它和代码的实现联系的太紧,如果更改了代码的具体实现,我们也需要更改测试的代码。这种测试代码通常是完成开发工作后写的,唯一的好处是可以快速写出这样的测试代码。
第二种方式是示例式命名:
1 | @Test |
2 | public void doSomethingWithIdsSmallerThanZero() throws Exception { |
3 | } |
4 | |
5 | @Test |
6 | public void ignoreWhenNullIsPassed() throws Exception { |
7 | } |
示例式测试是系统使用的例子。它很适合测试特殊情况,但是不用把这种放肆用在所有的测试中,和代码的实现关联度太高。
现在我们尝试抛开实现,抽象这个类,这种测试的写法怎么样:
1 | @Test |
2 | public void shouldDeleteExistingFeed() throws Exception { |
3 | } |
4 | |
5 | @Test |
6 | public void shouldIgnoreDeletingNonExistingFeed() throws Exception { |
7 | } |
从这个测试类的方法名上可以清楚的知道我们想要这个类做什么。这个测试可以当作类的说明书,因此,这种命名方式叫-说明书风格的命名。命名和代码实现没有任何关联,从测试的方法名上,我们可以写出代码的实现。出了一些边界情况外,说明书风格的命名通常是最好的选择,对于哪些个别的特殊情况的测试,可以选择加几个示例风格的命名测试。
理论只是介绍到这里,准备好撸起袖子玩泥巴了!
3.测试domain
考虑下怎么测试use cases, 在我们的app中use cases的结构长这样:
问题是EnableBackgroundFeedUpdatesUseCase是final类型的,测试其它 use case的时候需要用到它,但是测试框架Mockito不允许创建final类的替身(mock).
Use case会被它的实现引用,添加了一层接口后:
现在我们可以创建EnableBackgroundFeedUpdatesUseCase 接口的替身了。但是过了一段时间后,我们发现这种做法在开发过程中会让人产生疑惑,中间层的接口是空的并且use case实际上不需要有接口,use case就像它自己的名字描述的那样,只做一件事情-enable background feed updates use case”,没有什么要抽象的!
好吧,让我们试试不需要让use case 是final类型的。
我们试着让尽可能多的变量是final类型的,这样可以让代码更加结构化,我们可以不让这些use cases是final类型的,不过需要一个更好的方法。
我们找到了一个方法就是使用mockito-inline,它让那些不能创建替身的类有了替身。使用最新版本的Mockito,可以为所有final类型的类创建替身。
下面是一个use case实现示例:
1 | public final class EnableBackgroundFeedUpdatesUseCase implements CompletableUseCase { |
2 | private final SetShouldUpdateFeedsInBackgroundUseCase setShouldUpdateFeedsInBackgroundUseCase; |
3 | |
4 | private final FeedsUpdateScheduler feedsUpdateScheduler; |
5 | |
6 | //constructor |
7 | |
8 | @Override |
9 | public Completable execute() { |
10 | return setShouldUpdateFeedsInBackgroundUseCase.execute(true) |
11 | .concatWith(Completable.fromAction(feedsUpdateScheduler::scheduleBackgroundFeedUpdates)); |
12 | } |
13 | |
14 | } |
测试use case时我们应该测试这个use case调用了仓库(repositories)中响应的方法或者执行了其它的use case,也应该测试这个use case有正确的回调结果:
1 | private EnableBackgroundFeedUpdatesUseCase enableBackgroundFeedUpdatesUseCase; |
2 | |
3 | private SetShouldUpdateFeedsInBackgroundUseCase setShouldUpdateFeedsInBackgroundUseCase; |
4 | private FeedsUpdateScheduler feedUpdateScheduler; |
5 | private TestSubscriber testSubscriber; |
6 | |
7 | @Before |
8 | public void setUp() throws Exception { |
9 | setShouldUpdateFeedsInBackgroundUseCase = |
10 | Mockito.mock(SetShouldUpdateFeedsInBackgroundUseCase.class); |
11 | feedUpdateScheduler = Mockito.mock(FeedsUpdateScheduler.class); |
12 | testSubscriber = new TestSubscriber(); |
13 | enableBackgroundFeedUpdatesUseCase = new EnableBackgroundFeedUpdatesUseCase(setShouldUpdateFeedsInBackgroundUseCase,feedUpdateScheduler); |
14 | } |
15 | |
16 | @Test |
17 | public void shouldEnableBackgroundFeedUpdates() throws Exception { |
18 | Mockito.when(setShouldUpdateFeedsInBackgroundUseCase.execute(true)).thenReturn(Complet- able.complete()); |
19 | |
20 | enableBackgroundFeedUpdatesUseCase.execute().subscribe(testSubscriber); |
21 | |
22 | Mockito.verify(setShouldUpdateFeedsInBackgroundUseCase, Mockito.times(1)).execute(true); Mockito.verifyNoMoreInteractions(setShouldUpdateFeedsInBackgroundUseCase); |
23 | |
24 | Mockito.verify(feedUpdateScheduler, Mockito.times(1)).scheduleBackgroundFeedUpdates(); |
25 | Mockito.verifyNoMoreInteractions(feedUpdateScheduler); |
26 | |
27 | testSubscriber.assertCompleted(); |
28 | } |
这里使用了Rx中的TestSubscriber,可以测试是否有正确的回调结果,它可以断言请求是否完成,返回的结果,结果的数量等等。
4.测试data模块
下面是一个非常简单的仓库类里的一个方法,只使用了一个DAO方法:
1 | public final class FeedRepositoryImpl implements FeedRepository { |
2 | private final FeedDao feedDao; |
3 | private final Scheduler backgroundScheduler; |
4 | |
5 | //constructor |
6 | |
7 | @Override |
8 | public Single feedExists(final String feedUrl) { |
9 | return Single.defer(() -> feedDao.doesFeedExist(feedUrl)) |
10 | .subscribeOn(backgroundScheduler); |
11 | } |
12 | |
13 | //more methods |
14 | |
15 | } |
测试仓库类时,你应该安排各种DAO,使他们返回或者收到一些模拟的数据,检验仓库类是否正确的处理了这些数据:
1 | private FeedService feedService; |
2 | private FeedDao feedDao; |
3 | private PreferenceUtils preferenceUtils; |
4 | private Scheduler scheduler; |
5 | private FeedRepositoryImpl feedRepositoryImpl; |
6 | |
7 | @Before |
8 | public void setUp() throws Exception { |
9 | feedService = Mockito.mock(FeedService.class); |
10 | feedDao = Mockito.mock(FeedDao.class); |
11 | preferenceUtils = Mockito.mock(PreferenceUtils.class); |
12 | scheduler = Schedulers.immediate(); |
13 | feedRepositoryImpl = new FeedRepositoryImpl(feedService, feedDao, preferenceUtils, scheduler); |
14 | } |
15 | |
16 | @Test |
17 | public void shouldReturnInfoAboutFeedExistingIfFeedExists() throws Exception { |
18 | } |
测试映射器(mappers)或者转换器(converters)时,指定映射器的输入,验证输出是否精确并断言他们相等,测试服务(services),解析器(parsers)也采用这用方法。
测试app 模块
在整洁架构(clean architecture)中,我们使用了MVP,主持人(Presenter)只是普通的java类,和android没有任何关系,所以测试他们没有什么特殊的地方:
1 | public final class ArticlesPresenterTest { |
2 | @Test |
3 | public void shouldFetchArticlesAndPassThemToView() throws Exception { |
4 | } |
5 | |
6 | @Test |
7 | public void shouldFetchFavouriteArticlesAndPassThemToView() throws Exception { |
8 | } |
9 | |
10 | @Test |
11 | public void shouldShowArticleDetails() throws Exception { |
12 | } |
13 | |
14 | @Test |
15 | public void shouldMarkArticleAsRead() throws Exception { |
16 | } |
17 | |
18 | @Test |
19 | public void shouldMakeArticleFavourite() throws Exception { |
20 | } |
21 | |
22 | @Test |
23 | public void shouldMakeArticleNotFavorite() throws Exception { |
24 | } |
25 | } |
MVP中的主持人(Presenter)通常有很多依赖,我们通过注解@Inject把这些依赖注入到主持人中,而不是通过构造函数传递这些依赖。在下面的测试代码中,使用了@Mock和@Spy注解:
1 | public final class ArticlesPresenter extends BasePresenter implements ArticlesContract.Presenter { |
2 | @Inject |
3 | GetArticlesUseCase getArticlesUseCase; |
4 | @Inject |
5 | FeedViewModeMapper feedViewModeMapper; |
6 | // (...) more fields |
7 | |
8 | public ArticlesPresenter(final ArticlesContract.View view) { |
9 | super(view); |
10 | } |
11 | |
12 | @Override |
13 | public void fetchArticles(final int feedId) { |
14 | viewActionQueue.subscribeTo(getArticlesUseCase.execute(feedId) |
15 | .map(feedViewModeMapper::mapArticlesToViewModels) |
16 | .map(this::toViewAction), |
17 | Throwable::printStackTrace); |
18 | } |
19 | |
20 | // (...) more methods |
21 | |
22 | } |
@Mock注解只是在类的外部模拟一些简单的数据,@Spy注解让你使用已经存在的实例的所有方法,也可以模拟一些方法并且在被调用的方法上使用@Spy注解。模拟数据通过@InjectMocks注解被注入到主持人中:
1 | @Mock |
2 | GetArticlesUseCase getArticlesUseCase; |
3 | |
4 | @Mock |
5 | FeedViewModeMapper feedViewModeMapper; |
6 | |
7 | @Mock |
8 | ConnectivityReceiver connectivityReceiver; |
9 | |
10 | @Mock |
11 | ViewActionQueueProvider viewActionQueueProvider; |
12 | |
13 | @Spy |
14 | Scheduler mainThreadScheduler = Schedulers.immediate(); |
15 | |
16 | @Spy |
17 | MockViewActionQueue mockViewActionHandler; |
18 | |
19 | @InjectMocks |
20 | ArticlesPresenter articlesPresenter; |
之后需要一些准备工作,视图层需要手动模拟因为它通过构造方法被注入,在presenter.start() and presenter.activate()方法中主持人准备好需要做的工作:
1 | @Before |
2 | public void setUp() throws Exception { |
3 | view = Mockito.mock(ArticlesContract.View.class); articlesPresenter = new ArticlesPresenter(view); |
4 | MockitoAnnotations.initMocks(this); |
5 | Mockito.when(connectivityReceiver.getConnectivityStatus()).thenReturn(Observable.just(true)); |
6 | Mockito.when(viewActionQueueProvider.queueFor(Mockito.any())).thenReturn(new MockViewActionQueue ()); |
7 | |
8 | articlesPresenter.start(); |
9 | articlesPresenter.activate(); |
10 | |
11 | } |
准备就绪后可以写测试代码了,确保主持人在需要的时间调用视图层的方法:
1 | @Test |
2 | public void shouldFetchArticlesAndPassThemToView() throws Exception { |
3 | |
4 | final int feedId = AppTestData.TEST_FEED_ID; |
5 | |
6 | final List<article> articles = new ArrayList<>(); |
7 | |
8 | final Article = new Article (AppTestData.TEST_ARTICLE_ID, feedId, AppTestData.TEST_ STRING, AppTestData.TEST_LINK, AppTestData.TEST_LONG_DATE, |
9 | false, false); |
10 | articles.add(article); |
11 | |
12 | final List<ArticleViewModel articleViewModels = new ArrayList <>(); |
13 | |
14 | final ArticleViewModel articleViweModel = new ArticleViewModel(AppTestData.TEST_ARTICLE_ ID, AppTestData.TEST_STRING, AppTestData.TEST_LINK, AppTestDAta.TEST_STRING,false, false); |
15 | articleViewModels.add(articleViewModel); |
16 | |
17 | Mockito.when(getArticlesUseCase.execute(feedID)).thenReturn(Single.just(articles)); |
18 | Mockito.when(feedViewModeMapper.mapArticlesToViewModels(Mockito.anyList())).thenReturn(articleViewModels); |
19 | articlesPresenter.fetchArticles(feedId); |
20 | Mockito.verify(getArticlesUseCase, Mockito.times(1)).execute(feedId); |
21 | Moclito.verify(view, Mockito.times(1)).showArticles(articleViewModels); |
22 | } |
结论
向你安利开始写代码前和写代码的过程中就写测试,这样可以写出可测试性强和关联度低的代码。把测试当成代码的说明书,尽可能写代码前就先写测试。凡人都会犯错误,不要让过度自信占据上风,我们需要这道测试屏障保护自己的应用程序。