【发布时间】:2011-06-22 06:46:10
【问题描述】:
在 Android 中有多种单元测试方法,测试我编写的自定义视图的最佳方法是什么?
我目前正在测试它作为我在仪器测试用例中活动的一部分,但我宁愿只测试孤立的视图。
【问题讨论】:
标签: android unit-testing android-view android-custom-view
在 Android 中有多种单元测试方法,测试我编写的自定义视图的最佳方法是什么?
我目前正在测试它作为我在仪器测试用例中活动的一部分,但我宁愿只测试孤立的视图。
【问题讨论】:
标签: android unit-testing android-view android-custom-view
缺少以视图为中心的 TestCase 实现的一个简单解决方案是在您的测试项目中创建一个包含您的视图的简单 Activity。这将允许您使用简单的 Activity 针对视图编写测试。活动测试信息:
http://developer.android.com/reference/android/test/ActivityUnitTestCase.html
【讨论】:
单元测试是一种测试各个源代码单元以确定它们是否适合使用的方法。因此,当您说要测试自定义视图时,可以检查自定义视图的各种方法,例如“onTouchEvent”、“onDown”、“onFling”、“onLongPress”、“onScroll”、“onShowPress”、“onSingleTapUp”、 “onDraw”和其他各种取决于您的业务逻辑。您可以提供模拟值并对其进行测试。我建议使用两种方法来测试您的自定义视图。
1) 猴子测试 Monkey 测试是由自动化测试工具执行的随机测试。猴子测试是一个单元测试,在运行时没有考虑特定的测试。在这种情况下,猴子是任何输入的生产者。例如,猴子测试可以在文本框中输入随机字符串以确保处理所有可能的用户输入,或者提供垃圾文件来检查对其数据有盲目信任的加载例程。这是一种黑盒测试技术,它可以在许多独特的条件下检查您的自定义视图,您会感到惊讶:)。
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.html 和 http://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/
一位用户制作了一个截屏视频。
这是我制作的关于如何让单元测试工作的 ScreenCast。简单单元 测试和更复杂的单元测试 取决于参考 上下文或活动对象。 http://www.gubatron.com/blog/2010/05/02/how-to-do-unit-testing-on-android-with-eclipse/
希望它能帮助您在所有可能的条件下测试您的自定义视图 :)
评论(futlib)您的所有建议似乎都涉及测试 ACTIVITY,而我真的只想测试 VIEW。我可能想在其他活动中使用这个视图,所以用一个特定的来测试它对我来说没有多大意义。 – futlib
答案:要实现自定义视图, 你通常会首先提供 覆盖某些标准 框架调用的方法 所有意见。例如“onDraw”, "onKeyDown(int, KeyEvent)", "onKeyUp(int, KeyEvent)", “onTrackballEvent(MotionEvent)”等 您的自定义视图。所以当你想 为您的自定义进行单元测试,您将 必须测试这些方法,并且 为其提供模拟值,以便您 可以在所有测试您的自定义视图 可能的情况。测试这些方法 并不意味着你正在测试你的 活动,但这意味着测试你的 自定义视图(方法/函数) 是在一个活动中。你也会 必须将您的自定义视图放在 活动最终为您的目标 用户来体验。一次 彻底测试,您的自定义视图 可以放置在许多项目中,并且 很多活动。
【讨论】:
这里有一个不同的建议,在许多情况下都可以正常工作:假设您从布局文件中引用自定义视图,您可以使用 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());
}
...
【讨论】:
valueSelection 中的 TextView titleTextView = (TextView) valueSelection.findViewById(R.id.mylayout_title_textView); 是什么?
customView(其中包含 TextView)。
为my custom view 设置屏幕截图测试我付出了很多努力。
以下是我如何做到这一点以及我在此过程中学到的一切。
这可能不是最方便的方法,但我还是把它放在这里。
当然,现在Jetpack Compose 中的屏幕截图测试更容易了。
如果需要,您可以使用 JUnit 4。我正在使用 JUnit 5。由于 JUnit 5 从头开始构建在 Java 8 之上,因此其仪器测试只能在运行 Android 8.0 (API 26) 或更高版本的设备上运行。较旧的手机/模拟器将完全跳过这些测试的执行,将它们标记为忽略。
如果您想在 Android 上运行 JUnit 5 测试,请参阅 this answer 了解如何设置。
屏幕截图测试可能无法在其他设备上运行,即使它们具有相同的屏幕 DPI(它们可能根本无法在具有不同屏幕 DPI 的设备上运行)。例如,即使我在本地机器和 GitHub Actions 上使用相同的设备运行测试,它们也不会产生相同的结果(GitHub Actions 断言失败)。所以,我不得不在 GitHub Actions 上禁用它们。
如果您想禁用 GitHub Actions(或其他 CI)上的屏幕截图测试,请参阅 this answer。
如果您在检测测试中有资源(在 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。
由于我们将测试屏幕截图保存在设备/模拟器的外部存储中,我们需要确保在清单和 adb 安装选项中添加了 WRITE_EXTERNAL_STORAGE 权限- 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")
}
}
我将参考屏幕截图(我想与当前屏幕截图比较的那个)保存在 src/androidTest/assets 目录中。因此,将该目录指定为库构建文件中的 assets 条目:
android {
sourceSets {
// This is Kotlin DSL; see https://stackoverflow.com/a/59920318 for groovy DSL
get("debug").assets.srcDirs("src/androidTest/assets")
}
要在运行测试时传递检测参数(如我的代码中的 shouldSave),请执行以下操作:
./gradlew myTask -Pandroid.testInstrumentationRunnerArguments.shouldSave=true
-Pandroid.testInstrumentationRunnerArguments.shouldSave=true
shouldSave Value true。请参阅 this article 和 this post。
您第一次想要运行屏幕截图测试时,以及每当您更新可能会改变其视觉效果的自定义视图时,您应该运行测试通过 true 为 shouldSave 参数,以便新的屏幕截图保存在设备中(请参阅下面代码中的 cmets 以上save 方法了解图像的位置),然后手动将新的屏幕截图复制到您的 src/androidTest/assets/ 目录,以便它们成为新的参考。
确保为 Kotlin 使用 -ktx 版本的 androidx 库(如 AndroidX Core library)。
-ktx 变体包含有用的 Kotlin 扩展函数。示例:
implementation("androidx.core:core-ktx:1.6.0")
确保设备屏幕已打开且已解锁,以便 Activity 进入恢复状态。
这是我在 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
}
}
【讨论】: