安卓:如何测试自定义视图?

24

在Android中有几种单元测试方法,对于我编写的自定义视图来说,哪种方法最好?

目前,我是将其作为仪器化测试用例中活动的一部分进行测试,但我更愿意单独测试该视图。

4个回答

20

wikibooks中所述:

单元测试是通过测试单独的源代码单元来确定其是否适合使用的方法。

因此,当您说要测试自定义视图时,可以检查自定义视图的各种方法,例如"onTouchEvent"、"onDown"、"onFling"、"onLongPress"、"onScroll"、"onShowPress"、"onSingleTapUp"、"onDraw"和其他与业务逻辑相关的方法。您可以提供模拟值并进行测试。我建议两种测试自定义视图的方法。

1) 猴子测试

猴子测试是由自动化测试工具执行的随机测试。

G.D.S. Prasad on geekinterview.com

和:

猴子测试是一种无特定目的的单元测试。在这种情况下,猴子是任何输入的产生者。例如,猴子测试可以将随机字符串输入到文本框中,以确保处理所有可能的用户输入,或提供垃圾文件以检查对盲目信任其数据的加载例程。

sridharrganesan on geekinterview.com


这是一种黑盒测试技术,它可以在许多独特的条件下检查您的自定义视图,让您感到惊讶:)。

2) 单元测试

2a) 使用Robotium单元测试框架

前往Robotium.org或http://code.google.com/p/robotium/并下载示例测试项目。Robotium是一个非常易于使用的框架,可以轻松快捷地测试Android应用程序。我创建它是为了使测试高级Android应用程序变得更加容易且省力。它与ActivityInstrumentationTestCase2一起使用。

2b) 使用Android测试框架

以下是参考链接: http://developer.android.com/reference/android/test/ActivityInstrumentationTestCase2.htmlhttp://developer.android.com/reference/android/test/ActivityUnitTestCase.html

对于初学者: http://developer.android.com/guide/topics/testing/testing_android.html

根据一个用户的说法:除了轻松测试非平台相关逻辑外,我还没有找到运行测试的巧妙方法,至少对我来说,任何实际的平台逻辑测试都很麻烦。这几乎是微不足道的,因为我发现模拟器和我的实际设备之间的实现差异,并且我不想在我的设备上运行单元测试实现,只是为了之后删除应用程序。

我的策略是:尝试简洁并精心考虑逻辑,然后逐个测试实现(不太理想)。

此外,Stephen Ng提供了适用于Android项目的真正单元测试解决方案的好方法:https://sites.google.com/site/androiddevtesting/

一个用户制作了一个屏幕录像。

这里是我如何使单元测试工作的屏幕录像。简单的单元测试和更复杂的单元测试,这些测试依赖于具有对Context或Activity对象的引用。http://www.gubatron.com/blog/2010/05/02/how-to-do-unit-testing-on-android-with-eclipse/

希望它能帮助您在所有可能的情况下测试自定义视图 :)


评论(futlib):你所有的建议似乎都涉及测试活动,而我真正想要测试的只是视图。我可能想在其他活动中使用这个视图,所以用特定的视图进行测试并不太合理。

回答:要实现自定义视图,通常需要提供一些框架调用所有视图的标准方法的覆盖。例如"onDraw"、"onKeyDown(int, KeyEvent)"、"onKeyUp(int, KeyEvent)"、"onTrackballEvent(MotionEvent)"等您的自定义视图的方法。因此,当您要对自定义视图进行单元测试时,将必须测试这些方法,并为其提供模拟值,以便您可以在所有可能的情况下测试自定义视图。测试这些方法并不意味着您正在测试活动,而是意味着测试您的自定义视图(方法/函数),该自定义视图位于一个活动中。另外,您最终必须将自定义视图放入一个活动中,以便目标用户可以体验它。经过彻底测试后,您的自定义视图可以放置在许多项目和许多活动中。


2
你所有的建议似乎都涉及到测试 ACTIVITY,但我真正想要测试的只是 VIEW。我可能想要在其他活动中使用此视图,因此用特定的活动测试它并不太合理。 - futlib
@futlib,我已经编辑了我的答案并更详细地解释了它。请查看一下。 - Muhammad Shahab
3
没有活动,就没有办法测试我的观点吗? - futlib
没有 Activity 是不可能的。 - Muhammad Shahab
只使用普通的JUnit测试、没有Android上下文以及使用Mockito模拟对象来测试自定义视图,这是否有用?我看到一些旧代码,想知道这是否有用。 - unlimited101
显示剩余3条评论

20

14

