Android 单元测试的演变
零版本:没有单元测试
第一个版本:我的第一个单元测试
了解一些关于 JUnit 框架的知识
从业务逻辑中抽象出Android
模拟
第一个单元测试
第二版:代码清理
第三版:模拟库
第四个版本:Kotlin
第五版:Spek
奖金部分
在本文中,我们将了解单元测试从入门到精通的演变过程。“评分对话框”是一项非常受欢迎的功能,它将是一个很好的例子。典型的“评分对话框”有如下要求:
- 应在某些条件或用户操作后显示
- 有“评价我们”按钮,可跳转至 Google Play
- 有“稍后提醒我”按钮,可安排对话框在一段时间后显示(在我们的例子中是 2 个月)
- 有“不再显示”按钮,可以永久隐藏对话框
零版本:没有单元测试
在 Android 社区初期,单元测试并不那么流行。这背后有以下几点原因:
- 测试需要时间来实施和维护
- 应用程序大多很简单
- 框架本身不适合编写单元测试
第一个版本:我的第一个单元测试
但不幸的是,“评价”对话框太重要了,不能忽略单元测试。用户留下的评价越多,你的应用就能吸引越多的新用户。而且,它不应该让人觉得烦人。否则,人们就会留下一星评价。
我实现了一个示例应用,它遵循了简介中描述的逻辑。要触发对话框,用户需要点击按钮两次。请查看代码库中的代码。
为了编写第一个单元测试,我必须做几件事。
了解一些关于 JUnit 框架的知识
如果您熟悉 JUnit,请跳过本章。
JUnit 是 Java 中最流行的测试框架。它默认包含在所有新 Android 项目的依赖项中:
dependencies {
testImplementation 'junit:junit:4.12'
}
要编写测试,你应该在默认创建的 test 文件夹中创建一个包含 ExampleUnitTest.java 的类。通常,如果开发人员测试 SomeClass ,开发人员会将测试类命名为 SomeClassTest 。而且,大多数情况下,它们都属于同一个 Java 包。
让我们看一个简单的测试。你应该使用 org.junit.Test 注释所有测试方法。Android Studio 将自动显示“运行测试”按钮。如果参数不相等,assertEquals 将抛出异常。如果测试结束时没有任何异常,JUnit 将标记测试通过。
从业务逻辑中抽象出Android
在 Android 中,你无法为使用 Android 框架的类编写单元测试。但是等等……什么???
是的,该框架需要特定的环境,并且您无法在任何 JVM 上运行它。在单元测试中,所有 Android 类都会被模拟为抛出异常。我们能做的最好的事情就是强制它返回默认值而不是抛出异常。
// In application module build.gradle
android.testOptions {
unitTests.returnDefaultValues = true
}
回到对话框,我们当然会用到 Activity/Fragment、View、Dialog 等 Android 类来实现“评分”功能。因此,我们编写单元测试需要付出一些努力。
首先,我为每个 Android 依赖项创建了一个界面,用于“评价我们”显示逻辑。
interface BuyPreferences {
fun incrementBuyCount()
fun getBuyCount(): Int
}
class BuyPreferencesImpl(context: Context) : BuyPreferences {
// ...
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(...)
override fun incrementBuyCount() {
val count = getBuyCount()
sharedPreferences.edit().putInt(BUY_COUNT_KEY, count + 1).apply()
}
override fun getBuyCount() = sharedPreferences.getInt(BUY_COUNT_KEY, 0)
}
其次,我为每个不能直接在测试中使用的 Java 依赖项创建了一个接口。更具体地说,它是 System.currentTimeMillis() 。
interface Time {
fun getCurrentTimeMillis(): Long
}
class TimeImpl : Time {
override fun getCurrentTimeMillis() = System.currentTimeMillis()
}
最后,我将依赖倒置原则应用于ShowRateUsLogic。
class ShowRateUsLogic(
private val rateUsPreferences: RateUsPreferences,
private val buyPreferences: BuyPreferences,
private val time: Time
) {
fun shouldShowRateUs(): Boolean {
val timeFromLastShown = time.getCurrentTimeMillis() - rateUsPreferences.getLastShownTimeMillis()
return when {
// User doesn't want to see "rate us" again
rateUsPreferences.isNeverShownAgainClicked() -> false
// User already rated the app
rateUsPreferences.isRateNowClicked() -> false
// "Rate us" should be shown after 2 "buy" clicked
buyPreferences.getBuyCount() < 2 -> false
// Show "rate us" only first time or if passed two months since last shown time
timeFromLastShown < TimeUnit.DAYS.toMillis(60) -> false
else -> true
}
}
}
模拟
现在我们应该为单元测试编写模拟类。下面我将展示一个模拟类。你可以在这里查看所有模拟类。
public class BuyPreferencesMock implements BuyPreferences {
private int count;
@Override public void incrementBuyCount() {
++count;
}
@Override public int getBuyCount() {
return count;
}
}
第一个单元测试
经过努力,很高兴编写第一个单元测试:)
public class ShowRateUsLogicTest {
private RateUsPreferencesMock rateUsPreferences;
private BuyPreferencesMock buyPreferences;
private TimeMock time;
private ShowRateUsLogic showRateUsLogic;
@Test public void test1() {
rateUsPreferences = new RateUsPreferencesMock();
buyPreferences = new BuyPreferencesMock();
time = new TimeMock();
showRateUsLogic = new ShowRateUsLogic(rateUsPreferences, buyPreferences, time);
buyPreferences.incrementBuyCount();
time.setCurrentTimeMillis(new Date(2019, 6, 7).getTime());
Assert.assertFalse(showRateUsLogic.shouldShowRateUs());
}
}
说实话,确实不行。不过我用的是“我的第一个单元测试”的风格写的。我会在接下来的章节中修复这个问题。
第二版:代码清理
第一个单元测试很酷,但如果没人能理解,那就没用了。因此,我重构了测试类:
- setUp 方法带有 @Before 注解。它使得该方法在每个单元测试之前调用。我们将通用测试代码移至 setUp 方法中。
- 好的测试方法应该有一个有意义的名称。这样不仅方便阅读,还能在测试报告中清晰地呈现。我们将 test1 重命名为 onFirstCheckAndOneClickItShouldNotShow 。
- 我添加了一个更复杂的测试。测试名称太短,无法表达所有信息。因此,我们将在方法体中添加注释。
- 最后一步,我们将删除不必要的和错误的时间设置。
public class ShowRateUsLogicTest {
// property declaration is skipped
@Before public void setUp() {
rateUsPreferences = new RateUsPreferencesMock();
buyPreferences = new BuyPreferencesMock();
time = new TimeMock();
showRateUsLogic = new ShowRateUsLogic(rateUsPreferences, buyPreferences, time);
}
@Test public void onFirstCheckAndOneClickItShouldNotShow() {
buyPreferences.incrementBuyCount();
Assert.assertFalse(showRateUsLogic.shouldShowRateUs());
}
@Test public void onThreeClicksAndItShouldShow() {
// clicked three times
buyPreferences.incrementBuyCount();
buyPreferences.incrementBuyCount();
buyPreferences.incrementBuyCount();
// set first dialog show time
final Calendar calendar = Calendar.getInstance();
calendar.set(2019, Calendar.JULY, 7);
rateUsPreferences.setLastShownTimeMillis(calendar.getTimeInMillis());
// set current time to be 90 days after first show
time.setCurrentTimeMillis(calendar.getTimeInMillis() + TimeUnit.DAYS.toMillis(90));
Assert.assertTrue(showRateUsLogic.shouldShowRateUs());
}
}
第三版:模拟库
如上所述,我们不能在单元测试中使用 Android 类。但几乎所有类都这样做。为每个类创建一个接口、一个实现和一个模拟代码,这相当枯燥。
幸运的是,还有另一种方法。我们可以使用模拟库。
Mockito
直接上最常用的API:
- 使用 Mockito.mock(Class) 模拟任何接口或类
- 使用 Mockito.when(instance.method()).thenReturn(value) 模拟方法调用
- 使用 Mockito.verify(instance).method() 检查方法是否被调用
共享偏好设置模拟
在我的实践中,共享首选项类是业务逻辑中最常用的 Android 依赖项。因此,我实现了一个共享首选项模拟库,它模仿了 Android 的实现。现在,您只需额外添加一行代码,就可以在单元测试中使用共享首选项了 ;)
public class ShowRateUsLogicTest {
// property declaration is skipped
@Before public void setUp() {
final Context mockedContext = new SPMockBuilder().createContext();
rateUsPreferences = new RateUsPreferencesImpl(mockedContext);
buyPreferences = new BuyPreferencesImpl(mockedContext);
time = Mockito.mock(Time.class);
showRateUsLogic = new ShowRateUsLogic(rateUsPreferences, buyPreferences, time);
}
// first test code leaves the same
// second test code changed only in time mocking
@Test public void onThreeClicksAndItShouldShow() {
// ...
// set current time to be 90 days after first show
Mockito.when(time.getCurrentTimeMillis()).thenReturn(calendar.getTimeInMillis() + TimeUnit.DAYS.toMillis(90));
Assert.assertTrue(showRateUsLogic.shouldShowRateUs());
}
}
第四个版本:Kotlin
Kotlin 是一门优秀的 Android 开发语言。此外,它还能改进单元测试代码。
- 我们将使用反引号括起来的空格来重命名测试方法名称
- 我们将把通用的准备代码移到具有默认参数的函数中
- 我们将删除不必要的注释,因为我们可以使用命名参数
- 我们将使用 mockito-kotlin,因为它具有更惯用和紧凑的语法
class ShowRateUsLogicTest {
// property declaration and setup are skipped
private fun prepareConditions(
buyClickedTimes: Int = 0,
isNeverShownAgainClicked: Boolean = false,
isRateNowClicked: Boolean = false,
lastShownTimeMillis: Long = 0,
currentTimeMillis: Long = 0
) {
repeat(buyClickedTimes) { buyPreferences.incrementBuyCount() }
if (isNeverShownAgainClicked) rateUsPreferences.setNeverShownAgainClicked()
if (isRateNowClicked) rateUsPreferences.setRateNowClickedClicked()
rateUsPreferences.setLastShownTimeMillis(lastShownTimeMillis)
whenever(time.getCurrentTimeMillis()).thenReturn(currentTimeMillis)
}
@Test fun onFirstCheckAndOneClickItShouldNotShow() {
prepareConditions(buyClickedTimes = 1)
Assert.assertFalse(showRateUsLogic.shouldShowRateUs())
}
@Test fun onThreeClicksAndItShouldShow() {
prepareConditions(
buyClickedTimes = 3,
lastShownTimeMillis = SOME_DAY_IN_MILLIS,
currentTimeMillis = SOME_DAY_IN_MILLIS + MORE_THAN_TWO_MONTHS
)
Assert.assertTrue(showRateUsLogic.shouldShowRateUs())
}
}
第五版:Spek
Spek是 Kotlin 的单元测试框架,支持规范和 Gherkin 风格。
就我个人而言,Spek 的关键特性包括:
能够根据条件构建测试,
能够随时构建测试(因为测试代码是 lambda 表达式,而不是方法)。
我故意不描述语法,因为它很容易理解。如果你对 Spek 感兴趣,可以看看这个链接。
class ShowRateUsLogicTest : Spek({
// property declaration, setup and preparation are skipped
describe("show rate us logic") {
context("first conditions checks") {
context("buy clicked once") {
beforeEachTest {
prepareConditions(buyClickedTimes = 1)
}
it("should not show 'rate us'") {
Assert.assertFalse(showRateUsLogic.shouldShowRateUs())
}
}
context("buy clicked two times") {
beforeEachTest {
prepareConditions(buyClickedTimes = 2)
}
it("should show 'rate us'") {
Assert.assertTrue(showRateUsLogic.shouldShowRateUs())
}
}
}
context("'rate us' was shown already, and user clicked 'show me later' on the dialog") {
context("less than two months passed and user clicks buy") {
beforeEachTest {
prepareConditions(
buyClickedTimes = 3,
lastShownTimeMillis = SOME_DAY_IN_MILLIS,
currentTimeMillis = SOME_DAY_IN_MILLIS + LESS_THAN_TWO_MONTHS
)
}
it("should not show 'rate us' again") {
Assert.assertFalse(showRateUsLogic.shouldShowRateUs())
}
}
context("more than two months passed and user clicks buy") {
beforeEachTest {
prepareConditions(
buyClickedTimes = 3,
lastShownTimeMillis = SOME_DAY_IN_MILLIS,
currentTimeMillis = SOME_DAY_IN_MILLIS + MORE_THAN_TWO_MONTHS
)
}
it("should show 'rate us' again") {
Assert.assertTrue(showRateUsLogic.shouldShowRateUs())
}
}
}
}
})
此外,Spek 在 Android Studio 中生成结构化的测试报告。
奖金部分
这篇关于 Android 单元测试的文章如果没有提及一些内容,就不完整。如果您有更多链接值得提及,请在评论中分享,我会将它们添加到文章中。
机器人电工
Robolectric 是一个为 Android 带来快速可靠单元测试的框架。测试可在几秒钟内在工作站上的 JVM 中运行。
它并非纯粹的单元测试,但允许我们在无需启动设备或模拟器的情况下测试 Android API。另一方面,它的测试运行时间更长。
断言框架
“评价我们”对话框逻辑具有布尔返回值,因此我们使用了简单的 assertTrue 或 assertFalse。但对于更复杂的测试,默认断言的灵活性不足。
Hamcrest 是一个用于编写匹配器对象的框架,允许以声明方式定义“匹配”规则。
assertThat(Math.sqrt(-1), is(notANumber()))
AssertJ — 流畅的断言 Java 库。
assertThat(frodo.getName()).isEqualTo("Frodo")
Truth 使你的测试断言和失败消息更具可读性。与 AssertJ 类似,它原生支持多种 JDK 和 Guava 类型,并且可以扩展到其他类型。
assertThat(notificationText).contains("testuser@google.com")