android ui测试_Android UI测试之路

android ui测试

Writing stable and reliable UI tests is neither easy nor trivial. There are multiple factors applications depend on that UI developers can’t control. Network is a very common external dependency that can produce test instability. There are many reasons that make it hard to maintain a fully functional server side infrastructure:

编写稳定可靠的UI测试既不容易也不容易。 UI开发人员无法控制的应用程序有多种因素。 网络是一种非常普遍的外部依赖关系,会导致测试不稳定 。 有许多原因使维护功能完善的服务器端基础结构变得困难:

  • Data consistency

    资料一致性
  • Different application and server availability

    不同的应用程序和服务器可用性
  • Effort and cost of maintaining the above

    维护上述内容的努力和成本

A few months ago the Android team at my job decided to work on enabling UI tests. Writing tests itself is an “easy” task thanks to powerful tools like Espresso. But based on our past experience, this time we wanted to have robust, reliable and stable tests. The existing tests (existing but not running) were hitting a real (and non-production) environment. As mentioned before, this lead to unreliable tests.

几个月前,我工作的Android团队决定致力于启用UI测试。 借助Espresso之类的强大工具,编写测试本身是一项“轻松”的任务。 但是根据我们过去的经验,这次我们希望进行健壮,可靠和稳定的测试 。 现有测试(现有但尚未运行)正在达到真实(非生产)环境。 如前所述,这导致测试不可靠。

So we changed the approach and decided to have the entire network layer mocked. What this means for native apps is that no network request to any environment will be performed.

因此,我们改变了方法,并决定模拟整个网络层。 对于本机应用程序而言,这意味着将不会对任何环境执行网络请求。

🌍环境图书馆 (🌍 Environment library)

The first step towards making the app point to a given base url is to centralize every URL definition. To give some context, we have many backend services running on different layers that the app hits. Sometimes we hit a middle backend for frontend (BFF) layer, but other times we call micro-services directly. Because of this we have multiple base URLs in our app.

使应用程序指向给定的基本URL的第一步是集中每个URL定义。 为了提供一些背景信息,我们有许多后端服务运行在应用程序所到达的不同层上。 有时,我们会在前端 (BFF)层中找到一个中间后端 ,但有时我们会直接调用微服务。 因此,我们的应用程序中有多个基本URL

Image for post
Testing using a mock web server
使用模拟Web服务器进行测试

To solve this, we constructed an Environment library. The core of it is a simple class containing multiple String values for URLs:

为了解决这个问题,我们构建了一个环境库。 它的核心是一个简单的类,其中包含URL的多个String值:

class Environment {
    val api: String,
    val bff: String,
    ...
}

Building an Environment object should usually just depend on the build type/variant/flavor. But we also have an additional use case. It consists of giving internal users the ability to override the URLs. This feature is only available on the debug build, but the URLs that can be set are completely arbitrary. Therefore we have an interface abstracting how an Environment is built:

构建Environment对象通常应仅取决于构建类型/变体/风味。 但是我们还有另外一个用例。 它包括使内部用户可以覆盖URL。 此功能仅在调试版本中可用,但是可以设置的URL完全是任意的。 因此,我们有一个接口抽象了环境的构建方式:

interface UrlsDataStore {
    val api: String
    val bff: String
    ...
}

Then we have the implementation for building an Environment based on the build type:

然后,我们有了基于构建类型构建环境的实现:

class BuildTypeUrlsDataStore : UrlsDataStore {
    override val api: String
        get() = TODO("return url based on the build type")
    override val bff: String
        get() = TODO("return url based on the build type")
    ...
}

And this one is the implementation specific to the use case of providing users the ability to set custom URLs:

这是特定于用例的实现,它为用户提供了设置自定义URL的能力:

class UserOverrideUrlsDataStore(private val urlsDataStore: UrlsDataStore, ...) : UrlsDataStore by urlsDataStore {
    override val api: String
        get() = TODO("return api overwritten by the user, or delegate")


    override val bff: String
        get() = TODO("return bff overwritten by the user, or delegate")
    ...
}

Note how we are using a kind of decorator pattern + Kotlin Delegation. Users having the ability to override the URLs is just a decorating behavior on top of the values returned by the build type. As the user is not able to override every environment URL, we use delegation to forward implementation to per-build type implementation by default.

注意我们如何使用一种装饰器模式+ Kotlin委托 。 能够覆盖URL的用户只是构建类型返回的值之上的修饰行为。 由于用户无法覆盖每个环境URL,因此默认情况下,我们使用委托将实现转发到按构建类型实现。

Image for post
Building the Environment object
构建环境对象

Finally this is the way we inject the UrlsDataStore dependency in our graph using Koin:

最后,这是我们使用Koin在图表中注入UrlsDataStore依赖项的方式

val environmentKoinModule = {
    factory<UrlsDataStore> {
        UserOverrideUrlsDataStore(BuildTypeUrlsDataStore(), ...)
    }
}

🧪测试库 (🧪 Testing library)

The next step after having a small library that gives us an Environment instance is to create a testing one. This library will have the following responsibilities:

在拥有一个小型图书馆为我们提供环境实例之后,下一步就是创建一个测试图书馆。 该库将承担以下职责:

  • Define a custom Android JUnit runner.

    定义一个自定义的Android JUnit运行器。
  • Spin up a local web server that will listen for all the necessary endpoints.

    启动本地Web服务器,该服务器将侦听所有必需的端点。
  • Build an Environment object having every url pointing to the mock server address.

    构建一个环境对象,使每个URL都指向模拟服务器地址。
  • Define a JUnit rule/extension to provide the ability to override the behavior for a given endpoint.

    定义一个JUnit规则/扩展,以提供覆盖给定端点行为的能力。

🏃自定义AndroidJUnitRunner (🏃 Custom AndroidJUnitRunner)

The main reason for building a custom test runner (or extending the Android one to be more accurate) is that this is the place where we need to intercept the application being created. Our DI graph is built on Application.onCreate so we need to override the Environment before that happens.

构建自定义测试运行程序(或扩展Android测试运行程序以使其更准确)的主要原因是我们需要拦截正在创建的应用程序。 我们的DI图基于Application.onCreate构建,因此我们需要在发生这种情况之前覆盖环境。

To achieve it we can override test runner’s callApplicationOnCreate function:

为此,我们可以覆盖测试运行程序的callApplicationOnCreate函数:

class NetworkMockTestRunner : AndroidJUnitRunner() {
    override fun callApplicationOnCreate(app: Application) {
        // TODO Spin up a local web server
        // TODO Inject a new Environment object in the DI graph
        super.callApplicationOnCreate(app)
    }
}

Calling super will end up calling onCreate on the Application class.

调用super将最终在Application类上调用onCreate。

🌐本地模拟Web服务器 (🌐 Local mock web server)

To run a server inside the app process we chose Ktor. Mainly due to the easiness of setup and being Kotlin based.

为了在应用程序流程中运行服务器,我们选择了Ktor 。 主要是由于易于设置且基于Kotlin。

thread(isDaemon = true) {
    embeddedServer(Netty, host = "localhost", port = 8080) {
        // TODO Define the routing for requests
    }.start(wait = true)
}

So here we start a new thread (note that we run it as a daemon because it would be ok for the JVM to shut down if the only running thread is this one). Inside the lambda passed to the embeddedServer function we would be able to define the HTTP mapping such as:

因此,这里我们开始一个新线程(请注意,我们将其作为守护程序运行,因为如果唯一正在运行的线程是JVM,则可以关闭JVM)。 在传递给embeddedServer函数的lambda中,我们将能够定义HTTP映射,例如:

get("path/to/get") {
    call.respondText("Hello GET!")
}
post("path/to/post") {
    call.respondText("Hello POST!")
}

in在DI图中注入环境 (💉 Injecting the Environment in the DI graph)

As I mentioned before, for building and providing our runtime dependencies we use Koin. In this particular case we want to provide a custom instance of UrlsDataStore class:

如前所述,为了构建和提供我们的运行时依赖项,我们使用Koin 。 在这种特殊情况下,我们想提供一个UrlsDataStore类的自定义实例:

environmentKoinModule.factory<UrlsDataStore>(override = true) {
    val httpAddress = "localhost:8080"
    object : UrlsDataStore {
        override val api = httpAddress
        override val bff = httpAddress
        ...
    }
}

We reference environmentKoinModule which is the original Koin module that provides the instance of UrlsDataSore. It allows us to redefine how it is provided to override the definition.

我们引用environmentKoinModule ,它是提供UrlsDataSore实例的原始Koin模块。 它允许我们重新定义如何提供它以覆盖该定义。

This must be done before Application’s onCreate gets called because that is where Koin is started:

必须在调用Application的onCreate之前完成此操作,因为这是启动Koin的位置:

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    startKoin {
        modules(..., environmentKoinModule, ...)
    }
    ...
}

📏自定义JUnit规则 (📏 Custom JUnit Rule)

Building a custom JUnit rule allows an easy and loosely coupled way to communicate between tests and the mock server. The approach for this is to define a Kotlin Channel that the custom runner will listen to, and then inject it into the DI graph so that the JUnit rule would get it.

构建自定义的JUnit规则允许在测试和模拟服务器之间进行简单且松散耦合的通信方式。 此方法是定义自定义运行程序将收听的Kotlin通道 ,然后将其注入DI图中,以便JUnit规则将其获取。

Image for post
Kotlin channel to communicate between tests and the mock web server
Kotlin通道,用于在测试和模拟Web服务器之间进行通信
class RequestOverrideRule(private vararg val mockedRequests: MockedRequest, private val overrideChannel: SendChannel<MockedRequest> = get().koin.get()) : TestRule {
    override fun apply(statement: Statement, description: Description): Statement {
        return object : Statement() {
            override fun evaluate() {
                mockedRequests.forEach {
                    overrideChannel.offer(it)
                }
                statement.evaluate()
            }
        }
    }
}


data class MockedRequest(val method: HttpMethod, val url: String, val jsonBody: JsonObject)
enum class HttpMethod {
    GET,
    PUT,
    POST
}

The constructor’s first argument is a variable amount of MockedRequest objects, which is a class representing an endpoint behavior to be overwritten. The second and last is the channel where overrides will be sent to (and asynchronously processed on the test runner).

构造函数的第一个参数是数量可变的MockedRequest对象,这是一个表示要覆盖的端点行为的类。 第二个也是最后一个是替代将被发送到的通道(并在测试运行器上进行异步处理)。

The logic when executing the rule is pretty clear, it is just a matter of iterating every MockedRequest object to offer a message to the channel.

执行规则时的逻辑非常清楚,只需迭代每个MockedRequest对象以offer通道offer消息即可。

On the other end (the custom test runner) we have:

在另一端(自定义测试运行程序),我们有:

private val job = Job()
private val scope = CoroutineScope(job + Dispatchers.Default)
private val overrideChannel = Channel<MockedRequest>(Channel.UNLIMITED)

These are properties of our custom test runner that we need to launch a coroutine scoped to the lifecycle of the runner. For that we create a job that would be cancelled when the runner finishes. Next we create a custom scope based on the Default dispatcher and the job. Additionally we have the channel that is created with an UNLIMITED capacity, rhe best fit for our use case. You can read more about it in the coroutines docs.

这些是我们自定义测试运行器的属性,我们需要启动针对运行器生命周期范围的协程。 为此,我们创建了一个跑步者完成时将被取消的工作。 接下来,我们将基于默认调度程序和作业创建一个自定义范围。 此外,我们拥有以无限容量创建的渠道,最适合我们的用例。 您可以在协程文档中阅读有关它的更多信息。

The final piece is listening for messages coming to the channel and overriding the behavior in the server. We can achieve it by launching a new coroutine using the scope defined above to consume every message that arrives to the channel:

最后一步是侦听进入通道的消息,并覆盖服务器中的行为。 我们可以通过使用上面定义的作用域启动一个新的协程来消耗到达通道的每条消息来实现此目的:

scope.launch {
    overrideChannel.consumeEach { mockedRequest ->
        server.application.routing {
            val httpAction: Route.(String, PipelineInterceptor<Unit, ApplicationCall>) -> Route =
            when (mockedRequest.method) {
                HttpMethod.GET -> Route::get
                HttpMethod.POST -> Route::post
                HttpMethod.PUT -> Route::put
            }
            httpAction(mockedRequest.url) {
                call.respondText(mockedRequest.jsonBody.toString(), ContentType.Application.Json)
        }
    }
}

As each of the messages is of type MockedRequest then it is simple to redefine the routing on the Ktor server. We do that by mapping the HttpMethod enum type to a reference of Ktor’s Route function (get, post or put). Then inside the lambda we set how the call should respond.

由于每个消息的类型MockedRequest因此很容易在Ktor服务器上重新定义路由。 我们通过将HttpMethod枚举类型映射到Ktor的Route函数(get,post或put)的引用来做到这一点。 然后,在lambda中,我们设置调用应如何响应。

🏁总结 (🏁 Wrapping up)

With all of the above we can write tests using the new JUnit rule, for example:

通过上述所有操作,我们可以使用新的JUnit规则编写测试,例如:

@get:Rule
RequestOverrideRule(
    MockedRequest(
        method = GET,
        url = "/example/endpoint",
        jsonBody = json {
            "intProperty" to 0
            "stringProperty" to "test"
        }
    )
)

It gives us the power to have full control of the network on each of the tests.

它使我们能够在每个测试中完全控制网络

This is just the beginning of the approach we decided to follow to write UI tests. But there are obviously important things to do in this “framework”:

这只是我们决定遵循的编写UI测试方法的开始。 但是,显然在此“框架”中有重要的事情要做:

  • Undo the override after a test runs.

    测试运行后撤消替代。

  • Better support for the mocked data. One reason could be to avoid the need of defining the entire response in the tests. For example having a template of the responses that could be parameterized. (Did anybody say contract testing? 👂)

    更好地支持模拟数据。 原因之一可能是避免在测试中定义整个响应。 例如,具有可以参数化的响应模板 。 (有人说过合同测试吗?👂)

结论 (Conclusion)

Like many things in this world, this is not a silver bullet solution. Problems we may face include (among others) changes on the APIs that our tests could not pick up. This particular one is being addressed by defining a centralized mock data provider that clients will periodically pull from. So our default mock responses would always be up to date.

就像这个世界上的许多事物一样,这不是灵丹妙药。 我们可能面临的问题包括(其中包括)我们的测试无法获取的API更改。 通过定义一个集中的模拟数据提供程序来解决这一特定问题,客户端将定期从中提取该数据提供程序。 因此,我们的默认模拟响应将始终是最新的。

Stay safe and keep writing tests 👍

保持安全并继续编写测试👍

翻译自: https://medium.com/@patxi/road-to-android-ui-tests-5d2b180f3eca

android ui测试

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值