{"id":18001593,"url":"https://github.com/hehonghui/app-test-arch","last_synced_at":"2025-03-26T08:30:51.439Z","repository":{"id":146185302,"uuid":"95428907","full_name":"hehonghui/app-test-arch","owner":"hehonghui","description":"Android 单元测试、Monkey、LeakCanary测试demo项目【粗略示例】","archived":false,"fork":false,"pushed_at":"2017-06-26T09:24:45.000Z","size":112,"stargazers_count":98,"open_issues_count":0,"forks_count":16,"subscribers_count":7,"default_branch":"master","last_synced_at":"2025-03-21T11:50:36.836Z","etag":null,"topics":["android","architecture","test"],"latest_commit_sha":null,"homepage":"","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/hehonghui.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2017-06-26T09:08:37.000Z","updated_at":"2024-07-22T17:36:00.000Z","dependencies_parsed_at":null,"dependency_job_id":"7b663b99-32ca-4ec8-a168-2cae24b6b959","html_url":"https://github.com/hehonghui/app-test-arch","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hehonghui%2Fapp-test-arch","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hehonghui%2Fapp-test-arch/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hehonghui%2Fapp-test-arch/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hehonghui%2Fapp-test-arch/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/hehonghui","download_url":"https://codeload.github.com/hehonghui/app-test-arch/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":245618574,"owners_count":20645024,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["android","architecture","test"],"created_at":"2024-10-29T23:18:03.843Z","updated_at":"2025-03-26T08:30:51.424Z","avatar_url":"https://github.com/hehonghui.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"# 初创团队的Android应用质量保障之道-稳定性与内存优化 应用示例部分\n\n\u003e\n该项目为 [初创团队的Android应用质量保障之道](http://blog.csdn.net/bboyfeiyu/article/details/73716633) 的应用示例部分（应用架构、单元测试、Monkey、LeakCanary定制），由于时间有限，只能够写些简单的代码来讲述一个大致的过程。其中的代码有很多不合理之处，各位客官只需要了解其原理，然后将原理运用到自己的项目中即可。具体的代码结构、测试框架都可以自行替换.\n\n核心要点: \n\n1. 单元测试覆盖，提高开发、测试效率，保证底层基础类型的正确性. 测试对象: 非UI的Class都可以进行单元测试.\n2. Monkey 压力测试 配合 LeakCanary, 获取崩溃信息、内存泄露信息\n3. 通过Jenkins平台自动执行测试任务, 将结果通过邮件发送给开发人员. (夜间执行测试，第二天早上得到邮件反馈)\n\n## jenkins 流程\n\n对于Android项目来说，你可以理解为它可以定期的拉取代码，然后打包你的应用，并且执行一些特定的任务，例如打包之后运行单元测试、压力测试、UI自动化测试、上传到fir.im 上等。Jenkins的执行流程大致如图 1-1 所示 :\n\n![](http://img.blog.csdn.net/20170418131739385)     \n图 1-1  \n\nJenkins测试任务的执行步骤: \n\n1. 获取最新代码(通过将github作为代码仓库)\n3. 运行测试任务\n4. 将测试报告通过邮件的形式发给相关人员\n\n## Monkey 测试\n\n1. 通过 gradle 执行 `./gradlew assembleMonkeyDebug` 命令生成 monkey flavor的apk包\n2. 通过 shell 脚本安装上述apk\n3. 执行monkey 命令运行monkey测试， 例如 `adb shell monkey -p com.simple.apptestarch --ignore-crashes --ignore-timeouts --ignore-native-crashes --pct-touch 40 --pct-motion 25 --pct-appswitch 10 --pct-rotation 5 -s 12358 -v -v -v --throttle 500 1000 2\u003e~/monkey_error.txt 1\u003e~/monkey_log.txt`, `com.simple.apptestarch` 为你的应用包名, 参数 1000代表事件的数量，测试时可以根据具体情况来设置，通常我们设置为 100000次 到 200000次。\n4. 如果在测试过程中出现崩溃和内存泄露，相关信息会写入到sdcard对应的目录中\n5. 测试完成，将相关日志通过邮件反馈给开发人员\n\n\n崩溃日志的保存路径可以通过logcat来查看(可以自行修改): \n\n```\nlog file name : /storage/emulated/0/com.simple.apptestarch/crash/2017-06-26-crash.txt\n```\n\n### Crash日志 `2017-06-26-crash.txt` 详细信息 :\n\n```\njava.lang.IllegalStateException: Detail Leak !!! Please check !\n\tat com.simple.apptestarch.ui.detail.DetailActivity$1$override.run(DetailActivity.java:34)\n\tat com.simple.apptestarch.ui.detail.DetailActivity$1$override.access$dispatch(DetailActivity.java)\n\tat com.simple.apptestarch.ui.detail.DetailActivity$1.run(DetailActivity.java:0)\n\tat android.os.Handler.handleCallback(Handler.java:751)\n\tat android.os.Handler.dispatchMessage(Handler.java:95)\n\tat android.os.Looper.loop(Looper.java:154)\n\tat android.app.ActivityThread.main(ActivityThread.java:6119)\n\tat java.lang.reflect.Method.invoke(Native Method)\n\tat com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886)\n\tat com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776)\n\n```\n\n崩溃日志很直观，直接暴露出来了崩溃的原因. 即 `com.simple.apptestarch.ui.detail.DetailActivity` 34行出的 `run`函数中抛出了一个 `java.lang.IllegalStateException `异常. 看到这个log之后到相应的类中处理掉即可.\n\n\n### 内存泄露日志的保存路径可以通过logcat来查看(可以自行修改):\n\n```\n### *** onHeapAnalyzed in onHeapAnalyzed , dump dir :  /data/user/0/com.simple.apptestarch/files/leakcanary\n### log file name : /storage/emulated/0/com.simple.apptestarch/leak/2017-06-26-leak.txt\n```\n\n内存泄露的详细信息:\n\n```\nIn com.simple.apptestarch:1.0:1.\n* com.simple.apptestarch.ui.detail.DetailActivity has leaked:\n* GC ROOT static com.simple.apptestarch.ui.detail.DetailActivity.sRecords\n* references java.util.LinkedList.first\n* references java.util.LinkedList$Node.item\n* leaks com.simple.apptestarch.ui.detail.DetailActivity instance\n\n* Retaining: 48 KB.\n* Reference Key: dce6c099-4abe-4be1-abd8-0bdb24eb6082\n* Device: motorola google Nexus 6 shamu\n* Android Version: 7.1.1 API: 25 LeakCanary: 1.5 00f37f5\n* Durations: watch=5009ms, gc=139ms, heap dump=1817ms, analysis=85866ms\n\n```\n\nlog指出 `com.simple.apptestarch.ui.detail.DetailActivity `发生了内存泄露, 只有它的GC ROOT是 `com.simple.apptestarch.ui.detail.DetailActivity.sRecords`, 我们根据信息到DetailActivity类中，发现问题代码如下: \n\n```\npublic class DetailActivity extends AppCompatActivity {\n\n    private static List\u003cActivity\u003e sRecords = new LinkedList\u003c\u003e() ;\n\n    @Override\n    protected void onCreate(@Nullable Bundle savedInstanceState) {\n        super.onCreate(savedInstanceState);\n        setContentView(R.layout.activity_detail);\n        // 这里有问题 !!!!\n        sRecords.add(this) ;\n        // 其他代码\n    }\n}\n```\n此时只需要将DetailActivity对象在合适的时候从sRecords中移除即可. Jenkins执行Monkey测试、LeakCanary收集信息、邮件发送测试报告，整个过程都是通过自动执行，不需要我们人工干预，在快速开发时使得我们能够更快、更省心的发现问题。\n\n## 单元测试\n\n### Android 单元测试\n\n测试代码目录为: `app/src/androidTest/java/`\n\n![](http://img.blog.csdn.net/20170418131731443)\n\n图 2-1 中将自动测试分为了三个层次，从下到上依次为单元测试、业务逻辑测试、UI测试，越往上测试成本越高、测试的效率越低，也就是说单元测试是整个测试金字塔中投入最少、收益最高、测试效率最高的测试类型。\n\n* com.simple.apptestarch.services 包下为 Presenter的测试用例, 相当于业务逻辑测试;\n* com.simple.apptestarch.services.unittest包下为单元测试\n\n以 MainPresenterTestCase 中的testFetchNewsFromDb测试用例为例, 该测试用例的测试对象为 MainPresenter的fetchNews函数,代码如下:\n\n```\npublic class MainPresenter extends Presenter\u003cMainView\u003e {\n    // 本地新闻源, 从数据库获取新闻\n    NewsDataSource mLocalSource  ;\n    // 网络数据源, 从服务器获取新闻\n    NewsDataSource mRemoteSource  ;\n    // 是否应该自动刷新\n    RefreshMonitor mRefreshMonitor;\n\n    public MainPresenter(NewsDataSource local, NewsDataSource remote, RefreshMonitor refreshMonitor) {\n        this.mLocalSource = local;\n        this.mRemoteSource = remote;\n        this.mRefreshMonitor = refreshMonitor;\n    }\n    \n    private boolean isNotEmpty(List\u003cNews\u003e newsList) {\n        return newsList != null \u0026\u0026 newsList.size() \u003e 0 ;\n    }\n\n    public void fetchNews() {\n        // 1. 从数据库中读取缓存新闻\n        mLocalSource.fetchNews(new NewsListener() {\n            @Override\n            public void onComplete(List\u003cNews\u003e newsList) {\n            \t\t// 2. 从数据库中如果到获取新闻则回调给 MainView \n                if ( getView() != null \u0026\u0026 isNotEmpty(newsList) ) {\n                    getView().onFetchNews(newsList);\n                }\n\n                // 3. 如果缓存中没有新闻 或者 mRefreshMonitor.shouldRefresh() 返回true, 那么要从网络上获取新闻\n                if ( !isNotEmpty(newsList) || mRefreshMonitor.shouldRefresh()) {\n                    mRemoteSource.fetchNews(mNewsListener);\n                }\n            }\n        });\n    }\n\n    NewsListener mNewsListener = new NewsListener() {\n        @Override\n        public void onComplete(List\u003cNews\u003e newsList) {\n            if ( getView() != null ) {\n                getView().onFetchNews(newsList);\n            }\n        }\n    } ;\n}\n``` \n在 MainPresenter中我们将 mLocalSource、mRemoteSource、mRemoteSource作为外部依赖注入, 而不是在声明字段时直接使用new的形式创建, 例如`NewsDataSource mLocalSource = new NewsDbSource(); `,这是因为在对 MainPresenter 进行测试时我们需要解除这几个类型依赖, 在测试时我们可以使用几个Mock对象来替代,这样我们就能够不真正的依赖数据库、网络等条件进行测试,而只需要关注 MainPresenter 本身的业务逻辑. 在应用中不少开发人员会使用Dagger进行依赖注入，但是对于为什么要注入、而不在初始化时直接通过new的形式创建这个问题很多人并不了解。这种情况恰好是修改依赖注入的场景之一，在正式代码中使用真实的对象，而在测试时则通过Dagger注入另外一种实现的对象，达到解除依赖的效果。这种形式会使得MainPresenter变得更灵活、简单，也是的MainPresenter可测试。\n\n我们再来看看 `testFetchNewsFromDb`测试用例的代码如下: \n\n```\n    /**\n     * 测试只从数据库中读取新闻. 这个测试用例模拟的情况为:\n     *\n     * 从数据库中读取了三条新闻缓存, 并且不应该从网络上获取新闻. 获取到数据库缓存之后会将缓存新闻通过 MainView 的 onFetchNews 回调给 MainActivity,\n     * 然后后续不会调用 mRemoteSource 的fetchNews 方法, 因为我们预设了条件 mRefreshMonitor.shouldRefresh() 返回false, 且获取到了缓存新闻.\n     *\n     * @throws Exception\n     */\n    public void testFetchNewsFromDb() throws Exception {\n        // ========= step 1. 条件准备部分\n        // 当调用mRefreshMonitor.shouldRefresh() 返回 false. 表示不应该从网络上获取新闻\n        when(mRefreshMonitor.shouldRefresh()).thenReturn(false) ;\n\n        // ======== step 2. 执行mPresenter.fetchNews()函数\n        mPresenter.fetchNews();\n\n        // 当调用 mLocalSource.fetchNews 函数时捕获它的 NewsListener 参数, 然后调用 NewsListener 对象的 onComplete 函数, 参数通过 createNews 返回.\n        ArgumentCaptor\u003cNewsListener\u003e captor = ArgumentCaptor.forClass(NewsListener.class) ;\n        // 参数捕获 NewsListener 参数\n        verify(mLocalSource).fetchNews(captor.capture());\n        // 执行回调, 将 createNews() 返回的数据回调给 MainPresenter 【这里相当于是模拟从数据库中读取到数据】\n        captor.getValue().onComplete(createNews());\n\n        // ======= step 3. 验证部分\n        // 调用了 mMainView的 onFetchNews 函数\n        verify(mMainView,times(1)).onFetchNews(anyListOf(News.class));\n        // 没有调用过 mRemoteSource的fetchNews函数\n        verify(mRemoteSource, never()).fetchNews(any(NewsListener.class));\n    }\n```\n\n在testFetchNewsFromDb测试用例中，模拟`mRefreshMonitor.shouldRefresh()`返回false. 当执行到 testFetchNewsFromDb函数的\"step 2\"时会执行 MainPresenter 类中的 fetchNews() 函数, 该函数首先会调用 `mLocalSource.fetchNews`函数，而在 `testFetchNewsFromDb `的 \"step 2\"之后会捕获 mLocalSource.fetchNews 函数的 NewsListener 参数，然后回调该NewsListener，将三条新闻(通过 createNews() 函数创建)回调给MainView.因此在 \"step 3\"处的 `verify(mMainView,times(1)).onFetchNews(anyListOf(News.class));` 成立，这句代码的意思是mMainView的onFetchNews函数被调用了一次. 也就是 MainPresenter 类中的 fetchNews() 函数中的注释2处条件成立了, `getView().onFetchNews(newsList);`被成功执行. 而我们预设了 `mRefreshMonitor.shouldRefresh()`返回false， 因此在MainPresenter 类中的 fetchNews() 函数中注释3处条件不成立，MainPresenter中的mRemoteSource.fetchNews函数不会被执行, 所以`verify(mRemoteSource, never()).fetchNews(any(NewsListener.class));` 验证条件正确. 这样我们这个加载新闻的业务逻辑测试用例就完成了。如果我们的MainPresenter代码、测试用例代码没有问题，那么我们的测试用例就应该通过。否则我们就需要修改代码，使它通过测试. (如果后期业务逻辑发生了改变，测试代码也需要改变，所以写单元测试也会有成本).\n\n总结一下测试的过程就是我们通过预设一些条件，然后再执行相应的函数，最后验证函数中的逻辑是否按照我们的期望来执行。如果满足我们的期望，那么测试成功.\n\n整个过程能够自动化之后，我们的后续维护工作就会很少了。只需要维护自己的单元测试用例，Jenkins、Monkey、LeakCanary这些都不再需要我们的维护。Jenkins会每天自动的执行测试、反馈结果，我们的应用也会变得越来越灵活、越来越稳定。但这些只能够在一定程度上提升应用的质量，它不能够发现类似设备兼容性等问题，因此我们需要通过多种手段、多重维度来保证应用的质量问题.\n\n## 参考资料\n\n* [《Android开发进阶 从小工到专家》单元测试章节](http://item.jd.com/11880368.html)\n* [Google应用多种架构项目示例（包含应用架构、测试），可重点学习!](https://github.com/googlesamples/android-architecture)\n* [小创的单元测试系列文章](http://chriszou.com/)\n* [Mockito中文文档](https://github.com/hehonghui/mockito-doc-zh)","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhehonghui%2Fapp-test-arch","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhehonghui%2Fapp-test-arch","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhehonghui%2Fapp-test-arch/lists"}