单元测试试图告诉我们关于 Activity 的什么事情:第二部分

本文讲的是单元测试试图告诉我们关于 Activity 的什么事情:第二部分,


Activity 和?Fragment,可能是因为一些奇怪的历史巧合,从 Android 推出之时起就被视为构建 Android 应用的最佳构件。我们把这种想法称为“android-centric”架构。

本系列博文是关于 android-centric 架构的可测试性和其它问题之间的联系的,而这些问题正导致 Android 开发者们排斥这种架构。它们同时也试图通过单元测试告诉我们:Activity 和 Fragment 不是应用的最佳构件,因为它们迫使我们写出高耦合和低内聚的代码。

在本系列文章的第二部分,对 Google I/O 示例 app 会话详情页的单元测试表明,将 Activity 和 Fragment 当作组件,会使代码难以测试。测试失败同时也揭示,目标类是低内聚的。

The Google I/O 会话细节例子

当我在开发一个项目时,我尝试从最让我害怕的代码开始测试。大型类让我害怕。Google I/O 应用的最大的类是SessionDetailFragment。长的方法也让我害怕,而这个大型类中最长的方法是 displaySessionData。这是这个巨大的类显示的内容的截图:

这是吓人的 displaySessionData 方法。这不是人们通常可以容易地理解的东西;这正是它可怕的原因。在继续之前,用惊恐的目光看它一眼,并恐惧地颤抖一下:

private void displaySessionData(final SessionDetailModel data) {
  mTitle.setText(data.getSessionTitle());
  mSubtitle.setText(data.getSessionSubtitle());
  try {
    AppIndex.AppIndexApi.start(mClient, getActionForTitle(data.getSessionTitle()));
  } catch (Throwable e) {
    // Nothing to do if indexing fails.
  }

  if (data.shouldShowHeaderImage()) {
    mImageLoader.loadImage(data.getPhotoUrl(), mPhotoView);
  } else {
    mPhotoViewContainer.setVisibility(View.GONE);
    ViewCompat.setFitsSystemWindows(mAppBar, false);
    // This is hacky but the collapsing toolbar requires a minimum height to enable
    // the status bar scrim feature; set 1px. When there is no image, this would leave
    // a 1px gap so we offset with a negative margin.
    ((ViewGroup.MarginLayoutParams) mCollapsingToolbar.getLayoutParams()).topMargin = -1;
  }

  tryExecuteDeferredUiOperations();

  // Handle Keynote as a special case, where the user cannot remove it
  // from the schedule (it is auto added to schedule on sync)
  mShowFab = (AccountUtils.hasActiveAccount(getContext()) && !data.isKeynote());
  mAddScheduleFab.setVisibility(mShowFab ? View.VISIBLE : View.INVISIBLE);

  displayTags(data);

  if (!data.isKeynote()) {
    showInScheduleDeferred(data.isInSchedule());
  }

  if (!TextUtils.isEmpty(data.getSessionAbstract())) {
    UIUtils.setTextMaybeHtml(mAbstract, data.getSessionAbstract());
    mAbstract.setVisibility(View.VISIBLE);
  } else {
    mAbstract.setVisibility(View.GONE);
  }

  // Build requirements section
  final View requirementsBlock = getActivity().findViewById(R.id.session_requirements_block);
  final String sessionRequirements = data.getRequirements();
  if (!TextUtils.isEmpty(sessionRequirements)) {
    UIUtils.setTextMaybeHtml(mRequirements, sessionRequirements);
    requirementsBlock.setVisibility(View.VISIBLE);
  } else {
    requirementsBlock.setVisibility(View.GONE);
  }

  final ViewGroup relatedVideosBlock =
      (ViewGroup) getActivity().findViewById(R.id.related_videos_block);
  relatedVideosBlock.setVisibility(View.GONE);

  updateEmptyView(data);

  updateTimeBasedUi(data);

  if (data.getLiveStreamVideoWatched()) {
    mPhotoView.setColorFilter(getContext().getResources().getColor(R.color.played_video_tint));
    mWatchVideo.setText(getString(R.string.session_replay));
  }

  if (data.hasLiveStream()) {
    mWatchVideo.setOnClickListener(new View.OnClickListener() {
      @Override public void onClick(View v) {
        String videoId =
            YouTubeUtils.getVideoIdFromSessionData(data.getYouTubeUrl(), data.getLiveStreamId());
        YouTubeUtils.showYouTubeVideo(videoId, getActivity());
      }
    });
  }

  fireAnalyticsScreenView(data.getSessionTitle());

  mTimeHintUpdaterRunnable = new Runnable() {
    @Override public void run() {
      if (getActivity() == null) {
        // Do not post a delayed message if the activity is detached.
        return;
      }
      updateTimeBasedUi(data);
      mHandler.postDelayed(mTimeHintUpdaterRunnable,
          SessionDetailConstants.TIME_HINT_UPDATE_INTERVAL);
    }
  };
  mHandler.postDelayed(mTimeHintUpdaterRunnable,
      SessionDetailConstants.TIME_HINT_UPDATE_INTERVAL);

  if (!mHasEnterTransition) {
    // No enter transition so update UI manually
    enterTransitionFinished();
  }

  if (BuildConfig.ENABLE_EXTENDED_SESSION_URL && data.shouldShowExtendedSessionLink()) {
    mExtendedSessionUrl = data.getExtendedSessionUrl();
    if (!TextUtils.isEmpty(mExtendedSessionUrl)) {
      mExtended.setText(R.string.description_extended);
      mExtended.setVisibility(View.VISIBLE);

      mExtended.setClickable(true);
      mExtended.setOnClickListener(new View.OnClickListener() {
        @Override public void onClick(final View v) {
          sendUserAction(SessionDetailUserActionEnum.EXTENDED, null);
        }
      });
    }
  }
}

