Android 单元测试的演变 零版本:没有单元测试 第一个版本:我的第一个单元测试 学习一些关于 JUnit 框架的知识 从业务逻辑中抽象出 Android Mocks 第一个单元测试 第二个版本:代码清理 第三个版本:模拟库 第四个版本:Kotlin 第五个版本:Spek 奖励部分

2025-06-09

Android 单元测试的演变

零版本:没有单元测试

第一个版本:我的第一个单元测试

了解一些关于 JUnit 框架的知识

从业务逻辑中抽象出Android

模拟

第一个单元测试

第二版:代码清理

第三版:模拟库

第四个版本:Kotlin

第五版:Spek

奖金部分

在本文中,我们将了解单元测试从入门到精通的演变过程。“评分对话框”是一项非常受欢迎的功能,它将是一个很好的例子。典型的“评分对话框”有如下要求:

  • 应在某些条件或用户操作后显示
  • 有“评价我们”按钮,可跳转至 Google Play
  • 有“稍后提醒我”按钮,可安排对话框在一段时间后显示(在我们的例子中是 2 个月)
  • 有“不再显示”按钮,可以永久隐藏对话框

图片描述

零版本:没有单元测试

图片描述

在 Android 社​​区初期,单元测试并不那么流行。这背后有以下几点原因:

  • 测试需要时间来实施和维护
  • 应用程序大多很简单
  • 框架本身不适合编写单元测试

第一个版本:我的第一个单元测试

但不幸的是,“评价”对话框太重要了,不能忽略单元测试。用户留下的评价越多,你的应用就能吸引越多的新用户。而且,它不应该让人觉得烦人。否则,人们就会留下一星评价。

我实现了一个示例应用,它遵循了简介中描述的逻辑。要触发对话框,用户需要点击按钮两次。请查看代码库中的代码。

为了编写第一个单元测试,我必须做几件事。

了解一些关于 JUnit 框架的知识

如果您熟悉 JUnit,请跳过本章。

JUnit 是 Java 中最流行的测试框架。它默认包含在所有新 Android 项目的依赖项中:

dependencies {
    testImplementation 'junit:junit:4.12'
}
Enter fullscreen mode Exit fullscreen mode

要编写测试,你应该在默认创建的 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
}
Enter fullscreen mode Exit fullscreen mode

回到对话框,我们当然会用到 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)
}
Enter fullscreen mode Exit fullscreen mode

其次,我为每个不能直接在测试中使用的 Java 依赖项创建了一个接口。更具体地说,它是 System.currentTimeMillis() 。

interface Time {
    fun getCurrentTimeMillis(): Long
}

class TimeImpl : Time {
    override fun getCurrentTimeMillis() = System.currentTimeMillis()
}
Enter fullscreen mode Exit fullscreen mode

最后,我将依赖倒置原则应用于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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

模拟

现在我们应该为单元测试编写模拟类。下面我将展示一个模拟类。你可以在这里查看所有模拟类

public class BuyPreferencesMock implements BuyPreferences {
    private int count;

    @Override public void incrementBuyCount() {
        ++count;
    }

    @Override public int getBuyCount() {
        return count;
    }
}
Enter fullscreen mode Exit fullscreen mode

第一个单元测试

经过努力,很高兴编写第一个单元测试:)

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

图片描述

说实话,确实不行。不过我用的是“我的第一个单元测试”的风格写的。我会在接下来的章节中修复这个问题。

第二版:代码清理

第一个单元测试很酷,但如果没人能理解,那就没用了。因此,我重构了测试类:

  1. setUp 方法带有 @Before 注解。它使得该方法在每个单元测试之前调用。我们将通用测试代码移至 setUp 方法中。
  2. 好的测试方法应该有一个有意义的名称。这样不仅方便阅读,还能在测试报告中清晰地呈现。我们将 test1 重命名为 onFirstCheckAndOneClickItShouldNotShow 。
  3. 我添加了一个更复杂的测试。测试名称太短,无法表达所有信息。因此,我们将在方法体中添加注释。
  4. 最后一步,我们将删除不必要的和错误的时间设置。
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());
    }
}
Enter fullscreen mode Exit fullscreen mode

第三版:模拟库

如上所述,我们不能在单元测试中使用 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());
    }
}
Enter fullscreen mode Exit fullscreen mode

第四个版本:Kotlin

Kotlin 是一门优秀的 Android 开发语言。此外,它还能改进单元测试代码。

  1. 我们将使用反引号括起来的空格来重命名测试方法名称
  2. 我们将把通用的准备代码移到具有默认参数的函数中
  3. 我们将删除不必要的注释,因为我们可以使用命名参数
  4. 我们将使用 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())
    }
}
Enter fullscreen mode Exit fullscreen mode

第五版: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())
                }
            }
        }
    }
})
Enter fullscreen mode Exit fullscreen mode

此外,Spek 在 Android Studio 中生成结构化的测试报告。

图片描述

奖金部分

这篇关于 Android 单元测试的文章如果没有提及一些内容,就不完整。如果您有更多链接值得提及,请在评论中分享,我会将它们添加到文章中。

机器人电工

Robolectric 是一个为 Android 带来快速可靠单元测试的框架。测试可在几秒钟内在工作站上的 JVM 中运行。

它并非纯粹的单元测试,但允许我们在无需启动设备或模拟器的情况下测试 Android API。另一方面,它的测试运行时间更长。

断言框架

“评价我们”对话框逻辑具有布尔返回值,因此我们使用了简单的 assertTrue 或 assertFalse。但对于更复杂的测试,默认断言的灵活性不足。

Hamcrest 是一个用于编写匹配器对象的框架,允许以声明方式定义“匹配”规则。

assertThat(Math.sqrt(-1), is(notANumber()))
Enter fullscreen mode Exit fullscreen mode

AssertJ — 流畅的断言 Java 库。

assertThat(frodo.getName()).isEqualTo("Frodo")
Enter fullscreen mode Exit fullscreen mode

Truth 使你的测试断言和失败消息更具可读性。与 AssertJ 类似,它原生支持多种 JDK 和 Guava 类型,并且可以扩展到其他类型。

assertThat(notificationText).contains("testuser@google.com")
Enter fullscreen mode Exit fullscreen mode
鏂囩珷鏉ユ簮锛�https://dev.to/ivanshafran/evolution-of-unit-tests-in-android-32c2
PREV
片段:getContext 与 requireContext
NEXT
使用 Next JS 保护你的 API 密钥