三、测试秘籍

本章通过应用前几章中描述的原则和技术,提供了您将会遇到的多种常见情况的实例。示例以易于理解的方式呈现,因此您可以在自己的项目中修改和使用它们。

以下是本章将涉及的主题:

  • 安卓单元测试
  • 测试活动和应用
  • 测试数据库和内容提供商
  • 测试本地和远程服务
  • 测试用户界面
  • 测试异常
  • 测试解析器
  • 测试内存泄漏
  • 用浓缩咖啡测试

在这一章之后,你将有一个参考来为你的项目在不同的情况下应用不同的测试方法。

安卓单元测试

在某些情况下,您确实需要单独测试应用的某些部分,而很少与底层系统连接。在安卓系统中,系统是活动框架。在这种情况下,我们必须选择一个基类,这个基类在测试层次结构中足够高,可以移除一些依赖关系,但是又不够高,不能负责实例化上下文的一些基本基础设施。

在这种情况下,候选基类是AndroidTestCase,因为这允许在不考虑活动的情况下使用上下文和资源:

public class AccessPrivateDataTest extends AndroidTestCase {

   public void testAccessAnotherAppsPrivateDataIsNotPossible()  {
        String filesDirectory = getContext().getFilesDir().getPath();
        String privateFilePath = filesDirectory + 
"/data/com.android.cts.appwithdata/private_file.txt";
        try {
            new FileInputStream(privateFilePath);
            fail("Was able to access another app's private data");
        } catch (FileNotFoundException e) {
            // expected
        }
   }
}

类型

这个例子是基于位于 http://source.android.com/compatibility/cts-intro.html 的安卓兼容性测试套件 T2。CTS 是一套测试,旨在使应用开发人员的安卓硬件和软件环境保持一致,而不考虑原始设备制造商。

AccessPrivateDataTest类扩展了AndroidTestCase,因为它是一个不需要系统基础设施的单元测试。在这种特殊情况下,我们不能直接使用TestCase,因为我们稍后将使用getContext()

该测试方法testAccessAnotherAppsPrivateDataIsNotPossible()测试对另一个包的私有数据的访问,如果可以访问,则失败。为了实现这一点,预期的异常是被捕获,如果没有发生这种情况,fail()将通过自定义消息被调用。这个测试看起来很简单,但是你可以看到这对于阻止无意中的安全错误的出现是多么的有效。

测试活动和应用

在这里,我们涵盖了一些您在日常测试中会发现的常见情况,包括处理意图、偏好和上下文的。您可以调整这些模式以适应您的特定需求。

嘲讽应用和偏好

用安卓的话来说,应用指的是需要维护全局应用状态时使用的基类。全包android.app.Application。这可以在处理共享偏好时利用。

我们期望改变这些偏好值的测试不会影响实际应用的行为。如果没有正确的测试框架,测试可能会删除将这些值存储为共享首选项的应用的用户帐户信息。这听起来不是个好主意。所以我们真正需要的是模拟一个上下文的能力,这个上下文也模拟了对SharedPreferences的访问。

我们的第一次尝试可能是使用RenamingDelegatingContext,但不幸的是,它没有模拟SharedPreferences,尽管它很接近,因为它模拟了数据库和文件系统访问。所以首先,我们需要模拟访问我们共享的偏好。

类型

每当你遇到一个新的类(比如RenamingDelegatingContext),阅读相关的 Java 文档来了解框架开发人员期望它如何被使用是一个好主意。更多信息请参考。

重命名模拟上下文类

让我们创建专门的上下文。RenamingDelegatingContext类是从开始的一个很好的起点,因为正如我们之前提到的,数据库和文件系统访问将被嘲笑。问题是如何模拟 SharedPreferences的接入。

记住RenamingDelegatingContext,顾名思义,将一切委托给一个上下文。所以我们问题的根源就在于这个语境。当您从上下文访问SharedPreferences时,您使用getSharedPreferences(String name, int mode)。要改变这种方法的工作方式,我们可以在RenamingMockContext中覆盖它。现在我们已经拥有了控制权,我们可以在测试前缀前面加上 name 参数,这意味着当我们的测试运行时,它们将写入一个不同于主应用的 preferences 文件:

public class RenamingMockContext extends RenamingDelegatingContext {

    private static final String PREFIX = "test.";

    public RenamingMockContext(Context context) {
        super(context, PREFIX);
    }

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        return super.getSharedPreferences(PREFIX + name, mode);
    }
}

现在,我们可以完全控制首选项、数据库和文件的存储方式。

嘲讽上下文

我们有RenamingMockContext课。现在,我们需要一个使用它的测试。由于我们将测试一个应用,测试的基类将是ApplicationTestCase。这个测试用例在中提供了一个框架,你可以在一个受控的环境中测试应用类。它为应用的生命周期提供基本支持,并挂钩注入各种依赖关系和控制测试应用的环境。使用setContext()方法,我们可以在创建应用之前注入RenamingMockContext方法。

我们将测试一个名为TemperatureConverter的应用。这是一个简单的应用,可以将摄氏温度转换为华氏温度,反之亦然。我们将在第六章练习测试驱动开发中讨论更多关于这个应用的开发。目前,细节是不必要的,因为我们专注于测试场景。TemperatureConverter应用会将任何转换的小数位存储为共享首选项。因此,我们将创建一个测试来设置小数位数,然后检索它来验证其值:

public class TemperatureConverterApplicationTests extends ApplicationTestCase<TemperatureConverterApplication> {

    public TemperatureConverterApplicationTests() {
        this("TemperatureConverterApplicationTests");
    }

    public TemperatureConverterApplicationTests(String name) {
        super(TemperatureConverterApplication.class);
        setName(name);
    }

    public void testSetAndRetreiveDecimalPlaces() {
        RenamingMockContext mockContext = new RenamingMockContext(getContext());
        setContext(mockContext);
        createApplication();
        TemperatureConverterApplication application = getApplication();

        application.setDecimalPlaces(3);

        assertEquals(3, application.getDecimalPlaces());
    }
}

我们使用TemperatureConverterApplication模板参数扩展ApplicationTestCase

然后,我们使用我们在第 2 章中讨论过的给定名称构造器模式,用 Android SDK 理解测试。

在这里,我们没有使用setUp()方法,因为班上只有一个测试–你不会像他们说的那样需要它。有一天,如果你来给这个类增加另一个测试,这时你可以覆盖setUp()并移动行为。这遵循了 DRY 原则,意思是不要重复自己,并导致更易维护的软件。因此在测试的顶部方法,我们创建模拟上下文,并使用 setContext()方法为这个测试设置上下文;我们使用createApplication()创建应用。您需要确保在createApplication之前调用setContext,因为这是获取正确实例化顺序的方式。现在,实际测试所需行为的代码设置小数位数,检索它,并验证它的值。就是这个,用RenamingMockContext给我们控制SharedPreferences。每当请求SharedPreference时,该方法将调用委托上下文,为名称添加前缀。应用使用的原SharedPreferences类不变:

public class TemperatureConverterApplication extends Application {
    private static final int DECIMAL_PLACES_DEFAULT = 2;
    private static final String KEY_DECIMAL_PLACES = ".KEY_DECIMAL_PLACES";

    private SharedPreferences sharedPreferences;

    @Override
    public void onCreate() {
        super.onCreate();
        sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
    }

    public void setDecimalPlaces(int places) {
        Editor editor = sharedPreferences.edit();
        editor.putInt(KEY_DECIMAL_PLACES, places);
        editor.apply();
    }

    public int getDecimalPlaces() {
        return sharedPreferences.getInt(KEY_DECIMAL_PLACES, DECIMAL_PLACES_DEFAULT);
    }
}

我们可以通过在共享首选项中为TemperatureConverterApplication类提供一些值,运行应用,然后运行测试并最终通过执行测试来验证该值没有受到影响来验证我们的测试没有影响应用。

测试活动

下一个例子展示了如何使用ActivityUnitTestCase<Activity>基类完全隔离地测试一个活动。第二个选择是ActivityInstrumentationTestCase2<Activity>。但是,前者允许您创建一个活动,但不将其附加到系统,这意味着您不能启动其他活动(您是一个活动单个单元)。父类的这种选择不仅在您的设置中需要更多的注意和关注,而且对测试中的活动提供了更大的灵活性和控制。这种测试旨在测试一般的活动行为,而不是活动实例与其他系统组件或任何用户界面相关测试的交互。

首先,这是被测班级。这是一个简单的活动,只需一个按钮。当按下此按钮时,它会激发一个启动拨号器的意图并自动完成:

public class ForwardingActivity extends Activity {
    private static final int GHOSTBUSTERS = 999121212;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_forwarding);
        View button = findViewById(R.id.forwarding_go_button);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent("tel:" + GHOSTBUSTERS);
                startActivity(intent);
                finish();
            }
        });
    }
}

对于我们的测试case,我们扩展ActivityUnitTestCase<ForwardingActivity>,正如我们前面提到的,作为一个Activity类的单元测试。被测活动将与系统断开,因此仅用于测试其内部方面,而非其与其他组件的交互。在 setUp()方法中,我们创建将启动我们的测试活动的意图,即ForwardingActivity。注意getInstrumentation()的使用。活动上下文的setUp()方法中的getContext类此时仍然为空:

public class ForwardingActivityTest extends ActivityUnitTestCase<ForwardingActivity> {
    private Intent startIntent;

    public ForwardingActivityTest() {
        super(ForwardingActivity.class);
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        Context context = getInstrumentation().getContext();
        startIntent = new Intent(context, ForwardingActivity.class);
    }

现在设置已经完成,我们可以继续测试:

public void testLaunchingSubActivityFiresIntentAndFinishesSelf() {
Activity activity = startActivity(startIntent, null, null);
View button = activity.findViewById(R.id.forwarding_go_button);

button.performClick();

assertNotNull(getStartedActivityIntent());
assertTrue(isFinishCalled());
}

第一个测试是点击转发活动的转到按钮。该按钮的onClickListener类调用startActivity(),其意图是定义一个将要启动的新Activity。执行此操作后,我们验证用于启动新活动的Intent不为空。如果测试中的活动调用了startActivity(Intent)startActivityForResult(Intent, int),则getStartedActivityIntent()方法返回使用的意图。接下来,我们断言调用了finish(),并且我们通过验证FinishCalled()的返回值来做到这一点,如果在被测活动中调用了finish方法(finish()finishFromChild(Activity)finishActivity(int))之一,则返回true:

public void testExampleOfLifeCycleCreation() {
  Activity activity = startActivity(startIntent, null, null);

  // At this point, onCreate() has been called, but nothing else
  // so we complete the startup of the activity
  getInstrumentation().callActivityOnStart(activity);
  getInstrumentation().callActivityOnResume(activity);

  // At this point you could test for various configuration aspects
  // or you could use a Mock Context 
  // to confirm that your activity has made
  // certain calls to the system and set itself up properly.

  getInstrumentation().callActivityOnPause(activity);

  // At this point you could confirm that 
  // the activity has paused properly,
  // as if it is no longer the topmost activity on screen.

    getInstrumentation().callActivityOnStop(activity);

  // At this point, you could confirm that 
  // the activity has shut itself down appropriately,
  // or you could use a Mock Context to confirm that 
  // your activity has released any
  // system resources it should no longer be holding.

  // ActivityUnitTestCase.tearDown() is always automatically called
  // and will take care of calling onDestroy().
 }

第二个测试可能是这个测试用例中更有趣的测试方法。这个测试案例演示了如何运行活动生命周期。启动 Activity 后,会自动调用onCreate(),然后我们可以通过手动调用其他生命周期方法来锻炼它们。为了能够调用这些方法,我们使用这个测试的Intrumentation。此外,我们不会手动调用onDestroy(),因为它将在tearDown()中为我们调用。

让我们浏览一下代码。该方法以与之前分析的测试相同的方式启动活动。活动启动后,系统自动调用其onCreate()方法。然后,我们使用Instrumentation调用其他生命周期方法来完成测试中的活动启动。这些对应于活动生命周期中的onStart()onResume()

活动现在完全开始了,是时候测试我们感兴趣的方面了。一旦实现了这一点,我们就可以遵循生命周期中的其他步骤。请注意,这个示例测试在这里并没有断言任何东西,只是指出了如何遍历生命周期。为了完成生命周期,我们调用onPause()onStop()。正如我们所知,onDestroy()被避免了,因为它将被tearDown()自动调用。

这个测试代表了一个测试框架。您可以重用它来单独测试您的活动,并测试与生命周期相关的案例。模拟对象的注入还可以促进活动其他方面的测试,例如访问系统资源。

测试文件、数据库和内容提供商

一些测试用例需要运行数据库或ContentProvider操作,很快就需要模拟这些操作。例如,如果我们在真实的设备上测试应用,我们不想干扰所述设备上应用的正常运行,尤其是如果我们要更改可能由多个应用共享的值。

这种情况可以利用另一个模拟类,它不是android.test.mock包的一部分,而是android.test,即RenamingDelegatingContext

记住,这个类让我们模拟文件和数据库操作。构造函数中提供的前缀用于修改这些操作的目标。所有其他操作都委托给您指定的委托上下文。

假设我们在测试下的 Activity 使用了一些我们想以某种方式控制的文件或数据库,可能是为了引入专门的内容来驱动我们的测试,而我们不想这样做,或者我们不能使用真实的文件或数据库。在这种情况下,我们创建RenamingDelegatingContext,它指定了一个前缀。我们使用这个前缀提供模拟文件,并引入任何我们需要的内容来驱动我们的测试,并且被测试的活动可以不加修改地使用它们。

保持我们的活动不变,也就是说,不修改它以从不同的来源读取,这样做的好处是确保所有的测试都是有效的。如果我们引入一个仅用于测试的变更,我们将无法保证在真实条件下,活动的行为是相同的。

为了演示这个案例,我们将创建一个极其简单的活动。

MockContextExampleActivity活动显示TextView中文件的内容。我们想要展示的是,与接受测试时相比,在“活动”的正常操作期间,它如何显示不同的内容:

public class MockContextExampleActivity extends Activity {
    private static final String FILE_NAME = "my_file.txt";