我知道这很可怕。但振作起来。让我们把目光聚焦在这几行代码上:

private void displaySessionData(final SessionDetailModel data) {
  //...

  // Handle Keynote as a special case, where the user cannot remove it
  // from the schedule (it is auto added to schedule on sync)
  mShowFab =  (AccountUtils.hasActiveAccount(getContext()) && !data.isKeynote());
  mAddScheduleFab.setVisibility(mShowFab ? View.VISIBLE : View.INVISIBLE);

  //...

  if (!data.isKeynote()) {
    showInScheduleDeferred(data.isInSchedule());
  }

  //...
}

很有趣。看起来我们遇到了一条业务规则:

与会者不能把主题演讲环节从日程中删除。

看起来这条规则有一条对应的展示逻辑:如果我们在展示主题演讲环节,我们将不提供把它添加到日程中,或从日程中删除的功能。否则,我们就提供上述功能。哦……而且,如果这个环节是在与会者的日程中,把它显示出来。

这个方法名,showInScheduleDeferred 实际上是一个谎言。哪怕你调用了它,你也不会看见一个添加或删除非主题演讲环节的 FAB。撒谎的方法比长方法更可怕。你不会看见 FAB 的原因是另一条业务规则:

与会者不能添加或删除已经过去的环节。

这些代码在 updateTimeBasedUi中:

private void updateTimeBasedUi(SessionDetailModel data) {
  //...
  // If the session is done, hide the FAB, and show the "Give feedback" card.
  if (data.isSessionReadyForFeedback()) {
    mShowFab = false;
    mAddScheduleFab.setVisibility(View.GONE);
    if (!data.hasFeedback()
        && data.isInScheduleWhenSessionFirstLoaded()
        && !sDismissedFeedbackCard.contains(data.getSessionId())) {
      showGiveFeedbackCard(data);
    }
  }
}

如果你在会议开始前看一看该环节的细节,你将会看见“添加到日程”的 FAB:

所以,我们现在得到了一条相当复杂的业务规则:

只有在一个环节不是主题演讲环节,并且它还没有过去时,与会者才可以在日程中添加或删除这个环节。

当然,我们希望我们的显示逻辑反映这条规则。这意味着我们只在和这条规则一致的情况下添加或删除一个环节。如果我们显示了一个 FAB,用户点击了它,但是应用却说——或许是用一个 Dialog 或者一个 Toast —— “不!你不能移除主题演讲环节!”,那就太傻了。

失败的测试尝试

我们看看是否能为这个展示逻辑写几个测试。记住,我上一次曾说,我的想法是:测试将会告诉我们一些关于设计的事情。如果一个类易于测试,它就设计得好。当我在写测试时,我将以我认为的最简单的方式去写。我在最简单的基础上修改得越多,我就越怀疑正在测试的类。

public class SessionDetailFragmentTest {

  @Test public void displayDataOnlyProvidesAddRemoveSessionAffordanceIfSessionIsNotKeynote() throws Exception {
    // Arrange
    SessionDetailFragment sessionDetailFragment = new SessionDetailFragment();
    final SessionDetailModel sessionDetailModel = mock(SessionDetailModel.class);
    when(sessionDetailModel.isKeynote()).thenReturn(true);
    // Act
    sessionDetailFragment.displayData(sessionDetailModel,
        SessionDetailModel.SessionDetailQueryEnum.SESSIONS);
    // Assert
    final View addScheduleButton =
        sessionDetailFragment.getView().findViewById(R.id.add_schedule_button);
    assertTrue(addScheduleButton.getVisibility() == View.INVISIBLE);
  }
}

