前言
上篇《Android單元測(cè)試 - 幾個(gè)重要問(wèn)題》 講解了“何解決Android依賴价匠、隔離Native方法、靜態(tài)方法央星、RxJava異步轉(zhuǎn)同步”這幾個(gè)Presenter單元測(cè)試中常見(jiàn)問(wèn)題霞怀。如果讀者你消化得差不多,就接著看本篇吧莉给。
在日常開(kāi)發(fā)中毙石,數(shù)據(jù)儲(chǔ)存是必不可少的。例如颓遏,網(wǎng)絡(luò)請(qǐng)求到數(shù)據(jù)徐矩,先存本地,下次打開(kāi)頁(yè)面叁幢,先從本地讀取數(shù)據(jù)顯示滤灯,再?gòu)姆?wù)器請(qǐng)求新數(shù)據(jù)。既然如此重要曼玩,對(duì)這塊代碼進(jìn)行測(cè)試鳞骤,也成為單元測(cè)試的重中之重了。
筆者在學(xué)會(huì)單元測(cè)試前黍判,也像大多數(shù)人一樣豫尽,寫(xiě)好了sql代碼,運(yùn)行app顷帖,報(bào)錯(cuò)了....檢查代碼美旧,修改,再運(yùn)行app....這真是效率太低了贬墩。有了單元測(cè)試做武器后榴嗅,我寫(xiě)DAO代碼輕松了不少,不擔(dān)心出錯(cuò)陶舞,效率也高嗽测。
常用的數(shù)據(jù)儲(chǔ)存有:sqlite、SharedPreference肿孵、Assets论咏、文件。由于這前三種儲(chǔ)取數(shù)據(jù)方式颁井,都必須依賴android環(huán)境厅贪,因此要進(jìn)行單元測(cè)試,不能僅僅用junit & mockito了雅宾,需要另外的單元測(cè)試框架养涮。接下來(lái)葵硕,筆者介紹如何使用robolectric進(jìn)行DAO單元測(cè)試。
縮寫(xiě)解釋:DAO (Data Access Object) 數(shù)據(jù)訪問(wèn)對(duì)象
Robolectric配置
Robolectric官網(wǎng):http://robolectric.org/
Robolectric配置很簡(jiǎn)單的贯吓。
build.gradle
:
dependencies {
testCompile "org.robolectric:robolectric:3.1.2"
}
然后在測(cè)試用例XXTest
加上注解:
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class XXTest {
}
配置代碼是寫(xiě)完了懈凹。
不過(guò),別以為這樣就完了悄谐。Robolectric最麻煩就是下載依賴介评! 由于我們生活在天朝,下載國(guó)外的依賴很慢爬舰,即使有了翻墻们陆,效果也一般。解決辦法《加速Robolectric下載依賴庫(kù)及原理剖析》
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數(shù)據(jù)操作類 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);
}
/**
* 獲取對(duì)應(yīng)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;
}
/**
* 檢查表是否存在情屹,不存在則創(chuàng)建表
*/
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; // 上次操作已確定表已存在于數(shù)據(jù)庫(kù)坪仇,直接返回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已創(chuàng)建,下次執(zhí)行isTableExist()時(shí)垃你,直接返回true
return true;
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (cursor != null) {
cursor.close();
}
cursor = null;
}
return false;
}
}
以上是你在項(xiàng)目中用到的類椅文,當(dāng)然數(shù)據(jù)庫(kù)一般開(kāi)發(fā)者都會(huì)用第三方庫(kù),例如:greenDAO惜颇、ormlite皆刺、dbflow、afinal凌摄、xutils....這里考慮到代碼演示規(guī)范性羡蛾、通用性,就直接用android提供的SQLiteDatabase望伦。
大家注意到BeanDAO
的構(gòu)造函數(shù):
public BeanDAO() {
this.db = new DbHelper(App.getContext(), "Bean").getWritableDatabase();
}
這種在內(nèi)部創(chuàng)建對(duì)象的方式,不利于單元測(cè)試煎殷。App
是項(xiàng)目本來(lái)的Application
屯伞,但是使用Robolectric往往會(huì)指定一個(gè)測(cè)試專用的Application
(命名為RoboApp
,配置方法下面會(huì)介紹)豪直,這么做好處是隔離App
的所有依賴劣摇。
隔離原Application依賴
項(xiàng)目原本的App
:
public class App extends Application {
private static Context context;
@Override
public void onCreate() {
super.onCreate();
context = this;
// 各種第三方初始化,有很多依賴
...
}
public static Context getContext() {
return context;
}
}
而單元測(cè)試使用的RoboApp
:
public class RoboApp extends Application {}
如果用Robolectric單元測(cè)試弓乙,不配置RoboApp
末融,就會(huì)調(diào)用原來(lái)的App
,而App
有很多第三方庫(kù)依賴暇韧,常見(jiàn)的有static{ Library.load() }
靜態(tài)加載so庫(kù)勾习。于是,執(zhí)行App
生命周期時(shí)懈玻,robolectric就報(bào)錯(cuò)了巧婶。
正確配置Application
方式,是在單元測(cè)試XXTest
加上@Config(application = RoboApp.class)
。
改進(jìn)DAO類
public class BeanDAO {
SQLiteDatabase db;
public BeanDAO(SQLiteDatabase db) {
this.db = db;
}
// 可以保留原來(lái)的構(gòu)造函數(shù)艺栈,只是單元測(cè)試不用這個(gè)方法而已
public BeanDAO() {
this.db = new DbHelper(App.getContext(), "Bean").getWritableDatabase();
}
單元測(cè)試
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 {
// 用隨機(jī)數(shù)做數(shù)據(jù)庫(kù)名稱英岭,讓每個(gè)測(cè)試方法,都用不同數(shù)據(jù)庫(kù)湿右,保證數(shù)據(jù)唯一性
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, "鍵盤(pán)男");
dao.insert(bean);
Bean retBean = dao.get(1);
Assert.assertEquals(retBean.getId(), 1);
Assert.assertEquals(retBean.getName(), "鍵盤(pán)男");
}
}
DAO單元測(cè)試跟Presenter有點(diǎn)不一樣诅妹,可以說(shuō)會(huì)更簡(jiǎn)單、直觀毅人。Presenter單元測(cè)試會(huì)用mock去隔離一些依賴吭狡,并且模擬返回值,但是sqlite執(zhí)行是真實(shí)的堰塌,不能mock的赵刑。
正常情況,insert()
和get()
應(yīng)該分別測(cè)試场刑,但這樣非常麻煩般此,必然要在測(cè)試用例寫(xiě)sqlite語(yǔ)句,并且對(duì)SQLiteDatabase 操作牵现☆戆茫考慮到數(shù)據(jù)庫(kù)操作的真實(shí)性,筆者把insert
和get
放在同一個(gè)測(cè)試用例:如果insert()
失敗瞎疼,那么get()
必然拿不到數(shù)據(jù)科乎,testInsertAndGet()
失敗贼急;只有insert()
和get()
代碼都正確茅茂,testInsertAndGet()
才能通過(guò)。
由于用Robolectric太抓,所以單元測(cè)試要比直接junit要慢空闲。僅junit跑單元測(cè)試,耗時(shí)基本在毫秒(ms)級(jí)走敌,而robolectric則是秒級(jí)(s)碴倾。不過(guò)怎么說(shuō)也比跑真機(jī)、模擬器的單元測(cè)試要快很多掉丽。
SharedPreference
其實(shí)跌榔,SharedPreference道理跟sqlite一樣,也是對(duì)每個(gè)測(cè)試用例創(chuàng)建單獨(dú)SharedPreference捶障,然后保存僧须、查找
一起測(cè)。
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, "");
}
}
單元測(cè)試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");
}
}
測(cè)試通過(guò)了项炼。是不是很簡(jiǎn)單皆辽?
請(qǐng)繼續(xù)看《SharePreference單元測(cè)試超級(jí)簡(jiǎn)單柑蛇!》,不需要robolectric驱闷,單元測(cè)試跑更快耻台!
Assets
Robolectric對(duì)Assets支持也是相當(dāng)不錯(cuò)的,測(cè)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 "";
}
}
單元測(cè)試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");
}
}
通過(guò)了通過(guò)了,非常簡(jiǎn)單扼菠!
文件操作
日常開(kāi)發(fā)中摄杂,文件操作相對(duì)比較少。由于通常都在真機(jī)測(cè)試循榆,有時(shí)目錄析恢、文件名有誤導(dǎo)致程序出錯(cuò),還是挺煩人的秧饮。所以映挂,筆者教大家在本地做文件操作單元測(cè)試。
Environment.getExternalStorageDirectory()
APP運(yùn)行時(shí)盗尸,通過(guò)Environment.getExternalStorageDirectory()
等方法獲取android儲(chǔ)存目錄柑船,因此,只要我們改變Environment.getExternalStorageDirectory()
返回的目錄泼各,就可以在單元測(cè)試時(shí)鞍时,讓jvm寫(xiě)操作指向本地目錄。
在《Android單元測(cè)試 - 幾個(gè)重要問(wèn)題》 介紹過(guò)如何解決android.text.TextUtils
依賴扣蜻,那么android.os.Environment
也是故伎重演:
在test/java
目錄下逆巍,創(chuàng)建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
就行锐极。
代碼
寫(xiě)完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單元測(cè)試
@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");
// 一定要?jiǎng)h除測(cè)試文件溪烤,保留的文件會(huì)影響下次單元測(cè)試
fileDAO.delete(name);
}
}
注意味咳,用Environment.getExternalStorageDirectory()
是不需要robolectric的庇勃,直接junit即可;而context.getCacheDir()
需要robolectric槽驶。
小技巧
如果你嫌麻煩每次都要寫(xiě)@RunWith(RobolectricTestRunner.class)
&@Config(...)
责嚷,那么可以寫(xiě)一個(gè)基類:
@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的測(cè)試用例掂铐,直接繼承RoboCase
即可罕拂。
小結(jié)
我想揍异,大家應(yīng)該感覺(jué)到,Sqlite爆班、SharedPreference衷掷、Assets、文件操作幾種單元測(cè)試柿菩,形式都差不多戚嗅。有這種感覺(jué)就對(duì)了,舉一反三枢舶。
本篇文字描述不多懦胞,代碼比例較大,相信讀者能看懂的凉泄。
如果讀者對(duì)Presenter躏尉、DAO單元測(cè)試運(yùn)用自如涯保,那應(yīng)該跟筆者水平相當(dāng)了弊决,哈哈哈。下一篇會(huì)介紹如何優(yōu)雅地測(cè)試傳參對(duì)象缕坎,敬請(qǐng)期待吼具!
關(guān)于作者
我是鍵盤(pán)男僚纷。
在廣州生活,在創(chuàng)業(yè)公司上班拗盒,猥瑣文藝碼農(nóng)怖竭。喜歡科學(xué)、歷史陡蝇,玩玩投資痊臭,偶爾獨(dú)自旅行。