在云计算普及的今天,Cloud Native 开发越来越流行。所谓云原生开发,就是从开发的第一天开始,就依赖云环境和云资源,不再有本地开发环境。这给 TDD 普及度本就很低的现状,又带来了更多的挑战,甚至导致了资深的开发人员,在云原生开发中也放弃了 TDD 实践。

本文以一个非常具体的例子,来戳破云原生这个纸老虎,让 TDD 的实践者更有信心,在云原生开发中,不再有 TDD 很难的错觉和心理恐惧。

问题

在使用 jest 写测试时,如果待测试对象中引用了 aws sdk,那么在直接运行测试时会碰到这个问题:

shell ConfigError: Missing region in config

image.png

原因

待测试对象中的方法,使用 aws sdk 时,会向 AWS 服务发送 HTTP 调用请求,由于没有传递 region,导致 AWS 服务报错,最终响应到了客户端这个错误信息。

解决方案

可以通过 jest 将 aws-sdk mock 掉,以避免在测试过程中真正去调用 AWS api。

具体做法

首先,要将 aws-sdk 整个地 mock 掉:

typescript export const mockAwsSdk = { DynamoDb: jest.fn(), CloudWatchLogs: jest.fn(), SQS: jest.fn() Lambda: jest.fn() }

jest.mock(aws-sdk, () => mockAwsSdk)

然后,针对待测对象的实际调用,提供针对性的 mock。比如待测对象使用了 DynamoDb 中的几乎所有方法,那么在 mock 时,可以使用一个数组来做为 DynamoDb 的一个替代。于是,可以实现以下的 mockDynamoDb(注意,使用了 items: [] 做为初始表格):

typescript // 自定义一个查询函数,在后面模拟搜索时可以用到。注意这里是一个示例,具体的实现可以根据待测对象使用到的 DynamoDb 的具体 Schema 任意改写 const queryByOperationKeyAndScenarioType = (opKey, scType) => (item) => (item.operationKey.S === opKey.S || item.operationKey.S === opKey.S.split(|)[0] + |*) && (scType ? item.scenarioType.S === scType.S : true)

export const mockDynamoDB = { createTable: jest.fn().mockImplementation(() => { return { promise: () => { return Promise.resolve() }, } }), updateTimeToLive: jest.fn().mockImplementation(() => { return { promise: () => { return Promise.resolve() }, } }), items: [], putItem: jest.fn().mockImplementation((params) => { // 假定params至多前4个Key是主键 const existed = mockDynamoDB.items.findIndex((item) => { const [pk1, pk2, pk3, pk4] = Object.keys(params.Item)

        return [pk1, pk2, pk3, pk4]
            .filter((pk) => !!pk)
            .reduce((prev, next) => prev && JSON.stringify(item[next]) === JSON.stringify(params.Item[next]), true)
    })

    if (existed === -1) {
        mockDynamoDB.items.push(params.Item)
    } else {
        mockDynamoDB.items[existed] = params.Item
    }
    
    return mockDynamoDB
}),
getItem: jest.fn().mockImplementation((params) => {
    // 根据传入的参数从数组中筛选出符合条件的第一条记录
    const [res] = mockDynamoDB.items.filter((item) =>
        Object.keys(params.Key).reduce(
            (prev, next) => prev && JSON.stringify(params.Key[next]) === JSON.stringify(item[next]),
            true
        )
    )

    return { promise: () => Promise.resolve({ Item: res }) }
}),
query: jest.fn().mockImplementation((params) => {
    return {
        promise: () => {
            return Promise.resolve({
                // 用自定义的筛选函数,当然,你也可以定义多个,根据需要使用不同的筛选函数
                Items: mockDynamoDB.items.filter(
                    queryByOperationKeyAndScenarioType(
                        params.ExpressionAttributeValues[:opKey],
                        params.ExpressionAttributeValues[:scType]
                    )
                ),
            })
        },
    }
}),
scan: jest.fn().mockImplementation((params) => {
    return {
        promise: () => {
            // 可以直接返回 items,或者不直接依赖它的结果的话,直接 resolve
            return Promise.resolve()
        },
    }
}),
promise: jest.fn().mockReturnValue(Promise.resolve()),
batchWriteItem: jest.fn().mockImplementation((params) => {
    // 根据需要将传入的参数写入数组
    for (const table in params.RequestItems) {
        const item = params.RequestItems[table]
        mockDynamoDB.items.push(item[0].PutRequest.Item)
    }

    return {
        promise: () => {
            return Promise.resolve({})
        },
    }
}),

}

以上是一个待测对象使用了 DynamoDb 的例子,如果使用了 SQS,也可以类似地 mock 一个 SQS。在有了 mockDynamoDb 后,就可以把这个 mock 对象灌入最早的 jest mock 过的 aws-sdk 了:

typescript mockAwsSdk.DynamoDb = jest.fn(() => mockDynamoDb)

注意事项

jest.mock(aws-sdk, ...) 这一行,需要放在引入待测对象之前,即在待测对象在真实引用 aws-sdk 前,把 aws-sdk 替换掉。
image.png

效果

于是,测试就可以愉快的进行了,既可以测试自己的业务逻辑,但是又不真正调用 AWS Api:
image.png

总结

测试驱动开发是一个以终为始的做事方法,是10倍程序员工作方法之一。但是为什么难以推广呢?既然是 10 倍工作效率,但业界采用比例为什么仍然很低呢?原因之一就是在实践过程中有各种拦路虎,比如让人觉得难以 mock。实际上,mock 方法有很多,本文对 aws sdk 这个具体的场景,提供了一种可行的 mock 方案,希望扫清大家在引用了 aws sdk 的开发中的 TDD 障碍。