这是我能想到的最简单的测试。现在已经有了一些问题,因为 displaySessionData 是一个 private 方法,所以我们必须通过public SessionDetailFragment.displayData 方法间接测试它。看起来不那么傻逼。不幸的是,我们运行它时,将会得到这个结果:

java.lang.NullPointerException
	at com.google.samples.apps.iosched.session.SessionDetailFragment.displaySessionData(SessionDetailFragment.java:396)
	at com.google.samples.apps.iosched.session.SessionDetailFragment.displayData(SessionDetailFragment.java:292)
	at com.google.samples.apps.iosched.session.SessionDetailFragmentTest.displayDataOnlyProvidesAddRemoveSessionAffordanceIfSessionIsNotKeynote(SessionDetailFragmentTest.java:19)

这个测试抱怨说 SessionDetailFragment.mTitleView 是 null。唉。这个错误很烦人,因为SessionDetailFragment.mTitleView 和这个测试没有关系。看起来我必须增加一个 onActivityCreated 方法来确定这些View 被初始化了:

@Test public void displayDataOnlyProvidesAddRemoveSessionAffordanceIfSessionIsNotKeynote()
      throws Exception {
    // Arrange
    SessionDetailFragment sessionDetailFragment = new SessionDetailFragment();
    final SessionDetailModel sessionDetailModel = mock(SessionDetailModel.class);
    when(sessionDetailModel.isKeynote()).thenReturn(false);
    // Act
    sessionDetailFragment.onActivityCreated(null);
    sessionDetailFragment.displayData(sessionDetailModel,
        SessionDetailModel.SessionDetailQueryEnum.SESSIONS);
    // Assert
    final View addScheduleButton =
        sessionDetailFragment.getView().findViewById(R.id.add_schedule_button);
    assertTrue(addScheduleButton.getVisibility() == View.INVISIBLE);
  }

如果我们运行这个测试,会得到另一个错误:

java.lang.NullPointerException
	at com.google.samples.apps.iosched.session.SessionDetailFragment.initPresenter(SessionDetailFragment.java:260)
	at com.google.samples.apps.iosched.session.SessionDetailFragment.onActivityCreated(SessionDetailFragment.java:177)
	at com.google.samples.apps.iosched.session.SessionDetailFragmentTest.displayDataOnlyProvidesAddRemoveSessionAffordanceIfSessionIsNotKeynote(SessionDetailFragmentTest.java:20)

这一次,这个抱怨基本上可以归结于 getActivity() 返回 null。现在,我们也许会调用 onAttach 并传入一个哑 Activity来避免这种情况。或者,我们也许会发现,哪怕我们这样做了,也还要做很多别的事来设置这个测试。这些事情和我们感兴趣的内容没有任何关系。

到这一步,我们也许会放弃,并选择 roboelectric。我曾经说过,我感觉使用 roboelectric 是一个错的选择。测试正试图告诉我们一些关于代码的事情。我们不需要修改我们测试的方式。我们需要修改编码的方式。

在放弃之前,先考虑一下正在发生的事情。我们对测试一小段行为感兴趣,但类设计的方式迫使我们关心很多和我们测试的内容没有关系的其他对象。这意味着我们的代码是低内聚的,我们的类有很多互相没有太大关系的方法和对象。这使得完成测试的设置步骤非常复杂;这也使得让我们的对象难以进入可以真正运行测试的状态。

据我们所知,低内聚并不只关于可测试性。低内聚的类难以理解和改变。这个我们尝试了但没有写出来的测试,印证了我们已经本能地知道的事情:超过 900 行的 SessionDetailFragment 是一个巨兽,它需要被重构。

也许更有争议的是,如果我们听从测试的建议,并首先把它们写出来,我认为我们将最终发现我们根本不需要一个 Fragment。事实上,我认为,我们很少会发现 Fragment 是理想的用于实现功能的组件。一次只讨论一个观点吧。先完成这篇帖子。我们将会在合适的时间回到这个有趣的争论的。

总结

我们刚刚看见,为类写一个测试可以告诉我们:目标类是低内聚的。SessionDetailFragment 可能是一个特别明显的低内聚类的例子,但 TDD 可以帮助我们发现更加隐蔽的低内聚类。在本文中,目标类是一个 Fragment,但如果你坚持写一段时间的测试,你会发现同样的事情对 Activity 也成立。