    private TextView textView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_mock_context_example);

        textView = (TextView) findViewById(R.id.mock_text_view);
        try {
            FileInputStream fis = openFileInput(FILE_NAME);
            textView.setText(convertStreamToString(fis));
        } catch (FileNotFoundException e) {
            textView.setText("File not found");
        }
    }

    private String convertStreamToString(java.io.InputStream is) {
   Scanner s = new Scanner(is, "UTF-8").useDelimiter("\\A");
       return s.hasNext() ? s.next() : "";
    }

    public String getText() {
        return textView.getText().toString();
    }
}

这是我们简单的活动。它读取my_file.txt文件的内容并显示在TextView上。它还显示可能出现的任何错误。显然,在真实场景中,您会有比这更好的错误处理。

我们需要这个文件的一些内容。创建文件最简单的方法可能如下面的代码所示:

$ adb shell 
$ echo "This is real data" > data/data/com.blundell.tut/files/my_file.txt

$ echo "This is *MOCK* data" > /data/data/com.blundell.tut/files/test.my_file.txt

我们创建了两个不同的文件,一个名为my_file.txt,另一个名为test.my_file.txt,内容不同。后者表示是模拟内容。如果您现在运行前面的活动,您会看到这是真实数据,因为它是从预期文件my_file.txt中读取的。

下面的代码演示了这个模拟数据在我们的活动测试中的使用:

public class MockContextExampleTest 
extends ActivityUnitTestCase<MockContextExampleActivity> {

private static final String PREFIX = "test.";
private RenamingDelegatingContext mockContext;

public MockContextExampleTest() {
super(MockContextExampleActivity.class);
}

@Override
protected void setUp() throws Exception {
super.setUp();
mockContext = new RenamingDelegatingContext(getInstrumentation().getTargetContext(), PREFIX);
mockContext.makeExistingFilesAndDbsAccessible();
}

public void testSampleTextDisplayed() {
setActivityContext(mockContext);

   startActivity(new Intent(), null, null);

assertEquals("This is *MOCK* data\n", getActivity().getText());
}
}

MockContextExampleTest类扩展了ActivityUnitTestCase,因为我们正在寻找对MockContextExampleActivity的独立测试,并且因为我们将注入一个模拟的上下文;在这种情况下,注入的上下文被RenamingDelegatingContext作为依赖项。

我们的装置由模拟上下文mockContextRenamingDelegatingContext组成,使用的是getInstrumentation().getTargetContext()获得的目标上下文。请注意,运行检测的上下文不同于测试中活动的上下文。

下面是一个基本步骤——因为我们想让现有的文件和数据库对这个测试是可访问的,我们必须调用makeExistingFilesAndDbsAccessible()

然后,我们名为testSampleTextDisplayed()的测试使用setActivityContext()注入模拟上下文。

类型

在通过调用startActivity()启动测试中的活动之前,您必须调用setActivityContext()来注入模拟上下文。

然后,活动通过使用刚刚创建的空白意图由startActivity()开始。

我们通过使用添加到活动中的一个 getter 来获取TextView持有的文本值。我不会建议仅仅为了真实项目中的测试而更改生产代码(也就是暴露 getters),因为这可能会导致错误、其他开发人员不正确的使用模式以及安全问题。然而,在这里,我们演示的是RenamingDelegatingContext的使用,而不是测试正确性。

最后,根据This is MOCK* data字符串检查获得的文本值。这里需要注意的是,用于该测试的值是测试文件内容,而不是真正的文件内容。

浏览器程序测试

这些测试基于安卓开源项目(AOSP)的浏览器模块。AOSP 有很多很棒的测试例子,在这里用它们作为例子可以阻止你写很多样板代码来设置测试场景。它们旨在测试内容提供商浏览器书签的某些方面,内容提供商浏览器是安卓平台附带的标准浏览器的一部分(不是 Chrome 应用,而是默认的浏览器应用):

public class BrowserProviderTests extends AndroidTestCase {
    private List<Uri> deleteUris;

    @Override
    protected void setUp() throws Exception {
       super.setUp();
        deleteUris = new ArrayList<Uri>();
    }

    @Override
    protected void tearDown() throws Exception {
        for (Uri uri : deleteUris) {
            deleteUri(uri);
        }
        super.tearDown();
    }
}

AOSP 测试不可从本章的示例项目中获得,但可以在线获得。

这个片段包括扩展AndroidTestCase的测试用例定义。BrowserProviderTests类扩展了AndroidTestCase,因为访问提供商内容需要一个上下文。

setUp()方法中创建的夹具创建一个Uris列表,用于跟踪在tearDown()方法中每次测试结束时要删除的插入的Uris。开发人员可以使用模拟内容提供者来省去这个麻烦,保持我们的测试和系统之间的隔离。不管怎样,tearDown()遍历这个列表并删除存储的Uris。这里不需要重写构造函数,因为AndroidTestCase不是参数化的类,我们不需要在其中做任何特殊的事情。

现在考验来了:

public void testHasDefaultBookmarks() {
  Cursor c = getBookmarksSuggest("");
  try {
    assertTrue("No default bookmarks", c.getCount() > 0);
  } finally {
    c.close();
  }
}

testHasDefaultBookmarks()方法是测试,以确保数据库中始终存在多个默认书签。启动时,光标遍历通过调用getBookmarksSuggest("")获得的默认书签,返回未过滤的书签光标;这就是为什么内容提供商查询参数是"":

public void testPartialFirstTitleWord() {
   assertInsertQuery(
"http://www.example.com/rasdfe", "nfgjra sdfywe", "nfgj");
}

testPartialFirstTitleWord()方法和其他三个类似的方法在这里没有显示testFullFirstTitleWord()testFullFirstTitleWordPartialSecond()testFullTitle()测试书签的插入。为了实现这一点,他们使用书签网址、标题和查询来调用assertInsertQuery()。方法assertInsertQuery()将书签添加到书签提供程序中,插入作为参数发布的带有指定标题的网址。返回的Uri被验证为不为空,与默认的不完全相同。最后将Uri插入到tearDown()中要删除的Uri实例列表中。这方面的代码可以在如下所示的实用方法中看到:

public void testFullTitleJapanese() {
String title = "\u30ae\u30e3\u30e9\u30ea\u30fc\u30fcGoogle\u691c\u7d22";
assertInsertQuery("http://www.example.com/sdaga", title, title);
}

Unicode 是一种计算行业标准,旨在对世界各地书面语言中使用的字符进行一致且唯一的编码。Unicode 标准使用十六进制来表示一个字符。例如,值\u30ae 代表片假名字母 GI (ギ).

我们进行了几项测试,旨在验证该书签提供程序对于除英语之外的语言和地区的利用率。这些特殊案例涵盖了书签标题中日语的使用。测试testFullTitleJapanese(),以及这里没有显示的另外两个测试,即testPartialTitleJapanese()testSoundmarkTitleJapanese()是使用 Unicode 字符之前引入的测试的日语版本。建议在不同的条件下测试应用的组件,比如在这种情况下,使用具有不同字符集的其他语言。

以下是几种实用方法。这些是测试中使用的实用程序。我们之前简单看了一下assertInsertQuery(),那么现在,我们也来看看其他的方法:

private void assertInsertQuery(String url, String title, String query) {
        addBookmark(url, title);
        assertQueryReturns(url, title, query);
    }
    private void addBookmark(String url, String title) {
        Uri uri = insertBookmark(url, title);
        assertNotNull(uri);
        assertFalse(BOOKMARKS_URI.equals(uri));
        deleteUris.add(uri);
    }
    private Uri insertBookmark(String url, String title) {
        ContentValues values = new ContentValues();
        values.put("title", title);
        values.put("url", url);
        values.put("visits", 0);
        values.put("date", 0);
        values.put("created", 0);
        values.put("bookmark", 1);
        return getContext().getContentResolver().insert(BOOKMARKS_URI, values);
    }

private void assertQueryReturns(String url, String title, String query) {
  Cursor c = getBookmarksSuggest(query);
  try {
    assertTrue(title + " not matched by " + query, c.getCount() > 0);
    assertTrue("More than one result for " + query, c.getCount() == 1);
    while (c.moveToNext()) {
      String text1 = getCol(c, SearchManager.SUGGEST_COLUMN_TEXT_1);
      assertNotNull(text1);
      assertEquals("Bad title", title, text1);
      String text2 = getCol(c, SearchManager.SUGGEST_COLUMN_TEXT_2);
      assertNotNull(text2);
      String data = getCol(c, SearchManager.SUGGEST_COLUMN_INTENT_DATA);
      assertNotNull(data);
      assertEquals("Bad URL", url, data);
    }
  } finally {
    c.close();
  }
}

private String getCol(Cursor c, String name) {
  int col = c.getColumnIndex(name);
  String msg = "Column " + name + " not found, " 
               + "columns: " + Arrays.toString(c.getColumnNames());
  assertTrue(msg, col >= 0);
  return c.getString(col);
}

private Cursor getBookmarksSuggest(String query) {
  Uri suggestUri = Uri.parse("content://browser/bookmarks/search_suggest_query");
  String[] selectionArgs = {query};
  Cursor c = getContext().getContentResolver().query(suggestUri, null, "url LIKE ?", selectionArgs, null);
  assertNotNull(c);
  return c;
}

private void deleteUri(Uri uri) {
  int count = getContext().getContentResolver().delete(uri, null, null);
  assertEquals("Failed to delete " + uri, 1, count);
}

方法assertInsertQuery()调用addBookmark()之后的assertQueryReturns(urltitlequery),验证返回的光标是否包含预期数据。这种期望可以概括为:

  • 查询返回的行数大于 0
  • 查询返回的行数等于 1
  • 返回行中的标题不为空
  • 查询返回的标题与方法参数完全相同
  • 建议的第二行不为空
  • 查询返回的网址不为空
  • 此网址与作为方法参数发出的网址完全匹配

这个策略为我们的测试提供了一个有趣的模式。我们需要创建一些实用方法来完成我们的测试,这些方法也可以自己验证几个条件,提高我们的测试质量。

在我们的类中创建 assert 方法允许我们引入一种特定于领域的测试语言,在测试系统的其他部分时可以重用这种语言。

测试异常

我们之前在第 1 章开始测试中提到过这一点,我们在这里声明您应该测试异常和错误值,而不仅仅是测试阳性病例:

@Test(expected = InvalidTemperatureException.class)
public final void testExceptionForLessThanAbsoluteZeroF() {
 TemperatureConverter.
fahrenheitToCelsius(TemperatureConverter.ABSOLUTE_ZERO_F - 1);
}

@Test(expected = InvalidTemperatureException.class)
public final void testExceptionForLessThanAbsoluteZeroC() {
  TemperatureConverter.
celsiusToFahrenheit(TemperatureConverter.ABSOLUTE_ZERO_C - 1);
}

我们之前也介绍过这些测试,但是在这里,我们正在深入研究。首先要注意的是,这些是 JUnit4 测试,这意味着我们可以使用expected注释参数测试异常。当您下载本章的示例项目时,您将能够看到它被分成两个模块,其中一个是 core,这是一个纯 Java 模块,因此,我们有机会使用 JUnit4。在撰写本文时,Android 已经宣布支持 JUnit4,但尚未发布,因此我们仍在 JUnit3 上进行仪表化 Android 测试。

每当我们有一个应该生成异常的方法时,我们应该测试这个异常条件。最好的方法是使用 JUnit4 的expected参数。这声明测试应该抛出异常,如果它没有抛出异常或者抛出不同的异常,测试将失败。这也可以在 JUnit3 中通过在 try-catch 块中调用测试中的方法来完成,捕获预期的异常,否则会失败:

    public void testExceptionForLessThanAbsoluteZeroC() {
        try {
          TemperatureConverter.celsiusToFahrenheit(ABSOLUTE_ZERO_C - 1);
          fail();
        } catch (InvalidTemperatureException ex) {
          // do nothing we expect this exception!
        }
    }

测试本地和远程服务

当你想测试一个android.app.Service时,想法是扩展ServiceTestCase<Service>类到一个控制的环境中进行测试:

public class DummyServiceTest extends ServiceTestCase<DummyService> {
    public DummyServiceTest() {
        super(DummyService.class);
    }

    public void testBasicStartup() {
        Intent startIntent = new Intent();
        startIntent.setClass(getContext(), DummyService.class);
        startService(startIntent);
    }

    public void testBindable() {
        Intent startIntent = new Intent();
        startIntent.setClass(getContext(), DummyService.class);
        bindService(startIntent);
    }
}

与其他类似情况一样,构造函数调用父构造函数,该父构造函数将安卓服务类作为参数传递。

