02 自动化测试:从0开始为一个特性覆盖自动化测试
你好,我是黄俊彬。今天我们一起来学习自动化测试的知识,我将通过一个示例给你系统介绍各种自动化测试的应用场景、需要使用的框架以及具体的用例设计和编写。
我相信此时你会有一些疑问:我为什么要学自动化测试,它能给我带来什么帮助呢?在过去很多的咨询项目中,我发现自动化测试是一个很容易产生“争议”的话题,我也经常会被问到一些很有意思的问题。
- 自动化测试不是应该由测试同学来编写吗,我作为开发没有必要学吧?
- 之前一个自动化测试都没写过,怎么开始落地呢?
- 编写自动化测试代码意味着要写更多的代码,这能带来什么好处呢?
根据我过往的经验,对自动化测试存在类似疑问的人,其实往往是那些一个测试都没有写过的同学。所以接下来,我们就针对一个特性,从0开始一步一步覆盖自动化测试。在这个过程中,你将深入感受到自动化测试的“魅力”(课程的配套代码,你可以从这里获取)。
示例介绍
今天这个示例是一个登录的场景。当用户在登录页面输入正确的账户和密码时,能正常跳转到登录界面,否则提示登录失败的信息。下面是关键的代码。
- 登录页面代码
public class LoginActivity extends AppCompatActivity {
private LoginLogic loginLogic = new LoginLogic();
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
final EditText usernameEditText = findViewById(R.id.username);
final EditText passwordEditText = findViewById(R.id.password);
final Button loginButton = findViewById(R.id.login);
loginButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
boolean success = loginLogic.login(LoginActivity.this,usernameEditText.getText().toString(),
passwordEditText.getText().toString());
if (success) {
//登录成功跳转主界面
startActivity(new Intent(LoginActivity.this, MainActivity.class));
} else {
//登录失败进行提示
Toast.makeText(LoginActivity.this, "login failed", Toast.LENGTH_LONG).show();
}
}
});
}
}
- 登录逻辑代码
public class LoginLogic {
public boolean login(Context context,String username, String password) {
if (!isUserNameValid(username) || !isPasswordValid(password)) {
return false;
} else {
//通过服务器判断账户及密码的有效性
boolean result = checkFromServer(username, password);
if (result) {
//登录成功保持本地的信息
SharedPreferencesUtils.put(context, username, password);
}
return result;
}
}
// 为了进行演示,去除通过服务器鉴定的逻辑,当用户输入特定账号及密码为时则验证成功
private static boolean checkFromServer(String username, String password) {
if (username.equals("123@163.com") && password.equals("123456")) {
return true;
}
return false;
}
private boolean isUserNameValid(String username) {
if (username == null) {
return false;
}
if (username.contains("@")) {
return Patterns.EMAIL_ADDRESS.matcher(username).matches();
} else {
return !username.trim().isEmpty();
}
}
private boolean isPasswordValid(String password) {
return password != null && password.trim().length() > 5;
}
}
注意,这里为了简化演示,我将验证的逻辑写死在本地了。另外,账户密码有两个核心的验证规则。
- 账户不能为空,需要符合邮箱规则。
- 密码不能为空,长度需要超过5个字符。
搭建测试环境
当我们通过默认的编辑器创建新的项目工程时,编辑器会自动创建好测试的运行配置,一般无需修改。如果要增加测试框架,就把测试框架的Maven坐标添加到对应的dependencies中即可。Gradle中的测试相关配置代码是后面这样。
android{
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
}
接下来,我们在默认的/src/test或/src/androidTest目录下编写用例。注意,test目录的用例运行不依赖于设备,androidTest目录下的用例运行需要依赖设备。
一般来说,自动化测试分为小型、中型和大型三种,接下来我会带你从小型自动化测试开始,逐个看看这三种测试怎么落地。
小型自动化测试实践
小型测试是指单元测试,用于验证应用的行为,一次验证一个类。在这个示例中,LoginLogic主要承担的是登录逻辑,这里我就以账户密码的验证逻辑为例,给你演示一下小型测试的编写,这两个逻辑的主要规则是这样。
- 账户不能为空,需要符合邮箱规则。
- 密码不能为空,长度需要超过5个字符。
接着,我们设计对应的测试用例。注意,用例的设计应该包含正常和异常的验证场景,具体的测试场景是后面这样。
- 输入大于6个字符长度的密码,验证成功。
- 输入为Null的字符,验证失败。
- 输入小于5个字符长度的密码,验证失败。
- 输入等于5个字符长度的密码,验证失败。
下面我们新建一个LoginLogicTest的测试类,按照上述的测试场景编写用例。这里我会采用given(输入)、when(执行)、then(结果)的形式,让用例更加结构化,便于理解和维护。具体的测试用例代码如下。
public class LoginLogicTest {
@Test
public void should_return_false_when_password_is_null() {
LoginLogic loginLogic = new LoginLogic();
String password = null;
boolean result = loginLogic.isPasswordValid(password);
Assert.assertFalse(result);
}
@Test
public void should_return_false_when_password_length_is_less_than_5() {
LoginLogic loginLogic = new LoginLogic();
String password = "1234";
boolean result = loginLogic.isPasswordValid(password);
Assert.assertFalse(result);
}
@Test
public void should_return_false_when_password_length_is_equal_5() {
LoginLogic loginLogic = new LoginLogic();
String password = "12345";
boolean result = loginLogic.isPasswordValid(password);
Assert.assertFalse(result);
}
@Test
public void should_return_true_when_password_length_greater_than_5() {
LoginLogic loginLogic = new LoginLogic();
String password = "123456";
boolean result = loginLogic.isPasswordValid(password);
Assert.assertTrue(result);
}
}
通过点击用例旁的运行箭头可以执行用例,如下图所示。
运行完就可以直接查看运行结果了,如下图所示。
可以看出小型测试的执行时间还是比较快的,4个用例总共用了7 ms。
此外,我们还可以用另一种方式执行测试用例:使用命令行./gradlew test,运行test目录下的测试用例,如下图所示。
执行完测试后,我们在/build/reports/tests下可以查看到对应的测试报告,报告截图如下所示,从中我们能得到每个用例具体的执行情况和执行时间。
中型自动化测试实践
接下来是中型测试,中型测试是指集成测试,用于验证模块内堆栈级别之间的互动或相关模块之间的互动。常用的测试框架有两种:Robolectric和Espresso。
在登录示例中,当LoginLogic的login方法被调用时,程序主逻辑首先会执行对账户名和密码的校验,接着通过服务器对账户密码的有效性做校验。当登录成功时,程序主逻辑会通过SharedPreferences保存用户的信息,并在最后返回登录的状态。
从示例代码中可以看出,LoginActivity类主要都是UI的操作,所以对该类主要覆盖的是UI相关的测试;对于LoginLogic的类核心方法,login主要负责整体的业务验证逻辑。下面我会依次介绍如何通过Espresso和Robolectric对这两个类进行中型自动化测试的覆盖。
1. Espresso的使用
Espresso 是Google官方提供的界面测试框架,使用简洁且可靠。它可以声明预期、交互和断言,不用直接访问底层应用的 Activity 和视图,可以防止测试不稳定,提高测试运行的速度。
根据用户UI上的主要操作,我们将覆盖以下两个主要的业务场景。
- 用户输入正确的用户名(123@163.com)和密码(123456),点击登录按钮能成功跳转到登录界面。
- 用户输入错误的用户名(123)和密码(456),点击登录按钮提示登录失败的Toast。
根据测试场景,我们使用Espresso对LoginActivity设计的测试用例代码如下。
public class LoginActivityTest {
@Test
public void should_start_main_activity_when_execute_login_given_valid_username_and_password() {
ActivityScenario.launch(LoginActivity.class);
onView(withId(R.id.username)).perform(typeText("123@163.com"));
onView(withId(R.id.password)).perform(typeText("123456"));
Intents.init();
onView(withId(R.id.login)).perform(click());
intended(allOf(
toPackage("com.jkb.junbin.autotestdemo"),
hasComponent(hasClassName(MainActivity.class.getName()))));
}
@Test
public void should_show_failed_toast_when_execute_login_given_invalid_username_and_password() {
ActivityScenario<LoginActivity> launch = ActivityScenario.launch(LoginActivity.class);
onView(withId(R.id.username)).perform(typeText("123"));
onView(withId(R.id.password)).perform(typeText("456"));
onView(withId(R.id.login)).perform(click());
View decorView = null;
launch.onActivity(activity -> {
activity.getWindow().getDecorView();
});
onView(withText("login failed")).inRoot(withDecorView(not(decorView))).check(matches(isDisplayed()));
}
}
Espresso提供的API能方便地进行元素的定位、执行操作和断言。在上述两个用例中,我们用onView定位元素,用perform执行操作,用check进行断言。如果你想了解Espresso更多的操作API,可以参考官网的介绍。
执行完上述用例后,运行结果是下图这样。
可以看到,这两个测试用例在模拟器中整体的运行时间在5s左右。相比小型测试,中型测试耗时会更长。并且用例运行需要依赖设备,这让运行测试的成本更高。用例的执行过程如下图所示。
2. Robolectric的使用
Robolectric 框架能为Android带来快速可靠的测试。具体来说,依赖该框架的测试用例无须在真机或者模拟器上运行,在本地工作站上的 JVM 内完成运行即可,一般只需要几秒。
下面以LoginLogic的login方法为例,我给你介绍一下Robolectric的使用。这里我们需要覆盖下面三个主要的业务场景。
- 传入空的字符串或者密码,返回失败。
- 传入错误的账户及密码,返回失败。
- 传入正确的账户及密码,返回成功,并且进行数据缓存。
后面是测试用例的代码。
@RunWith(RobolectricTestRunner.class)
public class LoginLoginMediumTest {
private final Context mContext = InstrumentationRegistry.getInstrumentation().getContext();
@Test
public void should_return_false_when_given_invalid_username_or_password() {
LoginLogic loginLogic = new LoginLogic();
boolean nullUserNameResult = loginLogic.login(mContext, null, "123");
Assert.assertFalse(nullUserNameResult);
boolean nullPasswordResult = loginLogic.login(mContext, "123", null);
Assert.assertFalse(nullPasswordResult);
}
@Test
public void should_return_false_when_given_error_username_and_password() {
//验证错误的账户及密码
boolean result = new LoginLogic().login(mContext, "123", "456");
Assert.assertFalse(result);
}
@Test
public void should_return_true_when_given_correct_username_and_password() {
String username = "123@163.com";
String password = "123456";
//验证正确的账户及密码
boolean result = new LoginLogic().login(mContext, username, password);
Assert.assertTrue(result);
//验证存在缓存信息
String cachePassword = (String) SharedPreferencesUtils.get(mContext, username, "");
Assert.assertEquals(password, cachePassword);
}
}
关于Robolectric的API,你可以参考官网的介绍,这里你了解一下即可,我会在后续的解耦重构篇中进一步介绍Robolectric的更多用法。
执行上述测试用例后,运行结果如下图。
从结果可以看出,用Robolectric框架进行测试,用例的执行时间在毫秒到秒之间。其中第二个用例的执行耗时超过2s,是因为启动Robolectric框架需要一定的时间。同时我们也能体会到Robolectric的核心优势:无须依赖设备,可以快速在本地的JVM进行验证,得到快速的反馈。
大型自动化测试实践
最后我们来进行大型测试。大型测试是指端到端测试,用于验证跨越了应用的多个模块的用户操作流程。前面介绍的Espresso和Robolectric主要是针对单个页面的测试场景,在实际的应用业务场景中,还有涉及跨应用和系统UI交互的场景。
通常我们会用UI Automator完成大型测试。UI Automator 是一个界面测试框架,适用于整个系统和多个已安装应用间的跨应用功能界面测试。它提供了一组 API,用于构建在用户应用和系统应用上执行交互的界面测试。
通过 UI Automator API,我们可以在测试设备中执行打开“设置”菜单或应用启动器等操作。相比Espresso和Robolectric编写的白盒测试用例,UI Automator 测试框架非常适合编写黑盒式自动化测试,此类测试的测试代码不依赖于目标应用的内部实现细节。
下面我们继续对登录示例进行完整的功能测试。完整的用户测试场景是这样的:用户在手机的任意界面,返回桌面启动测试的应用登录界面,然后输入正确的用户名和密码,成功跳转到主界面,验证主界面上显示的用户信息是否正确。
这个用户场景会涉及到多个应用,其中包括了桌面和目标测试应用。另外,应用内还会涉及到多个页面,主要是登录界面和主界面。现在我们使用UI Automator框架进行测试,用例代码是这样。
@RunWith(AndroidJUnit4.class)
public class SmellTest {
private static final String BASIC_SAMPLE_PACKAGE
= "com.example.sample";
private static final int LAUNCH_TIMEOUT = 5000;
private UiDevice mDevice;
@Before
public void startActivityFromHomeScreen() {
// 初始化UiDevice
mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
// 回到主界面
mDevice.pressHome();
// 等待launcher
final String launcherPackage = mDevice.getLauncherPackageName();
assertThat(launcherPackage, notNullValue());
mDevice.wait(Until.hasObject(By.pkg(launcherPackage).depth(0)),
LAUNCH_TIMEOUT);
// 启动目标APP
Context context = ApplicationProvider.getApplicationContext();
final Intent intent = context.getPackageManager()
.getLaunchIntentForPackage(BASIC_SAMPLE_PACKAGE);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
context.startActivity(intent);
// 等待应用启动
mDevice.wait(Until.hasObject(By.pkg(BASIC_SAMPLE_PACKAGE).depth(0)),
LAUNCH_TIMEOUT);
}
//账户密码登录成功后主界面显示用户名
@Test
public void should_show_username_in_main_activity_when_login_success() {
//输入账户名
mDevice.findObject(By.res(BASIC_SAMPLE_PACKAGE, "username"))
.setText("123");
//输入密码
mDevice.findObject(By.res(BASIC_SAMPLE_PACKAGE, "password"))
.setText("123");
//点击登录
mDevice.findObject(By.res(BASIC_SAMPLE_PACKAGE, "login"))
.click();
//验证主界面上显示用户名信息
UiObject2 text = mDevice
.wait(Until.findObject(By.res(BASIC_SAMPLE_PACKAGE, "text")),
500);
assertEquals(text.getText(), "123");
}
}
UI Automator 测试框架提供了一个 UiDevice 类,用于在运行目标应用的设备上访问和执行操作。通过调用findObject方法,我们可以定位到元素和执行操作。关于 UI Automator更多的API使用,你可以参考官网文档 。
执行上述测试用例后,运行结果如下图所示。
下面这个动图展示了用例的执行过程,你可以对比一下,这个过程如果用手工执行需要多久。
通过执行结果可知,该用例的执行时间实际为9s,比中小型测试的执行时间更长,并且需要依赖真机或模拟器。不过,该用例基本都是模拟用户对界面的点击操作,更贴近实际用户的真实使用场景。
总结
今天我带你对一个登录示例进行了小、中、大三种类型的自动化测试覆盖。小型测试能够快速帮我们验证代码中的核心逻辑和算法,我们通常使用的是Junit或者Robolectric等测试框架;中型测试能够帮我们验证代码中的一些核心组件交互流程,我们通常会用Espresso或者Robolectric等框架来完成;大型测试则能帮我们验证端到端的用户使用场景,我们通常使用的是UIAutomator或者Appium等框架。
现在让我们再重新思考一下这节课开头的三个问题。
第一个是测试由谁来写的问题。通过这节课的演示,我想你应该发现了,中小型的测试大部分都是根据代码设计来编写的。编写者需要了解原来代码的设计,精确到各个方法以及方法内部的条件分支和异常处理。所以,中小型测试应该由开发人员来编写。
另外,我也鼓励开发人员参与到大型端到端自动化测试的编写中。因为只有开发代码和测试代码一起共同维护,成本才是最低的。
第二个问题是:之前一个自动化测试都没写过,怎么开始落地?这节课我们通过一个特性介绍了如何从0去覆盖小、中、大型的自动化测试,相信你已经体会到,对于开发人员来说,编写自动化测试用例的难度其实比功能开发设计还简单。
第三个问题是关于自动化测试价值的问题。这里有一个前提:我不认为开发完代码就意味着结束,结束应该是在有足够的质量保证的前提下。所以,自动化测试是应用开发过程中不可或缺的一部分。通过持续运行测试,我们可以在发布版本之前验证其正确性、功能行为和易用性。
具体来讲,自动化测试给开发同学带来的帮助有这样三点。
1.自动化测试能提供多样化的编译调试。通常测试的运行时间在毫秒至秒之间,有助于提高我们编译调试的效率。
2.自动化测试能加强开发代码自测,帮我们快速获得故障反馈。通常我们在本地编写完代码后,就可以马上运行测试,检查功能是否正确。这样的好处是能在开发早期尽早发现问题。
3.自动化测试还能提供更安全的代码重构,当有了自动化测试这个安全守护网,我们可以放心地优化代码,不必担心引发新的问题,也可以尽可能避免其他人乱改代码破坏原有的逻辑。因为一旦有修改破坏了之前的自动化测试用例,CI门禁就会立即检查出来,避免代码合入。
虽然自动化测试可以帮我们提升开发的效率和质量,但对于遗留系统来说,还有另外一个非常棘手的问题,那就是代码可测试性低。下节课我将为你揭晓如何提高遗留系统的代码可测试性。
思考题
感谢你学完了今天的内容,今天的思考题是这样的:在登录示例中,验证邮箱的逻辑并没有覆盖自动化测试,如果是你,你会怎么来设计这个测试用例呢?Show me your code。
欢迎你在留言区与我交流讨论,也欢迎你把它分享给你的同事或朋友,我们一起来高效、高质量交付软件!
- peter 👍(4) 💬(1)
今天刚看到这个课程,马上购买了,一口气读到了这里,每一课都有问题,集中在这里提问。 老师的课很不错。 Q1:高版本不支持ButterKnife吗? 我用AS3.5创建了一个项目,API版本是32,引用了ButterKnife,结果报错,网上搜索后在build.gradle中指定了用Java8来编译,但还是报错,而且错误原因难以理解。后来把版本降低到28,就可以了。请问,28以上的高版本不支持ButterKnife了吗?(注:第一次加入的是:compileOptions {sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8}) Q2:版本管理和分支管理有什么区别? “工程化”部分,有两个子项“版本管理”和“分支管理”,我印象中版本管理包括分支管理啊,现在分为两个部分,有什么区别? Q3:“故事”是笔误吗? 第01课中多次用到“故事”,比如,“故事平均开发周期”等。感觉应该是“事故”? Q4:androidTest 目录下的用例运行需要依赖设备,真机或模拟器都可以吗? Q5:IDE应该是用AS吧。哪个版本?AS2021吗? Q6:在哪里执行命令行./gradlew test? 我在AS3.5的“terminal”窗口中,当前项目路径下,输入“AutoTest ./gradlew test”和“./gradlew test”,都不行,提示“'AutoTest' 不是内部或外部命令,”。
2023-02-14 - 布魯斯~ 👍(1) 💬(1)
感谢老师的讲解,想请问一下老师,在现实生活上,许多登入是透过OAuth 2.0 去实现的,想请问一下老师,针对这种场景,要如何撰写自动化的大型测试吗?
2023-03-18 - 余一 👍(0) 💬(1)
老师,为啥我的测试类测不了protected修饰的isPasswordValid方法?明明加了@VisibleForTesting注解😢
2023-07-08 - le bonheur 👍(0) 💬(1)
从上家公司开始要求跟学习写单元测试,开始爱上单元测试.终于看到了一篇比较综合写单元测试的文章.太高兴了
2023-03-28 - Geek_6f0f96 👍(0) 💬(1)
这样做主要收益是回归流程嘛?因为感觉大多数需求如果写测试用例,可能比需求开发时间还多
2023-02-28 - 李鑫鑫 👍(0) 💬(1)
抢了测试同学的工作了!
2023-02-16 - wangzhen666 👍(0) 💬(2)
LoginActivityTest的should_show_failed_toast_when_execute_login_given_invalid_username_and_password()没有通过
2023-02-16 - wangzhen666 👍(0) 💬(1)
老师方便的话把代码传一下吧~
2023-02-15 - 晓晓 👍(0) 💬(1)
受益匪浅,期待更新!
2023-02-14 - 蓝啼儿 👍(0) 💬(0)
这种功能测试,写用例就太浪费时间了,有这时间,人工都测试N遍了
2024-06-29 - aoe 👍(0) 💬(0)
感谢老师分享了这么多自动化测试的工具,对构建一个良好的 App 信心倍增。 我在官网的入门教程中学到的是 AndroidJUnitRunner https://developer.android.com/training/testing/instrumented-tests/androidx-test-libraries/runner,这个应该属于大型测试工具 分享一门提高测试技能的专栏「徐昊 · TDD项目实战70讲」 跟着老师学习,让我的入门项目「https://github.com/aoeai/aoeai-qigua-android」变强变大
2024-04-03