这里有一个不同的建议,在许多情况下都能很好地发挥作用:假设您在布局文件中引用了自定义视图,您可以使用AndroidTestCase来填充视图,然后在隔离环境中执行测试。以下是一些示例代码:

my_custom_layout.xml:

<?xml version="1.0" encoding="utf-8"?>
<de.mypackage.MyCustomView ...

MyCustomView.java:

public class MyCustomView extends LinearLayout {

    public MyCustomView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public void setTitle(CharSequence title) {
        ((TextView) findViewById(R.id.mylayout_title_textView)).setText(title);
    }
...

MyCustomViewTest.java:

public class MyCustomViewTest extends AndroidTestCase {

    private MyCustomView customView;

    @SuppressLint("InflateParams")
    @Override
    protected void setUp() throws Exception {
        super.setUp();
        customView = (MyCustomView) LayoutInflater.from(getContext())
            .inflate(R.layout.my_custom_layout, null);
    }

    public void testSetTitle_SomeValue_TextViewHasValue() {
        customView.setTitle("Some value");
        TextView titleTextView = (TextView) valueSelection.findViewById(R.id.mylayout_title_textView);
        assertEquals("Some value", titleTextView.getText().toString());
    }
...

TextView titleTextView = (TextView) valueSelection.findViewById(R.id.mylayout_title_textView); 中的 valueSelection 是什么? - beerBear
说实话,我不确定(太久远了),但我认为这是个笔误 - 应该是 customView (其中包含那个 TextView)。 - csoltenborn

0

我为了设置我的自定义视图的屏幕截图测试而努力奋斗。
这是我成功实现的方法以及在过程中学到的所有内容。
它可能不是最方便的方法,但我还是把它放在这里了。
当然,使用Jetpack Compose进行屏幕截图测试现在变得更加容易了。

⚠ 注意事项 #1

如果您愿意,可以使用JUnit 4。我正在使用JUnit 5。因为JUnit 5从头开始基于Java 8构建,其仪器化测试只能在运行Android 8.0(API 26)或更新版本的设备上运行。旧手机/模拟器将完全跳过执行这些测试,并将它们标记为已忽略

如果您想在Android上运行JUnit 5测试,请参考此回答以获取设置方法。

⚠ 注意事项 #2

屏幕截图测试可能在其他设备上无法正常工作,即使它们具有相同的屏幕 DPI(它们可能在具有不同屏幕 DPI 的设备上根本无法工作)。例如,即使我在本地机器和 GitHub Actions 上使用相同的设备运行测试,它们也不会产生相同的结果(GitHub Actions 断言失败)。因此,我不得不在 GitHub Actions 上禁用它们。

如果您想在 GitHub Actions(或其他 CI)上禁用屏幕截图测试,请参见this answer

⚠ 注意事项 #3

如果您在仪器化测试中有资源(在androidTest源集中),并且您想引用它们的 ID,则应像这样使用它们(注意包名称后跟.test):

com.example.test.R.id.an_id

例如,如果您的包名为my.package.name,则要在测试中访问src/androidTest/res/layout/my_layout.xml中的布局文件,您需要使用my.package.name.test.R.layout.my_layout

⚠ 注意事项 #4

由于我们将测试截图保存在设备/模拟器的外部存储上,因此我们需要确保在清单中添加了WRITE_EXTERNAL_STORAGE权限,并在构建脚本中配置了adb安装选项-g-r。在运行测试之前,当在Marshmallow+上运行时,我们还需要授予这些权限。 -g用于在安装应用程序时授予权限(仅适用于Marshmallow+),而-r用于允许重新安装应用程序。 这些对应于adb shell pm install选项。 请注意,这在Android Studio中尚不起作用。

因此,请在src/androidTest/目录中创建一个AndroidManifest.xml文件,并将以下内容添加到其中:

<manifest package="my.package.name">
  <!-- For saving screenshots in tests -->
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
                   tools:ignore="ScopedStorage"
                   tools:remove="android:maxSdkVersion"/>
  <application android:requestLegacyExternalStorage="true">
    <activity android:name=".MyActivityThatContainsTheView"/>
  </application>
</manifest>

并在您的库 Gradle 构建文件中添加 adb 安装选项:

android {
    // Note that adbOptions block is deprecated in Android Gradle Plugin 7.0.0;
    // replace adbOptions block with installation block
    adbOptions {
        installOptions("-g", "-r")
    }
}

⚠ 注意事项 #5

我将参考截图(即我想要与当前截图进行比较的截图)保存在 src/androidTest/assets 目录中。因此,请在库构建文件中指定该目录作为 assets 条目:

android {
    sourceSets {
        // This is Kotlin DSL; see https://dev59.com/xJnga4cB1Zd3GeqPUizk#59920318 for groovy DSL
        get("debug").assets.srcDirs("src/androidTest/assets")
    }

⚠ 注意事项 #6

要在运行测试时传递仪器参数(如我的代码中的shouldSave),请执行以下操作:

  • 对于Gradle任务:
    • 从命令行运行任务:在任务名称后传递您的参数
      ./gradlew myTask -Pandroid.testInstrumentationRunnerArguments.shouldSave=true
    • 使用Studio运行任务:在运行配置的Arguments:字段中传递参数
      -Pandroid.testInstrumentationRunnerArguments.shouldSave=true
  • 对于Android Studio的Android Instrumented Tests运行配置:
    运行配置弹出窗口中选择Edit Configurations...,然后选择您的运行配置,在Instrumentation arguments:字段前面点击...,然后添加一个名称-值条目,例如NameshouldSave Valuetrue

请查看本文本帖

⚠ 注意事项 #7

第一次运行屏幕截图测试以及每当更新可能会改变其外观的自定义视图时,您应该传递true参数来运行测试,以便将新的屏幕截图保存在设备中(请参见下面代码中save方法上方的注释以了解图像位置),然后手动将新的屏幕截图复制到您的src/androidTest/assets/目录中,以便它们成为新的参考文件。

⚠ 注意事项 #8

确保在Kotlin中使用androidx库的-ktx版本(例如AndroidX Core library)。
-ktx变体包含有用的Kotlin扩展函数。例如:

implementation("androidx.core:core-ktx:1.6.0")

⚠ 注意事项 #9

确保设备屏幕开启并解锁,以便活动进入恢复状态。

代码

这是我在 src/androidTest/java/com/example/ 目录中的测试活动,它公开了我要作为属性截取屏幕截图的视图:

class MyActivityThatContainsTheView : AppCompatActivity() {

    lateinit var myView: MyView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(my.package.name.test.R.layout.my_layout_that_contains_the_view)
        myView = findViewById(my.package.name.test.R.id.my_view_id_in_the_layout_file)
    }
}

最后,这是我的测试以及我如何保存、加载和比较截图:
@DisabledIfBuildConfigValue(named = "CI", matches = "true")
class ScreenshotTestView {