接下来是testBasicStartup()。我们使用我们在这里创建的一个 Intent 启动服务,将它的类设置为被测服务的类。我们还将检测的上下文用于此意图。这个类允许一些依赖注入,因为每个服务都依赖于它运行的上下文和与之相关的应用。这个框架允许您为这些依赖注入修改的、模拟的或隔离的替换,从而执行真正的单元测试。

依赖注入 ( DI )是一种软件设计模式,处理组件如何获得它们的依赖。您可以自己手动完成,也可以使用众多依赖注入库中的一个。

由于我们只是按原样运行测试,服务将被注入一个功能完整的Context和一个通用的MockApplication对象。然后,我们使用startService(startIntent)方法启动服务,就像它由Context.startService()启动一样,提供它提供的参数。如果您使用此方法启动服务,它将被tearDown()自动停止。

另一个测试testBindable(),将测试服务是否可以绑定。该测试使用bindService(startIntent),它以与Context.bindService()启动服务相同的方式启动被测服务,并提供参数。它将通信信道返回给服务。如果客户端无法绑定到服务,它可能会返回 null。最有可能的是,这个测试应该使用类似assertNotNull(service)的断言来检查服务中的 null 返回值,以验证服务是否被正确绑定,但事实并非如此,因此我们可以关注正在使用的框架类。在为类似的情况编写代码时,一定要包括这个测试。

返回的IBinder通常是使用 AIDL 描述的复杂界面的。为了使用这个接口进行测试,您的服务必须实现一个getService()方法,如本章示例项目中的DummService所示;其具有该方法这种实现:

    public class LocalBinder extends Binder {
        DummyService getService() {
            return DummyService.this;
        }
    }

模拟物体的广泛使用

在前面的章节中,我们描述并使用了安卓 SDK 中的模拟类。虽然这些类可以覆盖大量的情况,但是还有其他的安卓类和你自己的领域类需要考虑。您可能需要其他模拟对象来提供您的测试用例。

几个库提供了满足我们嘲讽需求的基础设施,但我们现在专注于 Mockito,这可能是安卓系统中使用最广泛的库。

这不是莫奇托教程。我们将只是分析它在安卓系统中的使用,所以如果你不熟悉它,我会建议你看一下它在https://code.google.com/p/mockito/网站上的文档。

Mockito 是一个开源软件项目,在麻省理工学院许可下可用,并提供测试替身(mock 对象)。它是测试驱动开发的完美匹配,因为它验证期望的方式,也因为它动态生成的模拟对象,因为它们支持重构,并且测试代码在重命名方法或更改其签名时不会中断。

总结其文档,莫奇托最相关的优势如下:

  • 询问执行后的互动问题
  • 它不是预期-运行-验证-避免昂贵的设置
  • 对此进行嘲笑的一种方式是一个简单的应用编程接口
  • 使用类型轻松重构
  • 它模仿具体的类和接口

为了演示它的用法,并建立一种可以在以后为其他测试复制的风格,我们正在完成一些示例测试用例。

截至本文撰写之时,安卓支持的最新版本的 mochito 是 Dexmaker Mockito 1.1。你可能想尝试一个不同的,但你很可能会遇到问题。

我们应该做的第一件事是添加Mockito作为你的安卓工具测试的依赖项。这就像给依赖项闭包添加androidTestCompile引用一样简单。Gradle 将完成剩下的工作,即下载 JAR 文件并将其添加到您的类路径中:

dependencies {
    // other compile dependencies

    androidTestCompile('com.google.dexmaker:dexmaker-mockito:1.1')
}

为了在我们的测试中使用 Mockito,我们只需要从org.mockito静态导入它的方法。通常,您的 IDE 会为您提供静态导入这些的选项,但是如果没有,您可以尝试手动添加它们(如果手动添加时代码是红色的,那么您在库可用方面有问题):

  import static org.mockito.Matchers.*;
import static org.mockito.Mockito.*;

最好使用特定的导入,而不是使用通配符。这里的通配符只是为了简洁。最有可能的是,当您的 IDE 自动保存时,它会将它们扩展到所需的导入中(或者如果您不使用它们,则删除它们!).

导入库

我们已经将 mochito 库添加到项目的 Java 构建路径中。通常,这不是问题,但有时,重建项目会导致我们出现以下错误,从而停止正在构建的项目:错误:在打包 APK 期间出现重复文件。

这取决于项目包含多少库以及它们是什么。

大多数可用的开源库都有类似于 GNU 提议的内容,包括诸如LICENSENOTICECHANGESCOPYRIGHTINSTALL等文件。一旦我们试图在同一个项目中包含一个以上的项目来最终建立一个单一的 APK,我们就会发现这个问题。这可以在您的build.gradle中解决:

    packagingOptions {
        exclude 'META-INF/LICENSE'
        exclude 'folder/duplicatedFileName'
  }

莫奇托用法示例

让我们创建EditText,它只接受有符号的十进制数。我们称之为EditNumberEditNumber使用InputFilter提供该功能。在下面的测试中,我们将使用这个过滤器来验证是否实现了正确的行为。

为了创建测试,我们将使用EditNumberEditText继承的属性,因此它可以添加一个监听器,实际上是一个TextWatcher。这将提供每当EditNumber的文本改变时调用的方法。这个TextWatcher是测试的一个合作者,我们可以将它实现为自己的单独类,并验证调用它的方法的结果,但是这很繁琐,并且可能会引入更多的错误,所以采取的方法是使用 Mockito,以避免编写外部TextWatcher的需要。

这正是我们引入模拟TextWatcher来检查文本变化时的方法调用的方式。

编辑号过滤器测试

这套测试将锻炼 EditNumberInputFilter行为,检查TextWatcher模拟的上的方法调用,并验证结果。

我们使用AndroidTestCase是因为我们有兴趣在其他组件或活动中单独测试EditNumber

我们有几个需要测试的输入(我们允许十进制数,但不允许多位小数、字母等),因此我们可以用一组预期输入和一组预期输出进行一次测试。然而,测试可能会变得非常复杂,维护起来会很困难。更好的方法是对InputFilter的每个测试用例进行一次测试。这允许我们给我们的测试起一个有意义的名字,并解释我们的测试目标。我们将以这样的列表结束:

testTextChangedFilter*
        * WorksForBlankInput
        * WorksForSingleDigitInput
        * WorksForMultipleDigitInput
        * WorksForZeroInput
        * WorksForDecimalInput
        * WorksForNegativeInput
        * WorksForDashedInput
        * WorksForPositiveInput
        * WorksForCharacterInput
        * WorksForDoubleDecimalInput

现在,我们将为其中一个测试testTextChangedFilterWorksForCharacterInput()运行 mocks 的使用,如果您检查示例项目,您将看到所有其他测试遵循相同的模式,并且我们实际上已经提取出一个助手方法,作为所有测试的自定义断言:

public void testTextChangedFilterWorksForCharacterInput() {
  assertEditNumberTextChangeFilter("A1A", "1");
}
/**
 * @param input  the text to be filtered 
 * @param output the result you expect once the input has been filtered
*/
private void assertEditNumberTextChangeFilter(String input, String output) {
 int lengthAfter = output.length();
 TextWatcher mockTextWatcher = mock(TextWatcher.class);
 editNumber.addTextChangedListener(mockTextWatcher);

 editNumber.setText(input);

 verify(mockTextWatcher)
.afterTextChanged(editableCharSequenceEq(output));
 verify(mockTextWatcher)
.onTextChanged(charSequenceEq(output), eq(0), eq(0), eq(lengthAfter));
 verify(mockTextWatcher)
.beforeTextChanged(charSequenceEq(""), eq(0), eq(0), eq(lengthAfter));
}

如您所见,文本案例非常简单;它断言,当您将A1A输入到编辑号视图的文本中时,文本实际上被更改为1。这意味着我们的编辑号已经过滤掉了字符。当我们看assertEditNumberTextChangeFilter(input, output)助手方法时,会发生一件有趣的事情。在我们的助手方法中,我们验证InputFilter正在做它的工作,在这里我们使用 Mockito。使用 Mockito 模拟对象时,有四个常见步骤:

  1. 实例化准备使用的预期模型。
  2. 确定预期的行为,并将其存根化以返回任何夹具数据。
  3. 通常通过调用被测类的方法来练习这些方法。
  4. 验证模拟对象的行为以通过测试。

根据第一步,我们使用mock(TextWatcher.class)创建一个模拟TextWatcher,并将其设置为我们在编辑号上的TextChangedListener

在这种情况下,我们跳过了第二步,因为我们没有夹具数据,因为我们嘲笑的类没有任何预期返回值的方法。稍后我们将在另一个测试中回到这个问题。

在第三步中,我们已经准备好了模拟,我们可以使用测试中的方法来执行它想要的动作。在我们的例子中,方法是editNumber.setText(input),预期的动作是设置文本,从而提示我们的InputFilter运行。

第四步是验证文本是否被我们的过滤器改变了。让我们把第四步分解一点。以下是我们的再次验证:

verify(mockTextWatcher)
.afterTextChanged(editableCharSequenceEq(output));
verify(mockTextWatcher)
.onTextChanged(charSequenceEq(output), eq(0), eq(0), eq(lengthAfter));
verify(mockTextWatcher)
.beforeTextChanged(charSequenceEq(""), eq(0), eq(0), eq(lengthAfter));

我们将使用两个自定义编写的匹配器(editableCharSequenceEq(String)charSequenceEq(String)),因为我们有兴趣比较安卓使用的不同类的字符串内容,如EditableCharSequence。当您使用一个特殊的匹配器时,这意味着为验证方法调用所做的所有比较都需要一个特殊的包装方法。

另一个匹配器eq()期望int等于给定值。后者是由 Mockito 为所有原语类型和对象提供的,但是我们需要实现editableCharSequenceEq()charSequenceEq(),因为它是安卓专用的匹配器。

莫奇托有一个预定义的ArgumentMatcher可以帮助我们创建我们的匹配器。您扩展了类,它给了您一个方法来重写:

    abstract boolean matches(T t);

matches参数匹配器方法需要一个可以用来与预定义变量进行比较的参数。这个参数是方法调用的“实际”结果,预定义变量是“预期”变量。然后你决定返回真或假,不管它们是否相同。

正如你可能已经意识到的那样,自定义ArgumentMatcher类在测试中的频繁使用可能会变得非常复杂,并且可能会导致错误,所以为了简化这个过程,我们将使用一个我们称之为CharSequenceMatcher的助手类。我们还有EditableCharSequenceMatcher,可以在本章的示例项目中找到:

class CharSequenceMatcher extends ArgumentMatcher<CharSequence> {

    private final CharSequence expected;

    static CharSequence charSequenceEq(CharSequence expected) {
        return argThat(new CharSequenceMatcher(expected));
    }

    CharSequenceMatcher(CharSequence expected) {
        this.expected = expected;
    }

    @Override
    public boolean matches(Object actual) {
        return expected.toString().equals(actual.toString());
    }

    @Override
    public void describeTo(Description description) {
        description.appendText(expected.toString());
    }
}

我们通过返回作为参数传递的对象与预定义字段在转换为字符串后的比较结果来实现匹配。

我们还覆盖了describeTo方法,这允许我们在验证失败时更改错误消息。这始终是一个需要记住的好提示:在之前和之后查看错误信息:

Argument(s) are different! Wanted: 
textWatcher.afterTextChanged(<Editable char sequence matcher>);
Actual invocation has different arguments:
textWatcher.afterTextChanged(1);

Argument(s) are different! Wanted: 
textWatcher.afterTextChanged(1XX);
Actual invocation has different arguments: 
textWatcher.afterTextChanged(1);

当匹配器的静态实例化方法被使用并且我们将其作为静态方法导入时,在我们的测试中,我们可以简单地写:

verify(mockTextWatcher).onTextChanged(charSequenceEq(output), 

隔离测试视图

我们在这里分析的测试是基于 Android SDK ApiDemos 项目中的 Focus2AndroidTest。它演示了当行为本身不能被隔离时,如何测试符合布局的视图的一些属性。视图的测试焦点就是这些情况之一。

我们只是在测试个人观点。为了避免创建完整的活动,该测试扩展了AndroidTestCase。您可能想过只使用TestCase,但不幸的是,这是不可能的,因为我们需要一个上下文来通过LayoutInflater扩展 XML 布局,AndroidTestCase将为我们提供这个组件:

public class FocusTest extends AndroidTestCase {
 private FocusFinder focusFinder;

 private ViewGroup layout;

 private Button leftButton;
 private Button centerButton;
 private Button rightButton;

@Override
protected void setUp() throws Exception {
 super.setUp();

 focusFinder = FocusFinder.getInstance();
 // inflate the layout
 Context context = getContext();
 LayoutInflater inflater = LayoutInflater.from(context);
 layout = (ViewGroup) inflater.inflate(R.layout.view_focus, null);

 // manually measure it, and lay it out
 layout.measure(500, 500);
 layout.layout(0, 0, 500, 500);

 leftButton = (Button) layout.findViewById(R.id.focus_left_button);
 centerButton = (Button) layout.findViewById(R.id.focus_center_button);
 rightButton = (Button) layout.findViewById(R.id.focus_right_button);
}

设置准备我们的测试如下:

  1. 我们要求上FocusFinder课。这是一个提供用于查找下一个可聚焦视图的算法的类。它实现了单例模式,这就是为什么我们使用FocusFinder.getInstance()来获取对它的引用。这个类有几个方法来帮助我们找到可聚焦和可触摸的项目,给定各种条件,比如在给定的方向上最近或者从特定的矩形中搜索。
  2. 然后,我们得到LayoutInflater类,并对测试中的布局进行膨胀。由于我们的测试与系统的其他部分隔离,我们需要考虑的一件事是,我们必须手动测量和布局组件。
  3. 然后,我们使用查找视图模式,并将找到的视图分配给字段。

在前一章中,我们列举了我们库中所有可用的断言,您可能还记得,为了测试视图的位置,我们在ViewAsserts类中有一套完整的断言。但是,这取决于布局的定义方式:

public void testGoingRightFromLeftButtonJumpsOverCenterToRight() {
 View actualNextButton = 
focusFinder.findNextFocus(layout, leftButton, View.FOCUS_RIGHT);
 String msg = "right should be next focus from left";
 assertEquals(msg, this.rightButton, actualNextButton);
}

public void testGoingLeftFromRightButtonGoesToCenter() {
 View actualNextButton = 
focusFinder.findNextFocus(layout, rightButton, View.FOCUS_LEFT);
 String msg = "center should be next focus from right";
 assertEquals(msg, this.centerButton, actualNextButton);
}

方法testGoingRightFromLeftButtonJumpsOverCenterToRight(),顾名思义,测试当焦点从左向右按钮移动时,右按钮获得的焦点。为了实现这种搜索,采用了在setUp()方法期间获得的FocusFinder的实例。这个类有一个findNextFocus()方法来获得在给定方向上接收焦点的视图。获得的价值与我们的期望相比较。

以类似的方式,testGoingLeftFromRightButtonGoesToCenter()测试测试向另一个方向的焦点。

测试解析器

很多情况下,你的安卓应用依赖于外部的 XML,JSON 消息,或者从 web 服务获得的文档。这些文档用于本地应用和服务器之间的数据交换。在许多用例中,XML 或 JSON 文档是从服务器获得的,或者是由本地应用生成并发送到服务器的。理想情况下,这些活动调用的方法必须被隔离测试,以进行真正的单元测试,为了实现这一点,我们需要在 APK 的某个地方包含一些模拟文件来运行测试。

但问题是我们能把这些文件放在哪里?

我们来看看。

安卓资产

首先,可以在安卓 SDK 文档中找到对资产定义的简要回顾:

表面上看,“资源”和“资产”的区别并不大,但总的来说,你使用资源存储外部内容的频率要比使用资产的频率高得多。真正的区别是,放置在资源目录中的任何东西都可以从你的应用中很容易地从由安卓编译的 R 类中访问。然而,放在资产目录中的任何东西都将保持其原始文件格式,为了读取它,您必须使用资产管理器以字节流的形式读取文件。因此,将文件和数据保存在资源中(res/)使它们易于访问。

显然,资产是我们需要用来存储文件的东西,这些文件将被解析以测试解析器。

因此,我们的 XML 或 JSON 文件应该放在资产文件夹中,以防止编译时的操作,并能够在应用或测试运行时访问原始内容。

但是要小心,我们需要将它们放在我们androidTest文件夹的资产中,因为那样的话,这些就不是应用的一部分了,而且我们不希望在发布一个实时应用时,它们与我们的代码打包在一起。

解析器测试

这个测试实现了一个AndroidTestCase,因为我们需要的只是一个能够引用我们的资产文件夹的上下文。此外,我们已经编写了测试中的解析,因为这个测试的重点不是如何解析 xml,而是如何引用测试中的模拟资产:

public class ParserExampleActivityTest extends AndroidTestCase {

 public void testParseXml() throws IOException {
 InputStream assetsXml = getContext().getAssets()
.open("my_document.xml");

  String result = parseXml(assetsXml);
  assertNotNull(result);
 }
}
}

通过getContext().getAssets()从资产中打开my_document.xml文件获得InputStream类。请注意,这里获得的上下文和资产来自测试包,而不是来自测试中的活动。

接下来,使用最近获得的InputStream调用parseXml()方法。如果有一个IOException,测试就会失败,从堆栈跟踪中吐出错误,如果一切顺利,我们测试结果不为空。

然后,我们应该在名为my_document.xml的资产中提供我们想要用于测试的 XML。您希望资产位于测试项目文件夹下;默认情况下,这是androidTest/assets

内容可以是:

<?xml version="1.0" encoding="UTF-8" ?>
<records>
  <record>
    <name>Paul</name>
  </record>
</records>

测试内存使用情况

有时,内存消耗是衡量测试目标良好行为的重要因素,无论是活动、服务、内容提供商还是其他组件。

为了测试这种情况,我们可以使用一个实用程序测试,您可以主要在运行测试循环后从其他测试中调用该测试:

public void assertNotInLowMemoryCondition() {
//Verification: check if it is in low memory
ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo();
 ((ActivityManager)getActivity()
.getSystemService(Context.ACTIVITY_SERVICE)).getMemoryInfo(mi);
assertFalse("Low memory condition", mi.lowMemory);
}

这个断言可以从其他测试中调用。开始时使用getMemoryInfo()ActivityManager获取MemoryInfo,之后使用getSystemService()获取实例。如果系统认为自己当前内存不足,则lowMemory字段设置为true

在某些情况下,我们希望更深入地了解资源使用情况,并可以从流程表中获得更详细的信息。

我们可以创建另一个助手方法来获取过程信息,并在测试中使用它:

    private String captureProcessInfo() {
        InputStream in = null;
        try {
           String cmd = "ps";
           Process p = Runtime.getRuntime().exec(cmd);
           in = p.getInputStream();
           Scanner scanner = new Scanner(in);
           scanner.useDelimiter("\\A");
           return scanner.hasNext() ? scanner.next() : "scanner error";
        } catch (IOException e) {
           fail(e.getLocalizedMessage());
        } finally {
           if (in != null) {
               try {
                   in.close();
               } catch (IOException ignore) {
               }
            }
        }
        return "captureProcessInfo error";
    }

为了获得该信息,使用Runtime.exec()执行一个命令(在这种情况下,使用ps,但是您可以根据需要对其进行调整)。该命令的输出连接成一个字符串,稍后返回。我们可以使用返回值将其打印到我们测试中的日志中,或者我们可以进一步处理内容以获得摘要信息。

这是一个记录输出的示例:

        Log.d(TAG, captureProcessInfo());

当运行该测试时,我们获得关于正在运行的进程的信息:

D/ActivityTest(1): USER     PID   PPID  VSIZE  RSS     WCHAN    PC   NAME
D/ActivityTest(1): root      1     0     312    220   c009b74c 0000ca4c S /init
D/ActivityTest(1): root      2     0     0      0     c004e72c 00000000 S kthreadd
D/ActivityTest(1): root      3     2     0      0     c003fdc8 00000000 S ksoftirqd/0
D/ActivityTest(1): root      4     2     0      0     c004b2c4 00000000 S events/0
D/ActivityTest(1): root      5     2     0      0     c004b2c4 00000000 S khelper
D/ActivityTest(1): root      6     2     0      0     c004b2c4 00000000 S suspend
D/ActivityTest(1): root      7     2     0      0     c004b2c4 00000000 S kblockd/0
D/ActivityTest(1): root      8     2     0      0     c004b2c4 00000000 S cqueue
D/ActivityTest(1): root      9     2     0      0     c018179c 00000000 S kseriod

为了简洁起见,输出被剪切了,但是如果您运行它,您将获得系统上运行的进程的完整列表。

获得的信息简要说明如下:

|

圆柱

|

描述

| | --- | --- | | 用户 | 这是文本用户标识。 | | PID | 这是进程的进程标识号。 | | PPID 是 | 这是父进程标识。 | | vsize-vsize-vsize-vsize-vsize-vsize-vsize | 这是进程的虚拟内存大小,以 KB 为单位。这是进程保留的虚拟内存。 | | 简易资讯聚合 | 这是常驻集大小,任务使用的非交换物理内存(以页为单位)。这是进程在页面中实际占用的内存量。这不包括尚未按需加载的页面。 | | WCHAN | 这是流程等待的“通道”。它是系统调用的地址,如果需要文本名称,可以在名称列表中查找。 | | 个人电脑 | 这是当前的 EIP(指令指针)。 | | 状态(无标题) | 这表示过程状态,如下所示:

  • s 用来表示睡眠处于可中断状态
  • r 用来表示跑步
  • t 用于指示停止的进程
  • z 用来表示僵尸

| | | 描述 | | 名字 | 这表示命令名称。Android 中的应用进程以其包名命名。 |

用浓缩咖啡测试

测试用户界面组件可能很困难。知道一个视图何时被膨胀或者确保你不会在错误的线程上访问视图会导致奇怪的行为和古怪的测试。这就是为什么谷歌发布了一个名为 Espresso(https://code.google.com/p/android-test-kit/wiki/Espresso)的用户界面相关测试助手库。

添加 Espresso 库 JAR 可以通过添加到/libs文件夹来实现,但是为了让 Gradle 用户更容易,谷歌向他们的 Maven 存储库发布了一个版本(认为自己是幸运的用户,因为这在 2.0 版本之前是不可用的)。使用 Espresso 时,您还需要使用捆绑的 TestRunner。因此,设置变为:

dependencies {
// other dependencies
androidTestCompile('com.android.support.test.espresso:espresso-core:2.0')
}
android {
    defaultConfig {
    // other configuration
    testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
// Annoyingly there is a overlap with Espresso dependencies at the moment 
// add this closure to fix internal jar file name clashes
packagingOptions {
        exclude 'LICENSE.txt'
    }
}

一旦 Espresso 依赖项被添加到您的项目中,您就有了一个流畅的界面,能够在您的用户界面元素上断言行为。在我们的例子中,我们有一个活动,允许您订购浓缩咖啡。当您按下订购按钮时,会出现一个漂亮的浓缩咖啡图像。我们希望在仪器测试中验证这种行为。

首先要做的是设置我们要测试的活动。我们使用ActivityInstrumentationTestCase2以便我们可以运行一个完整的生命周期活动。您需要在测试开始时调用getActivity()或使用setup()方法来启动活动,并让 Espresso 找到处于恢复状态的活动:

public class ExampleEspressoTest extends ActivityInstrumentationTestCase2<EspressoActivity> {

    public ExampleEspressoTest() {
        super(EspressoActivity.class);
    }

    @Override
    public void setUp() throws Exception {
        getActivity();
    }

设置完成后,我们可以使用 Espresso 编写一个测试来单击我们的按钮,并检查图像是否在活动中显示(变得可见):

    public void testClickingButtonShowsImage() {
        Espresso.onView(
              ViewMatchers.withId(R.id.espresso_button_order))
              perform(ViewActions.click());

        Espresso.onView(
              ViewMatchers.withId(R.id.espresso_imageview_cup))
                .check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
    }

这个例子展示了如何使用 Espresso 找到我们的订购按钮,点击按钮,并检查我们订购的 Espresso 是否显示给用户。Espresso 有一个流畅的接口,这意味着它遵循一个构建器风格的模式,并且大多数方法调用可以被链接。在前面的示例中,为了清晰起见,我展示了完全限定的类,但是这些类可以很容易地更改为静态导入,这样测试就更容易理解了:

    public void testClickingButtonShowsImage() {
        onView(withId(R.id.espresso_button_order))
                .perform(click());

        onView(withId(R.id.espresso_imageview_cup))
                .check(matches(isDisplayed()));
    }

现在可以用更加的句子的风格来解读。这个例子展示了如何使用浓缩咖啡找到我们的订单按钮onView(withId(R.id.espresso_button_order))。点击perform(click()),然后我们找到杯子图像onView(withId(R.id.espresso_imageview_cup)),检查用户是否可以看到check(matches(isDisplayed()))

这表明您需要考虑的唯一类是:

  • 浓缩咖啡:这是切入点。始终以此开始与视图交互。
  • 视图匹配器:用于定位当前层次结构中的视图。
  • 视图动作:用于在定位的视图上点击、长按等。
  • 视图断言:用于检查执行动作后视图的状态。

Espresso 有一个非常强大的应用编程接口,允许您测试视图彼此相邻的位置,匹配列表视图中的数据,直接从页眉或页脚获取数据,以及检查操作栏/工具栏中的视图和许多其他断言。另一个特性是它处理线程的能力;Espresso 将等待异步任务完成,然后断言用户界面是否发生了变化。维基页面上列出了对这些功能以及更多功能的解释(https://code.google.com/p/android-test-kit/w/list)。

总结

在这一章中,介绍了几个真实世界的测试示例,涵盖了广泛的案例。您可以在创建自己的测试时使用它们作为起点。

我们介绍了各种测试方法,您可以将其扩展到自己的测试中。我们使用了模拟上下文,并展示了如何在各种情况下使用RenamingDelegatingContext来更改测试获得的数据。我们还分析了将这些模拟上下文注入到测试依赖项中。

然后,我们使用ActivityUnitTestCase完全隔离地测试活动。我们使用AndroidTestCase单独测试了视图。我们演示了使用 Mockito 来模拟对象,并结合ArgumentMatchers在任何对象上提供自定义匹配器。最后,我们分析了潜在的内存泄漏,并窥视了使用 Espresso 测试用户界面的能力。

下一章着重于管理您的测试环境,使您能够以一致、快速且始终确定的方式运行测试,这将导致自动化和那些淘气的猴子!