在下一篇帖子中,我们将看一看测试的难度如何给我们提供新的见解:SessionDetailFragment 是高耦合的。我们将测试驱动同样的功能,并展示所得的设计是怎样高内聚和低耦合的。






原文发布时间为:2017年3月20日


本文来自合作伙伴掘金,了解相关信息可以关注掘金网站。

时间: 2024-10-24 04:06:12

单元测试试图告诉我们关于 Activity 的什么事情:第二部分的相关文章

[译] 通过测试来解耦 Activity

本文讲的是[译] 通过测试来解耦 Activity, 原文地址:Test Driving away Coupling in Activities 原文作者:philosohacker 译文出自:掘金翻译计划 译者:mnikn 校对者:phxnirvana,stormrabbit 通过测试来解耦Activity Activity 和 Fragment,可能是因为一些奇怪的历史巧合,从 Android 推出之时起就被视为构建 Android 应用的最佳构件.我们把Activity 和 Fragmen

Android的activity学习笔记

一.什么是activity     Activity 是用户接口程序,原则上它会提供给用户一个交互式的接口功能.它是 android 应用程序的基本功能单元.Activity 本身是没有界面的.所以activity类创建了一个窗口,开发人员可以通过setContentView(View)接口把UI放到activity创建的窗口上,当activity指向全屏窗口时,也可以用其他方式实现:作为漂浮窗口(通过windowIsFloating的主题集合),或者嵌入到其他的activity(使用Activ

[Android] 全面了解Activity

Activity是什么? 我们都知道Android中有四大组件(Activity 活动,Service 服务,Content Provider 内容提供者,BroadcastReceiver 广播接收器),Activity是我们用的最多也是最基本的组件,因为应用的所有操作都与用户相关,Activity 提供窗口来和用户进行交互.  官方文档这么说:  An activity is a single, focused thing that the user can do. Almost all a

[Android]单元测试实例

转自:http://yuanzhifei89.iteye.com/blog/1122104  作者:yuanzhifei  测试相关资源 让开发自动化: 用 Eclipse 插件提高代码质量http://www.ibm.com/developerworks/cn/java/j-ap01117/index.html  代码测试覆盖率介绍:http://www.cnblogs.com/coderzh/archive/2009/03/29/1424344.html  学习android单元测试时遇到的

android弹出activity设置大小的方法_Android

如何设置Activity的大小,让你的窗口看起来不再是全屏的.有些网友可能知道通过主题比如Theme.Dialog来实现,不过告诉大家设置Activity不再全屏显示的原理.Android Theme也主要是通过定义Style来实现的,实现的原理大家可以直接看Android Framework中的定义,今天给一种更简单,但相对灵活的方法,比如不要Theme.Dialog中的边框,下面就一起来看下自定义Activity 大小的实现方法. 1. 创建一个样式文件到你的工程,保存在在res/value

Activity跳转与返回

Normal 0 7.8 磅 0 2 false false false EN-US ZH-CN X-NONE MicrosoftInternetExplorer4 /* Style Definitions */ table.MsoNormalTable {mso-style-name:普通表格; mso-tstyle-rowband-size:0; mso-tstyle-colband-size:0; mso-style-noshow:yes; mso-style-priority:99; m

android的activity跳转到另一个activity_Android

开发环境:android4.1.1 实验功能:在第一个Hello World!为标签的activity中显示good,该界面中有一个名为Next的按钮.点击Next按钮进入到第二个activity中去,第二个界面中只有1个Close按钮.当然,据网上有人将要比较安全的实现关闭程序的功能也不是挺简单的,因为android有专门的退出键返回键等.所以该Close按钮暂时没去实现它.我的第1个activity为HelloworldActivity,第2个activity为NextActivity. 实

Android编程开发之TextView单击链接弹出Activity的方法_Android

本文实例讲述了Android编程开发之TextView单击链接弹出Activity的方法.分享给大家供大家参考,具体如下: 话不多说直接上码: 核心源码: package com.example.textview4; import android.app.Activity; import android.content.Intent; import android.os.Bundle; import android.text.SpannableString; import android.tex

通过实例简单讲解Android App中的Activity组件_Android

Activity是Android应用中,最直接与用户接触的组件,它负责加载View组件,使其展现给用户,并保持与用户的交互.所有的Activity组件均需要继承Activity类,这是一个Content的间接子类,包装了一些Activity的基本特性. View组件是所有UI组件.容器组件的基类,也就是说,它可以是一个布局容器,也可以是一个布局容器内的基本UI组件.View组件一般通过XML布局资源文件定义,同时Android系统也对这些View组件提供了对应的实现类.如果需要通过某个Activ