    @JvmField
    @RegisterExtension
    val scenarioExtension = ActivityScenarioExtension.launch<MyActivityThatContainsTheView>()
    lateinit var scenario: ActivityScenario<MyActivityThatContainsTheView>
    // See ⚠ Caution #6 above in the post
    val shouldSave = InstrumentationRegistry.getArguments().getString("shouldSave", "false").toBoolean()
    val shouldAssert = InstrumentationRegistry.getArguments().getString("shouldAssert", "true").toBoolean()

    @BeforeEach fun setUp() {
        scenario = scenarioExtension.scenario
        scenario.moveToState(Lifecycle.State.RESUMED)
    }

    @Test fun test1() {
        val screenshotName = "screenshot-1"
        scenario.onActivity { activity ->
            val view = activity.myView
            view.drawToBitmap()
                    .saveIfNeeded(shouldSave, screenshotName)
                    .assertIfNeeded(shouldAssert, screenshotName)
        }
    }

    fun Bitmap.saveIfNeeded(shouldSave: Boolean, name: String): Bitmap {
        if (shouldSave) save(name)
        return this
    }
    
    fun Bitmap.assertIfNeeded(shouldCompare: Boolean, screenshotName: String) {
        if (shouldCompare) assert(screenshotName)
    }
    
    /**
     * The screenshots are saved in /Android/data/my.package.name.test/files/Pictures
     * on the external storage of the device.
     */
    private fun Bitmap.save(name: String) {
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        val path = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
        val file = File(path, "$name.png")
        file.outputStream().use { stream ->
            compress(Bitmap.CompressFormat.PNG, 100, stream)
        }
    }
    
    private fun Bitmap.assert(screenshotName: String) {
        val reference = loadReferenceScreenshot(screenshotName)
        // I'm using AssertJ library; you can simply use assertTrue(this.sameAs(reference))
        assertThat(this.sameAs(reference))
            .withFailMessage { "Screenshots are not the same: $screenshotName.png" }
            .isTrue()
    }
    
    private fun loadReferenceScreenshot(name: String): Bitmap {
        val context = InstrumentationRegistry.getInstrumentation().context
        val assets = context.resources.assets
        val reference = assets.open("$name.png").use { stream ->
            BitmapFactory.decodeStream(stream)
        }
        return reference
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接