吐槽Robolectric
如果你读过笔者的《Android单元测试 - Sqlite、SharedPreference、Assets、文件操作 怎么测?》,就知道我们可以用Robolectric去做SharePreference单元测试。但笔者越来越觉得Robolectric非常麻烦,主要以下几点:
1.对初学者门槛高
2.运行效率低下
第一次使用Robolectric的同学,必然会卡在下载依赖的步骤,这一步让多少同学放弃或者延迟学习robolectric。读者可以参考《加速Robolectric下载依赖库及原理剖析》,彻底解决这个问题。
其次,就是配置麻烦,从2.x到3.x版本,配置一直改动(其实是越来越精简),2.x版本的配置到3.x版本,就有问题,不得不重新看官方文档如何配置。有时不知道是改了gradle
版本还是什么原因,配置没变,就给你报错,常见的"No such manifest file: build\intermediates\bundles\debug\AndroidManifest.xml"
......
至于运行效率,由于Robolectric是一个大而全的框架,单元测试到UI测试都能做,运行时先解析、加载一大堆东西,才给你跑测试。笔者研究过源码,前期解析慢主要是UI方面,如果只是测SharePreference
和SQLiteDatabase
根本不需要,就想不明白Robolectric团队为什么不把SharePreference
和SQLiteDatabase
配置分离出来,好让单元测试跑快一点。
简单实验,跑一个什么都不做的robolectric test case:
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class RoboCase {
@Test
public void testRobo() {
}
}
尽管你什么都不做,不好意思,Robolectric就得运行3秒!而且随着工程代码增加,这个时间有增无减。如果跑一个什么都不做的Junit单元测试,1ms不到。笔者本文介绍的方法,跑简单的运行测试时间在10~1000ms不等,视乎测试代码复杂度,最快比Robolectric快140+倍。
理解SharedPreferences
我们通过Context
获取SharedPreferences
:
Context context;
SharedPreferences sharePref = context.getSharedPreferences("name", Context.MODE_PRIVATE);
getSharedPreferences
有name
和mode
参数,传不同的name
获取不同的SharedPreferences
。
SharedPreferences
源码:
public interface SharedPreferences {
public interface OnSharedPreferenceChangeListener {
void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key);
}
public interface Editor {
Editor putString(String key, @Nullable String value);
Editor putStringSet(String key, @Nullable Set values);
Editor putInt(String key, int value);
Editor putLong(String key, long value);
Editor putFloat(String key, float value);
Editor putBoolean(String key, boolean value);
Editor remove(String key);
Editor clear();
boolean commit();
void apply();
}
Map getAll();
@Nullable
String getString(String key, @Nullable String defValue);
@Nullable
Set getStringSet(String key, @Nullable Set defValues);
int getInt(String key, int defValue);
long getLong(String key, long defValue);
float getFloat(String key, float defValue);
boolean getBoolean(String key, boolean defValue);
boolean contains(String key);
Editor edit();
void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener);
void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener);
}
SharedPreferences
实际上只是一个接口,我们获取的对象,是继承该接口的android.app.SharedPreferencesImpl
。Android sdk没有提供这个类,读者可阅读源码:SharedPreferencesImpl.java。
从功能上看,SharedPreferences
就是简单的kev-value数据库,在app运行时,对SharedPreferences
储存、读取数据,会存放在Android手机该app空间的文件里。
单元测试思路
首先,单元测试原则是每个测试用例的数据独立。因此,前一个测试用例在SharedPreferences
储存的数据,下一个用例不应该读取到,SharedPreferences
就没有必要真的把数据储存在文件了,只需要存放在jvm内存就足够。
既然SharedPreferences
的功能用内存实现,那么java代码就能轻易实现key-value储存,原理跟java.util.Map
如出一辙。
代码实现SharedPreferences
ShadowSharedPreferences:
public class ShadowSharedPreference implements SharedPreferences {
Editor editor;
List mOnChangeListeners = new ArrayList<>();
Map map = new ConcurrentHashMap<>();
public ShadowSharedPreference() {
editor = new ShadowEditor(new EditorCall() {
@Override
public void apply(Map commitMap, List removeList, boolean commitClear) {
Map realMap = map;
// clear
if (commitClear) {
realMap.clear();
}
// 移除元素
for (String key : removeList) {
realMap.remove(key);
for (OnSharedPreferenceChangeListener listener : mOnChangeListeners) {
listener.onSharedPreferenceChanged(ShadowSharedPreference.this, key);
}
}
// 添加元素
Set keys = commitMap.keySet();
// 对比前后变化
for (String key : keys) {
Object lastValue = realMap.get(key);
Object value = commitMap.get(key);
if ((lastValue == null && value != null) || (lastValue != null && value == null) || !lastValue.equals(value)) {
for (OnSharedPreferenceChangeListener listener : mOnChangeListeners) {
listener.onSharedPreferenceChanged(ShadowSharedPreference.this, key);
}
}
}
realMap.putAll(commitMap);
}
});
}
public Map getAll() {
return new HashMap<>(map);
}
public String getString(String key, @Nullable String defValue) {
if (map.containsKey(key)) {
return (String) map.get(key);
}
return defValue;
}
public Set getStringSet(String key, @Nullable Set defValues) {
if (map.containsKey(key)) {
return new HashSet<>((Set) map.get(key));
}
return defValues;
}
public int getInt(String key, int defValue) {
if (map.containsKey(key)) {
return (Integer) map.get(key);
}
return defValue;
}
public long getLong(String key, long defValue) {
if (map.containsKey(key)) {
return (Long) map.get(key);
}
return defValue;
}
public float getFloat(String key, float defValue) {
if (map.containsKey(key)) {
return (Float) map.get(key);
}
return defValue;
}
public boolean getBoolean(String key, boolean defValue) {
if (map.containsKey(key)) {
return (Boolean) map.get(key);
}
return defValue;
}
public boolean contains(String key) {
return map.containsKey(key);
}
public Editor edit() {
return editor;
}
/**
* 监听对应的key值的变化,只有当key对应的value值发生变化时,才会触发
*
* @param listener
*/
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
mOnChangeListeners.add(listener);
}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
mOnChangeListeners.remove(listener);
}
interface EditorCall {
void apply(Map map, List removeList, boolean commitClear);
}
public class ShadowEditor implements SharedPreferences.Editor {
boolean commitClear;
Map map = new ConcurrentHashMap<>();
/**
* 待移除列表
*/
List removeList = new ArrayList<>();
EditorCall mCall;
public ShadowEditor(EditorCall call) {
this.mCall = call;
}
public ShadowEditor putString(String key, @Nullable String value) {
map.put(key, value);
return this;
}
public ShadowEditor putStringSet(String key, @Nullable Set values) {
map.put(key, new HashSet<>(values));
return this;
}
public ShadowEditor putInt(String key, int value) {
map.put(key, value);
return this;
}
public ShadowEditor putLong(String key, long value) {
map.put(key, value);
return this;
}
public ShadowEditor putFloat(String key, float value) {
map.put(key, value);
return this;
}
public ShadowEditor putBoolean(String key, boolean value) {
map.put(key, value);
return this;
}
public ShadowEditor remove(String key) {
map.remove(key);
removeList.add(key);
return this;
}
public ShadowEditor clear() {
commitClear = true;
map.clear();
removeList.clear();
return this;
}
public boolean commit() {
try {
apply();
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
public void apply() {
mCall.apply(map, removeList, commitClear);
// 每次提交清空缓存数据
map.clear();
commitClear = false;
removeList.clear();
}
}
}
SharePreferenceHelper:
public class SharePreferenceHelper {
public static SharedPreferences newInstance() {
return new ShadowSharePreference();
}
}
只需要两个类,准备工作就大功告成了,非常简单!
跑单元测试
BookDAO:
public class BookDAO {
SharedPreferences mSharedPre;
SharedPreferences.Editor mEditor;
// 单元测试调用,注意声明protected
protected BookDAO(SharedPreferences sharedPre) {
this.mSharedPre = sharedPre;
this.mEditor = sharedPre.edit();
}
// 正常代码调用
public BookDAO(Context context) {
this(context.getSharedPreferences("book", Context.MODE_PRIVATE));
}
/**
* 设置某book是否已读
*
* @param bookId 书本id
* @param isRead 是否已读
*/
public void setBookRead(int bookId, boolean isRead) {
mEditor.putBoolean(String.valueOf(bookId), isRead);
mEditor.commit();
}
/**
* book是否已读
*
* @param bookId 书本id
* @return
*/
public boolean isBookRead(int bookId) {
return mSharedPre.getBoolean(String.valueOf(bookId), false);
}
}
BookDAO
有两个构造方法,BookDAO(SharedPreferences sharedPre)
和BookDAO(Context context)
,由于单元测试没有Context,因此直接创建SharedPreferences对象即可。
BookDAOTest单元测试:
public class BookDAOTest {
BookDAO bookDAO;
@Before
public void setUp() throws Exception {
bookDAO = new BookDAO(SharePreferenceHelper.newInstance());
}
@Test
public void isBookRead() throws Exception {
int bookId = 10;
// 未读
Assert.assertFalse(bookDAO.isBookRead(bookId));
// 设置已读
bookDAO.setBookRead(bookId, true);
// 已读
Assert.assertTrue(bookDAO.isBookRead(bookId));
}
}
仅需要12ms,非常快,而且不需要任何配置。
进阶
场景测试
你本来有BookDAO
,后来重构,需要新增或者抛弃一些方法或者其他原因,写一个BookDAOV2
。这个BookDAOV2
与BookDAO
的数据共享,意味着用同一个SharedPreferences。
单元测试怎么写呢?
public class BookDAOV2 {
SharedPreferences mSharedPre;
SharedPreferences.Editor mEditor;
protected BookDAOV2(SharedPreferences sharedPre) {
this.mSharedPre = sharedPre;
this.mEditor = sharedPre.edit();
}
public BookDAOV2(Context context) {
// 与BookDAO使用同一个SharedPreferences
this(context.getSharedPreferences("book", Context.MODE_PRIVATE));
}
public void clearAllRead() {
mEditor.clear();
mEditor.commit();
}
}
测试用例:
public class BookUpdateTest {
BookDAO bookDAO;
BookDAOV2 bookDAOV2;
@Before
public void setUp() throws Exception {
SharedPreferences sharedPref = SharedPreferencesHelper.newInstance();
bookDAO = new BookDAO(sharedPref);
bookDAOV2 = new BookDAOV2(sharedPref);
}
@Test
public void testClearAllRead() {
int bookId = 10;
// 设置已读
bookDAO.setBookRead(bookId, true);
// 已读
Assert.assertTrue(bookDAO.isBookRead(bookId));
// DAOV2 清除已读
bookDAOV2.clearAllRead();
// 未读
Assert.assertFalse(bookDAO.isBookRead(bookId));
}
}
但是这样不太优雅,能不能调用SharedPreferencesHelper
同一个方法,返回同一个SharedPreferences
呢?
通过name获取不同SharedPreferences
context.getSharedPreferences(name, mode)
可以改变name
类获取不同SharedPreferences
对象,这些SharedPreferences
彼此数据独立。
因此,我们在SharePreferenceHelper
加两个静态方法:
public class SharePreferenceHelper {
private static Map map = new ConcurrentHashMap<>();
public static SharedPreferences getInstance(String name) {
if (map.containsKey(name)) {
return map.get(name);
} else {
SharedPreferences sharedPreferences = new ShadowSharePreference();
map.put(name, sharedPreferences);
return sharedPreferences;
}
}
public static void clean() {
map.clear();
}
......
}
我们调用SharePreferenceHelper.getInstance(name)
就可以获取name对应不同ShadowSharedPreferences
。
跑个测试:
public class MultipleSharedPrefTest {
@Test
public void testSampleSharedPrefer() {
SharedPreferences sharedPref0 = SharedPreferenceHelper.getInstance("name");
SharedPreferences sharedPref1 = SharedPreferenceHelper.getInstance("name");
Assert.assertEquals(sharedPref0, sharedPref1);
}
@Test
public void testDifferentSharedPref() {
SharedPreferences sharedPref0 = SharedPreferenceHelper.getInstance("name");
SharedPreferences sharedPref1 = SharedPreferenceHelper.getInstance("other");
// 不同SharedPreferences
Assert.assertNotEquals(sharedPref0, sharedPref1);
}
}
结果当然是两个都pass啦!
处理Test Case前后数据干扰
运行一次单元测试,无论Test Case多少,jvm只启动一次,因此,静态变量就会一直存在,直到该次单元测试完成。问题就出现了:上面介绍的SharedPreferenceHelper.getInstance(name)
,是通过static Map
缓存SharedPreferences
对象,所以,同一次单元测试,上一个Test Case储存的数据,会影响下一个Test Case。
下面的单元测试,先执行testA()
,储存key=1,在执行testB()
:
@FixMethodOrder(MethodSorters.NAME_ASCENDING) // 按case名称字母顺序排序
public class DistractionTest {
SharedPreferences mSharedPref;
SharedPreferences.Editor mEditor;
@Before
public void setUp() throws Exception {
mSharedPref = SharedPreferencesHelper.getInstance("name");
mEditor = mSharedPref.edit();
}
@Test
public void testA() {
mEditor.putInt("key", 1);
mEditor.commit();
}
@Test
public void testB() {
// testA()的数据,不应该影响testB()
Assert.assertEquals(0, mSharedPref.getInt("key", 0));
}
}
很遗憾,testA()
的数据影响到testB()
:
java.lang.AssertionError:
Expected :0
Actual :1at org.junit.Assert.assertEquals(Assert.java:631)
at com.sharepreference.library.DistractionTest.testB(DistractionTest.java:34)
因此,需要在Test Case tearDown()
方法回调时,调用SharedPreferenceHelper.clean()
,再运行一次:
@FixMethodOrder(MethodSorters.NAME_ASCENDING) // 按case名称字母顺序排序
public class DistractionTest {
...
@After
public void tearDown() throws Exception {
SharedPreferencesHelper.clean();
}
...
}
统一处理tearDown()
如果我们每个Test Case都要写testDown()
处理SharedPreferences
缓存,未免太不优雅。我们可以借助TestRule
类完成。
SharedPrefRule:
public class SharedPrefRule extends ExternalResource {
@Override
protected void after() {
// 每测试完一个用例方法,就回调
SharedPreferencesHelper.clean();
}
}
SharedPrefCase:
public class SharedPrefCase {
@Rule
public SharedPrefRule rule = new SharedPrefRule();
public SharedPreferences getInstance(String name) {
return SharedPreferencesHelper.getInstance(name);
}
}
于是,我们所以SharedPrefences测试用例,都继承SharedPrefCase
:
public class MySharedPrefTest extends SharedPrefCase {
SharedPreferences mSharedPre;
@Before
public void setUp() throws Exception {
mSharedPre = getInstance("name");
}
}
这样,数据干扰的问题就解决了。
修改BookUpdateTest
上文提到的BookDAO
和BookDAOV2
单元测试,可以修改如下:
public class BookUpdateTest extends SharedPrefCase {
@Before
public void setUp() throws Exception {
bookDAO = new BookDAO(getInstance("book"));
bookDAOV2 = new BookDAOV2(getInstance("book"));
}
}
比之前优雅多了。
Context获取SharedPreferences
很多同学都会在Application.onCreate()
时,在某个地方把ApplicationContext
存起来,方便其他地方获取。然后,在DAO里面直接用这个Context
获取SharedPreferences
。按照笔者的方法,单元测试时,每个DAO都要传一个新创建的SharedPreferences
。但有的同学就是懒,有其他更好的方式吗?
你的代码可能是这样:
public class ContextProvider {
private static Context context;
public static Context getContext() {
return context;
}
public static void setContext(Context context) {
ContextProvider.context = context;
}
}
public class BookDAO {
SharedPreferences mSharedPre;
public BookDAO() {
Context context = ContextProvider.getContext();
mSharedPre = context.getSharedPreferences("book", Context.MODE_PRIVATE);
}
}
我们的问题是,如何让context.getSharedPreferences
返回一个SharedPreferences
。借助一下mockito来实现,修改SharedPrefRule
:
public class SharedPrefRule extends ExternalResource {
@Override
protected void before() throws Throwable {
Context context = mock(Context.class);
// 调用context.getSharedPreferences(name)时,执行SharedPreferencesHelper.getInstance(name),返回结果
doAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
String name = (String) invocation.getArguments()[0];
return SharedPreferencesHelper.getInstance(name);
}
}).when(context).getSharedPreferences(anyString(), anyInt());
// 设置Context
ContextProvider.setContext(context);
}
...
}
开源一个SharePreferences单元测试框架
本文的重头戏——开源框架!
听起来好像很屌的样子,其实就是那么几个类,见笑_. 上述的代码,笔者整理成项目,在github开源,并且发布到jitpack. 读者可以免费使用,通过gradle依赖。
开源框架命名很头痛,就叫SPTestFramework吧!
不需要Robolectric即可测试SharedPreferences,SPTestFramework你值得拥有!
SPTestFramework项目是什么?
SPTestFramework(简称SPTest)是一个SharedPreferences单元测试框架。项目自带单元测试,确保测试框架代码质量和功能正确。
同时,欢迎各位同学使用、测试、提出问题!
项目地址:https://github.com/kkmike999/SPTestFramework
关于作者
我是键盘男。
在广州生活,悦跑圈Android工程师,猥琐文艺码农。每天谋划砍死产品经理。喜欢科学、历史,玩玩投资,偶尔旅行。