简化的架构对于自动化测试和主代码一样重要。冗余和不灵活性可能会导致一些问题:比如 UI 中的任何更改都需要更新多个文件,测试可能在功能上相互重复,并且支持新功能可能会变成一项耗时且有挑战性的工作来适应现有测试。
页面对象模式如何理顺代码
在为应用程序编写测试时,我们需要在运行各种检查或操作时引用应用程序的视图元素。如果我们总是在编写的每个测试中明确说明元素 ID,这将使我们的代码容易受到 UI 更改的影响:我们必须在使用这些元素的每个测试中更新所有已更改的 ID。
页面对象模式有助于避免这种情况。页面对象模式的理念是将页面(应用程序屏幕)作为一个对象(测试抽象)来呈现,该对象会公布和初始化页面上的所有图形元素,并设置与它们的交互。有关该模式的详细信息可以在此处了解(https://kasperskylab.github.io/Kaspresso/en/Wiki/Page_object_in_Kaspresso/)。
本文中的所有示例都使用我们的开源测试自动化框架 Kaspresso。(https://github.com/KasperskyLab/Kaspresso)为什么不使用Espresso?
首先,Kaspresso使用声明式方法编写测试,这种方法依赖于Kakao,它是Espresso的Kotlin DSL封装器。下面是一个例子:
Espresso
@Test
fun testFirstFeature() {
onView(withId(R.id.toFirstFeature))
.check(ViewAssertions.matches(
ViewMatchers.withEffectiveVisibility(
ViewMatchers.Visibility.VISIBLE)))
onView(withId(R.id.toFirstFeature)).perform(click())
}
Kaspresso
@Test
fun testFirstFeature() {
MainScreen {
toFirstFeatureButton {
isVisible()
click()
}
}
}
其次,在拦截器的帮助下,Kaspresso 避免了测试的不稳定性,从而提高了稳定性。这些拦截器在我们处理异步图形元素或列表时特别有用。
第三,Kaspresso集成了KAutomator,这是一个方便的Kotlin DSL封装器,可用于UI Automator,从而加快UI测试的速度。下面是标准版(右)和加速版(左)UI Automator之间的区别:
除此之外,Kaspresso 允许将测试分解为步骤,类似于手动测试用例的完成方式,并记录每个步骤。如果测试崩溃,日志将帮助你立即查看哪些步骤成功完成,哪些步骤失败。除了日志之外,你还可以访问图形元素的层次结构以及视频、屏幕截图等。Kaspresso 内置的 Android 调试桥 (adb) 支持将帮助你直接使用 Android。Allure集成可清晰显示测试结果。
那么,让我们开始讨论正题。你可以通过下载项目源代码并运行它来重现下面描述的所有步骤。我们将描述 MainActivity 页面并自动化 LoginActivity 测试。结果以及测试可在TECH-tutorial-results分支中找到,因此你可以随时前往那里查看完成的代码。
MainActivity 看起来像这样:
我们创建一个继承自 KScreen 的 MainScreen 对象:
object MainScreen : KScreen<MainScreen>() {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
}
KScreen 实现了页面对象模式,它描述了与测试交互的所有视图元素。
Kaspresso 中的页面对象实现以 layoutId 和 viewClass 变量而闻名,它们可以帮助开发人员立即识别哪个布局文件用于相关页面以及哪个类提供其功能。但手头的任务是讨论页面对象概念本身,因此我们现在将它们设置为 null。
我们使用 Android Studio 中的 UI Automator Viewer 或 Layout Inspector 来查找登录活动按钮的 ID。页面上其余视图元素的标识符可以类似地找到。
主屏幕元素的描述如下所示:
object MainScreen : KScreen<MainScreen>() {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
val titleTextView = KTextView { withId(R.id.title) }
val simpleActivityButton = KButton { withId(R.id.simple_activity_btn) }
val wifiActivityButton = KButton { withId(R.id.wifi_activity_btn) }
val notificationActivityButton = KButton { withId(R.id.notification_activity_btn) }
val loginActivityButton = KButton { withId(R.id.login_activity_btn) }
val makeCallActivityButton = KButton { withId(R.id.make_call_activity_btn) }
val flakyActivityButton = KButton { withId(R.id.flaky_activity_btn) }
val listActivityButton = KButton { withId(R.id.list_activity_btn) }
}
现在,我们可以从我们创建的任何测试中引用 MainScreen 对象,并使用此页面的视图元素。
让我们编写第一个测试,它将检查页面上是否有“登录活动”按钮并单击它。
为此,我们创建一个继承自 TestCase 的 LoginActivityTest 类:
class LoginActivityTest : TestCase() {
/**
* activityScenarioRule is used to invoke MainActivity before running the test.
* More details on activityScenarioRule are available here:
* https://developer.android.com/reference/androidx/test/ext/junit/rules/ActivityScenarioRule
*/
@get:Rule
val activityRule = activityScenarioRule<MainActivity>()
@Test
fun test() {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
}
...并创建登录屏幕:
object LoginScreen : KScreen<LoginScreen>() {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
val usernameEditText = KEditText { withId(R.id.input_username) }
val passwordEditText = KEditText { withId(R.id.input_password) }
val loginButton = KButton { withId(R.id.login_btn) }
}
让我们修改 LoginActivityTest 并尝试使用登录名“123456”和密码“123456”获得授权:
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule<MainActivity>()
@Test
fun test() {
val username = "123456"
val password = "123456"
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
}
授权后,我们会看到最后一个页面 AfterLoginActivity。
Kaspresso 可以使用Device类从测试内部检查正在显示的活动。我们通过检查授权后设备屏幕上是否出现 AfterLoginActivity 来结束第一个测试:
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule<MainActivity>()
@Test
fun test() {
val username = "123456"
val password = "123456"
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
device.activities.isCurrent(AfterLoginActivity::class.java)
}
}
这种方法使得动态了解哪些测试字符串与哪些页面交互变得更加困难。添加新的检查和操作可能会使代码难以辨认。因此,我们建议使用页面对象来创建高质量的可扩展测试。
将测试分为几个步骤
任何测试,无论是自动测试还是手动测试,都要遵循一个测试用例--也就是说,测试人员要检查一连串的步骤,以确定页面是否功能齐全。在 step() 函数的帮助下,Kaspresso 将代码分解成多个步骤。步骤还有助于整理测试日志。
要使用步骤,需要在测试中调用 run{} 方法,并在大括号中列出测试中要运行的所有步骤。每个步骤都应在 step() 函数中调用。
让我们试一下:
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule<MainActivity>()
@Test
fun test() {
run {
val username = "123456"
val password = "123456"
step("Open login screen") {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
step("Try to login") {
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
step("Check current screen") {
device.activities.isCurrent(AfterLoginActivity::class.java)
}
}
}
}
通过这些步骤,标记为“KASPRESSO”的信息级日志如下所示:
如果你对步骤仍有疑问,建议你阅读这些(https://kasperskylab.github.io/Kaspresso/en/Tutorial/Steps_and_sections/)。它还提供了你可能在日志中注意到的之前/之后部分的详细信息。
现在,让我们尝试实施负面测试用例,例如用户输入的登录名或密码少于最小字符数(6 个)。
在创建一组自动测试时,应遵循的规则是为每个测试用例设置一个单独的测试方法。换句话说,我们不会在同一个方法中测试输入无效登录名或密码时的行为,而是在 LoginActivityTest 类中创建单独的方法:
@Test
fun loginUnsuccessfulIfUsernameIncorrect() {
run {
val username = "12"
val password = "123456"
step("Open login screen") {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
step("Try to login") {
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
step("Check current screen") {
device.activities.isCurrent(LoginActivity::class.java)
}
}
}
另一个测试,使用有效的登录名和无效的密码:
@Test
fun loginUnsuccessfulIfPasswordIncorrect() {
run {
val username = "123456"
val password = "1234"
step("Open login screen") {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
step("Try to login") {
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
step("Check current screen") {
device.activities.isCurrent(LoginActivity::class.java)
}
}
}
我建议你在执行第一个测试时重命名它,以便其名称显示我们仅检查是否成功授权。
@Test
fun test()
我们将其更改为:
@Test
fun loginSuccessfulIfUsernameAndPasswordCorrect()
你可能已经注意到,在上面的自动化测试中,用于导航到 LoginActivity 页面并输入登录凭据的字符串会重复。如果能重复使用这些步骤就好了。
使用Scenario
Kaspresso 包含一个名为 Scenario 的工具,它允许将多个步骤组合成有序的操作序列。这在编写重复步骤的测试时非常有用。
让我们创建一个继承自 Scenario 的 LoginScenario 类。为了使其工作,我们需要重写 steps 属性以列出Scenario中的所有步骤。
class LoginScenario : Scenario() {
override val steps: TestContext<Unit>.() -> Unit = {
step("Open login screen") {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
step("Try to login") {
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
}
}
class LoginScenario : Scenario()
更改为:
class LoginScenario(
private val username: String,
private val password: String
) : Scenario()
这是生成的Scenario代码:
class LoginScenario(
private val username: String,
private val password: String
) : Scenario() {
override val steps: TestContext<Unit>.() -> Unit = {
step("Open login screen") {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
step("Try to login") {
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
}
}
让我们在 LoginActivityTest 测试中使用此Scenario:
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule<MainActivity>()
@Test
fun loginSuccessfulIfUsernameAndPasswordCorrect() {
run {
step("Try to login with correct username and password") {
scenario(
LoginScenario(
username = "123456",
password = "123456",
)
)
}
step("Check current screen") {
device.activities.isCurrent(AfterLoginActivity::class.java)
}
}
}
@Test
fun loginUnsuccessfulIfUsernameIncorrect() {
run {
step("Try to login with incorrect username") {
scenario(
LoginScenario(
username = "12",
password = "123456",
)
)
}
step("Check current screen") {
device.activities.isCurrent(LoginActivity::class.java)
}
}
}
@Test
fun loginUnsuccessfulIfPasswordIncorrect() {
run {
step("Try to login with incorrect password") {
scenario(
LoginScenario(
username = "123456",
password = "1234",
)
)
}
step("Check current screen") {
device.activities.isCurrent(LoginActivity::class.java)
}
}
}
}
我们研究了一种有利于使用Scenario的案例——在同一页面的不同测试中重复使用相同的步骤。然而,这并不是Scenario的唯一目的。
一个应用程序可以有多个页面,你只能以授权用户的身份访问这些页面。然后,你需要重新描述每个页面的授权步骤。但是,如果你使用Scenario,这将变得非常简单。
目前,AfterLoginActivity 页面在我们登录后打开。让我们为该屏幕编写一个测试。
首先我们创建一个页面对象:
object AfterLoginScreen : KScreen<AfterLoginScreen>() {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
val title = KTextView { withId(R.id.title) }
}
然后我们添加测试:
class AfterLoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule<MainActivity>()
@Test
fun test() {
}
}
我们需要获得授权才能访问该页面。如果没有Scenario,我们将不得不再次重新运行所有步骤:打开主页,单击按钮,输入登录名和密码,然后再次单击按钮。整个过程现在简化为使用 LoginScenario:
class AfterLoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule<MainActivity>()
@Test
fun test() {
run {
step("Open AfterLogin screen") {
scenario(
LoginScenario(
username = "123456",
password = "123456"
)
)
}
step("Check title") {
AfterLoginScreen {
title {
isVisible()
}
}
}
}
}
}
总而言之,使用Scenario使代码干净、清晰且可重用。如果你想要测试仅授权用户可以访问的页面,则无需再重复大量相同的步骤。重要的是,我们还实现了适当的测试可扩展性。如果 LoginActivity 页面上的 UI 元素的标识符发生更改,则不需要更新测试代码。要使测试再次正常工作,你所需要做的就是修复 LoginScreen。
作为对比,这里是没有以上最佳实践的测试代码。我希望你能像一场噩梦一样忘记这种写作风格。
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule<MainActivity>()
@Test
fun test() {
val loginActivityButton = KButton { withId(R.id.login_activity_btn) }
loginActivityButton {
isVisible()
click()
}
val usernameEditText = KEditText { withId(R.id.input_username) }
val passwordEditText = KEditText { withId(R.id.input_password) }
val loginButton = KButton { withId(R.id.login_btn) }
usernameEditText { replaceText("123456") }
passwordEditText { replaceText("123456") }
loginButton { click() }
device.activities.isCurrent(AfterLoginActivity::class.java)
pressBack()
usernameEditText { replaceText("123456") }
passwordEditText { replaceText("1234") }
loginButton { click() }
device.activities.isCurrent(LoginActivity::class.java)
usernameEditText { replaceText("12") }
passwordEditText { replaceText("123456") }
loginButton { click() }
device.activities.isCurrent(LoginActivity::class.java)
}
}
Kaspresso 框架相关链接:
https://github.com/KasperskyLab/Kaspresso
https://kasperskylab.github.io/Kaspresso/en
感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:
这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!有需要的小伙伴可以点击下方小卡片领取