前言
上篇《Android单元测试 - 几个重要问题》 讲解了“何解决Android依赖、隔离Native方法、静态方法、RxJava异步转同步”这几个Presenter单元测试中常见问题。如果读者你消化得差不多,就接着看本篇吧。
在日常开发中,数据储存是必不可少的。例如,网络请求到数据,先存本地,下次打开页面,先从本地读取数据显示,再从服务器请求新数据。既然如此重要,对这块代码进行测试,也成为单元测试的重中之重了。
笔者在学会单元测试前,也像大多数人一样,写好了sql代码,运行app,报错了....检查代码,修改,再运行app....这真是效率太低了。有了单元测试做武器后,我写DAO代码轻松了不少,不担心出错,效率也高。
常用的数据储存有:sqlite、SharedPreference、Assets、文件。由于这前三种储取数据方式,都必须依赖android环境,因此要进行单元测试,不能仅仅用junit & mockito了,需要另外的单元测试框架。接下来,笔者介绍如何使用robolectric进行DAO单元测试。
缩写解释:DAO (Data Access Object) 数据访问对象
Robolectric配置
Robolectric官网:http://robolectric.org/
Robolectric配置很简单的。
build.gradle
:
dependencies {
testCompile "org.robolectric:robolectric:3.1.2"
}
然后在测试用例XXTest
加上注解:
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class XXTest {
}
配置代码是写完了。
不过,别以为这样就完了。Robolectric最麻烦就是下载依赖! 由于我们生活在天朝,下载国外的依赖很慢,笔者即使有了翻墙,效果也一般,可能是https://oss.sonatype.org 服务器比较慢。
笔者已经下载好了依赖包,读者们可以到 http://git.oschina.net/kkmike999/Robolectric-Dependencies 下载robolectric 3.1.2的依赖包,按照Readme.md
说明操作。
Sqlite
DbHelper
:
public class DbHelper extends SQLiteOpenHelper {
private static final int DB_VERSION = 1;
public DbHelper(Context context, String dbName) {
super(context, dbName, null, DB_VERSION);
}
...
}
Bean
:
public class Bean {
int id;
String name = "";
public Bean(int id, String name) {
this.id = id;
this.name = name;
}
}
Bean数据操作类 BeanDAO
:
public class BeanDAO {
static boolean isTableExist;
SQLiteDatabase db;
public BeanDAO() {
this.db = new DbHelper(App.getContext(), "Bean").getWritableDatabase();
}
/**
* 插入Bean
*/
public void insert(Bean bean) {
checkTable();
ContentValues values = new ContentValues();
values.put("id", bean.getId());
values.put("name", bean.getName());
db.insert("Bean", "", values);
}
/**
* 获取对应id的Bean
*/
public Bean get(int id) {
checkTable();
Cursor cursor = null;
try {
cursor = db.rawQuery("SELECT * FROM Bean", null);
if (cursor != null && cursor.moveToNext()) {
String name = cursor.getString(cursor.getColumnIndex("name"));
return new Bean(id, name);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (cursor != null) {
cursor.close();
}
cursor = null;
}
return null;
}
/**
* 检查表是否存在,不存在则创建表
*/
private void checkTable() {
if (!isTableExist()) {
db.execSQL("CREATE TABLE IF NOT EXISTS Bean ( id INTEGER PRIMARY KEY, name )");
}
}
private boolean isTableExist() {
if (isTableExist) {
return true; // 上次操作已确定表已存在于数据库,直接返回true
}
Cursor cursor = null;
try {
String sql = "SELECT COUNT(*) AS c FROM sqlite_master WHERE type ='table' AND name ='Bean' ";
cursor = db.rawQuery(sql, null);
if (cursor != null && cursor.moveToNext()) {
int count = cursor.getInt(0);
if (count > 0) {
isTableExist = true; // 记录Table已创建,下次执行isTableExist()时,直接返回true
return true;
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (cursor != null) {
cursor.close();
}
cursor = null;
}
return false;
}
}
以上是你在项目中用到的类,当然数据库一般开发者都会用第三方库,例如:greenDAO、ormlite、dbflow、afinal、xutils....这里考虑到代码演示规范性、通用性,就直接用android提供的SQLiteDatabase。
大家注意到BeanDAO
的构造函数:
public BeanDAO() {
this.db = new DbHelper(App.getContext(), "Bean").getWritableDatabase();
}
这种在内部创建对象的方式,不利于单元测试。App
是项目本来的Application
,但是使用Robolectric往往会指定一个测试专用的Application
(命名为RoboApp
,配置方法下面会介绍),这么做好处是隔离App
的所有依赖。
隔离原Application依赖
项目原本的App
:
public class App extends Application {
private static Context context;
@Override
public void onCreate() {
super.onCreate();
context = this;
// 各种第三方初始化,有很多依赖
...
}
public static Context getContext() {
return context;
}
}
而单元测试使用的RoboApp
:
public class RoboApp extends Application {}
如果用Robolectric单元测试,不配置RoboApp
,就会调用原来的App
,而App
有很多第三方库依赖,常见的有static{ Library.load() }
静态加载so库。于是,执行App
生命周期时,robolectric就报错了。
正确配置Application
方式,是在单元测试XXTest
加上@Config(application = RoboApp.class)
。
改进DAO类
public class BeanDAO {
SQLiteDatabase db;
public BeanDAO(SQLiteDatabase db) {
this.db = db;
}
// 可以保留原来的构造函数,只是单元测试不用这个方法而已
public BeanDAO() {
this.db = new DbHelper(App.getContext(), "Bean").getWritableDatabase();
}
单元测试
DAOTest
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = Build.VERSION_CODES.JELLY_BEAN, application = RoboApp.class)
public class DAOTest {
BeanDAO dao;
@Before
public void setUp() throws Exception {
// 用随机数做数据库名称,让每个测试方法,都用不同数据库,保证数据唯一性
DbHelper dbHelper = new DbHelper(RuntimeEnvironment.application, new Random().nextInt(1000) + ".db");
SQLiteDatabase db = dbHelper.getWritableDatabase();
dao = new BeanDAO(db);
}
@Test
public void testInsertAndGet() throws Exception {
Bean bean = new Bean(1, "键盘男");
dao.insert(bean);
Bean retBean = dao.get(1);
Assert.assertEquals(retBean.getId(), 1);
Assert.assertEquals(retBean.getName(), "键盘男");
}
}
DAO单元测试跟Presenter有点不一样,可以说会更简单、直观。Presenter单元测试会用mock去隔离一些依赖,并且模拟返回值,但是sqlite执行是真实的,不能mock的。
正常情况,insert()
和get()
应该分别测试,但这样非常麻烦,必然要在测试用例写sqlite语句,并且对SQLiteDatabase 操作。考虑到数据库操作的真实性,笔者把insert
和get
放在同一个测试用例:如果insert()
失败,那么get()
必然拿不到数据,testInsertAndGet()
失败;只有insert()
和get()
代码都正确,testInsertAndGet()
才能通过。
由于用Robolectric,所以单元测试要比直接junit要慢。仅junit跑单元测试,耗时基本在毫秒(ms)级,而robolectric则是秒级(s)。不过怎么说也比跑真机、模拟器的单元测试要快很多。
SharedPreference
其实,SharedPreference道理跟sqlite一样,也是对每个测试用例创建单独SharedPreference,然后保存、查找
一起测。
ShareDAO
:
public class ShareDAO {
SharedPreferences sharedPref;
SharedPreferences.Editor editor;
public ShareDAO(SharedPreferences sharedPref) {
this.sharedPref = sharedPref;
this.editor = sharedPref.edit();
}
public ShareDAO() {
this(App.getContext().getSharedPreferences("myShare", Context.MODE_PRIVATE));
}
public void put(String key, String value) {
editor.putString(key, value);
editor.apply();
}
public String get(String key) {
return sharedPref.getString(key, "");
}
}
单元测试ShareDAOTest
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = Build.VERSION_CODES.JELLY_BEAN, application = RoboApp.class)
public class ShareDAOTest {
ShareDAO shareDAO;
@Before
public void setUp() throws Exception {
String name = new Random().nextInt(1000) + ".pref";
shareDAO = new ShareDAO(RuntimeEnvironment.application.getSharedPreferences(name, Context.MODE_PRIVATE));
}
@Test
public void testPutAndGet() throws Exception {
shareDAO.put("key01", "stringA");
String value = shareDAO.get("key01");
Assert.assertEquals(value, "stringA");
}
}
测试通过了。是不是很简单?
Assets
Robolectric对Assets支持也是相当不错的,测Assets道理也是跟sqlite、sharePreference相同。
/assets/test.txt
:
success
public class AssetsReader {
AssetManager assetManager;
public AssetsReader(AssetManager assetManager) {
this.assetManager = assetManager;
}
public AssetsReader() {
assetManager = App.getContext()
.getAssets();
}
public String read(String fileName) {
try {
InputStream inputStream = assetManager.open(fileName);
StringBuilder sb = new StringBuilder();
byte[] buffer = new byte[1024];
int hasRead;
while ((hasRead = inputStream.read(buffer, 0, buffer.length)) > -1) {
sb.append(new String(buffer, 0, hasRead));
}
inputStream.close();
return sb.toString();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
}
单元测试AssetsReaderTest
:
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = Build.VERSION_CODES.JELLY_BEAN, application = RoboApp.class)
public class AssetsReaderTest {
AssetsReader assetsReader;
@Before
public void setUp() throws Exception {
assetsReader = new AssetsReader(RuntimeEnvironment.application.getAssets());
}
@Test
public void testRead() throws Exception {
String value = assetsReader.read("test.txt");
Assert.assertEquals(value, "success");
}
}
通过了通过了,非常简单!
文件操作
日常开发中,文件操作相对比较少。由于通常都在真机测试,有时目录、文件名有误导致程序出错,还是挺烦人的。所以,笔者教大家在本地做文件操作单元测试。
Environment.getExternalStorageDirectory()
APP运行时,通过Environment.getExternalStorageDirectory()
等方法获取android储存目录,因此,只要我们改变Environment.getExternalStorageDirectory()
返回的目录,就可以在单元测试时,让jvm写操作指向本地目录。
在《Android单元测试 - 几个重要问题》 介绍过如何解决android.text.TextUtils
依赖,那么android.os.Environment
也是故伎重演:
在test/java
目录下,创建android/os/Environment.java
package android.os;
public class Environment {
public static File getExternalStorageDirectory() {
return new File("build");// 返回src/build目录
}
}
Context.getCacheDir()
如果你是用contexnt.getCacheDir()
、getFilesDir()
等,那么只需要使用RuntimeEnvironment.application
就行。
代码
写完android.os.Environment
,我们离成功只差一小步了。FileDAO
:
public class FileDAO {
Context context;
public FileDAO(Context context) {
this.context = context;
}
public void write(String name, String content) {
File file = new File(getDirectory(), name);
if (!file.getParentFile().exists()) {
file.getParentFile().mkdirs();
}
try {
FileWriter fileWriter = new FileWriter(file);
fileWriter.write(content);
fileWriter.flush();
fileWriter.close();
} catch (IOException e) {
e.printStackTrace();
}
}
public String read(String name) {
File file = new File(getDirectory(), name);
if (!file.exists()) {
return "";
}
try {
FileReader reader = new FileReader(file);
StringBuilder sb = new StringBuilder();
char[] buffer = new char[1024];
int hasRead;
while ((hasRead = reader.read(buffer, 0, buffer.length)) > -1) {
sb.append(new String(buffer, 0, hasRead));
}
reader.close();
return sb.toString();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
public void delete(String name) {
File file = new File(getDirectory(), name);
if (file.exists()) {
file.delete();
}
}
protected File getDirectory() {
// return context.getCacheDir();
return Environment.getExternalStorageDirectory();
}
}
FileDAO单元测试
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = Build.VERSION_CODES.JELLY_BEAN, application = RoboApp.class)
public class FileDAOTest {
FileDAO fileDAO;
@Before
public void setUp() throws Exception {
fileDAO = new FileDAO(RuntimeEnvironment.application);
}
@Test
public void testWrite() throws Exception {
String name = "readme.md";
fileDAO.write(name, "success");
String content = fileDAO.read(name);
Assert.assertEquals(content, "success");
// 一定要删除测试文件,保留的文件会影响下次单元测试
fileDAO.delete(name);
}
}
注意,用Environment.getExternalStorageDirectory()
是不需要robolectric的,直接junit即可;而context.getCacheDir()
需要robolectric。
小技巧
如果你嫌麻烦每次都要写@RunWith(RobolectricTestRunner.class)
&@Config(...)
,那么可以写一个基类:
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = Build.VERSION_CODES.JELLY_BEAN, application = RoboApp.class)
public class RoboCase {
protected Context getContext() {
return RuntimeEnvironment.application;
}
}
然后,所有使用robolectric的测试用例,直接继承RoboCase
即可。
小结
我想,大家应该感觉到,Sqlite、SharedPreference、Assets、文件操作几种单元测试,形式都差不多。有这种感觉就对了,举一反三。
本篇文字描述不多,代码比例较大,相信读者能看懂的。
如果读者对Presenter、DAO单元测试运用自如,那应该跟笔者水平相当了,哈哈哈。下一篇会介绍如何优雅地测试传参对象,敬请期待!
关于作者
我是键盘男。
在广州生活,在创业公司上班,猥琐文艺码农。喜欢科学、历史,玩玩投资,偶尔独自旅行。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。