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

简介: 本文讲的是单元测试试图告诉我们关于 Activity 的什么事情:第二部分,Activity 和?Fragment,可能是因为一些奇怪的历史巧合,从 Android 推出之时起就被视为构建 Android 应用的最佳构件。我们把这种想法称为“android-centric”架构。
本文讲的是单元测试试图告诉我们关于 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 现在可见

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

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

当然,我们希望我们的显示逻辑反映这条规则。这意味着我们只在和这条规则一致的情况下添加或删除一个环节。如果我们显示了一个 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日

本文来自云栖社区合作伙伴掘金,了解相关信息可以关注掘金网站。
目录
相关文章
|
测试技术 API Android开发
单元测试试图告诉我们关于 Activity 的什么事情:第一部分
本文讲的是单元测试试图告诉我们关于 Activity 的什么事情:第一部分,Activity 和 Fragment,可能是因为一些奇怪的历史巧合,从 Android 推出之时起就被视为构建 Android 应用的最佳构件。
1371 0
|
30天前
|
Java 测试技术 开发者
在软件开发中,测试至关重要,尤以单元测试和集成测试为然
在软件开发中,测试至关重要,尤以单元测试和集成测试为然。单元测试聚焦于Java中的类或方法等最小单元,确保其独立功能正确无误,及早发现问题。集成测试则着眼于模块间的交互,验证整体协作效能。为实现高效测试,需编写可测性强的代码,并选用JUnit等合适框架。同时,合理规划测试场景与利用Spring等工具也必不可少。遵循最佳实践,可提升测试质量,保障Java应用稳健前行。
32 1
|
27天前
|
JSON Dubbo 测试技术
单元测试问题之增加JCode5插件生成的测试代码的可信度如何解决
单元测试问题之增加JCode5插件生成的测试代码的可信度如何解决
42 2
单元测试问题之增加JCode5插件生成的测试代码的可信度如何解决
|
15天前
|
IDE 测试技术 持续交付
Python自动化测试与单元测试框架:提升代码质量与效率
【9月更文挑战第3天】随着软件行业的迅速发展,代码质量和开发效率变得至关重要。本文探讨了Python在自动化及单元测试中的应用,介绍了Selenium、Appium、pytest等自动化测试框架,以及Python标准库中的unittest单元测试框架。通过详细阐述各框架的特点与使用方法,本文旨在帮助开发者掌握编写高效测试用例的技巧,提升代码质量与开发效率。同时,文章还提出了制定测试计划、持续集成与测试等实践建议,助力项目成功。
39 5
|
27天前
|
JSON 测试技术 数据格式
单元测试问题之使用JCode5插件生成测试类如何解决
单元测试问题之使用JCode5插件生成测试类如何解决
57 3
|
27天前
|
测试技术
单元测试问题之使用TestMe时利用JUnit 5的参数化测试特性如何解决
单元测试问题之使用TestMe时利用JUnit 5的参数化测试特性如何解决
21 2
|
1月前
|
IDE 测试技术 持续交付
Python自动化测试与单元测试框架:提升代码质量与效率
随着软件行业的发展,代码质量和效率变得至关重要。自动化测试与单元测试是保证质量、提升效率的关键。Python凭借其简洁强大及丰富的测试框架(如Selenium、Appium、pytest和unittest等),成为了实施自动化测试的理想选择。本文将深入探讨这些框架的应用,帮助读者掌握编写高质量测试用例的方法,并通过持续集成等策略提升开发流程的效率与质量。
41 4
|
17天前
|
测试技术 C# 开发者
“代码守护者:详解WPF开发中的单元测试策略与实践——从选择测试框架到编写模拟对象,全方位保障你的应用程序质量”
【8月更文挑战第31天】单元测试是确保软件质量的关键实践,尤其在复杂的WPF应用中更为重要。通过为每个小模块编写独立测试用例,可以验证代码的功能正确性并在早期发现错误。本文将介绍如何在WPF项目中引入单元测试,并通过具体示例演示其实施过程。首先选择合适的测试框架如NUnit或xUnit.net,并利用Moq模拟框架隔离外部依赖。接着,通过一个简单的WPF应用程序示例,展示如何模拟`IUserRepository`接口并验证`MainViewModel`加载用户数据的正确性。这有助于确保代码质量和未来的重构与扩展。
27 0
|
17天前
|
测试技术 Java Spring
Spring 框架中的测试之道:揭秘单元测试与集成测试的双重保障,你的应用真的安全了吗?
【8月更文挑战第31天】本文以问答形式深入探讨了Spring框架中的测试策略,包括单元测试与集成测试的有效编写方法,及其对提升代码质量和可靠性的重要性。通过具体示例,展示了如何使用`@MockBean`、`@SpringBootTest`等注解来进行服务和控制器的测试,同时介绍了Spring Boot提供的测试工具,如`@DataJpaTest`,以简化数据库测试流程。合理运用这些测试策略和工具,将助力开发者构建更为稳健的软件系统。
27 0
|
17天前
|
测试技术 Java
全面保障Struts 2应用质量:掌握单元测试与集成测试的关键策略
【8月更文挑战第31天】Struts 2 的测试策略结合了单元测试与集成测试。单元测试聚焦于单个组件(如 Action 类)的功能验证,常用 Mockito 模拟依赖项;集成测试则关注组件间的交互,利用 Cactus 等框架确保框架拦截器和 Action 映射等按预期工作。通过确保高测试覆盖率并定期更新测试用例,可以提升应用的整体稳定性和质量。
30 0