プログラムの動作は「入力を処理して出力する」ことの繰り返しです。出力先がデータベースであった場合、ソフトウェアテストでデータベースの内容を確認したくなります。
データベース(バイナリ)自体のアサーションは不一致した時の検証が困難なので、データを取り出した時のCursorの確認できれば良いのですが、アサーションは用意されていません。
なので、Cursorの一致を確認するTruthのSubjectを作成してみました。
目次
SQLite3
AndroidはSQLite3を採用
データベースシステムの有名なものと言えば、MySQL・PostgreSQLがあります。
この2つはサーバ&クライアントモデルで動作するデータベースシステムです。規格:SQL99に対応する本格的なシステムです。
一方、Androidは標準でSQLiteというデータベースシステムを採用しています。
SQLiteは単独のホストで動作するデータベースシステムです。規格:SQL92に対応していて、前の2つに比べると機能面で劣っていますが、データベースの基本的な機能を持ちつつ、動作が軽量なのが特徴です。

AndroidのAPIはSQLiteへアクセスするためのライブラリが組み込まれているので容易に利用できます。
SQLiteのさらなる情報は「SQLite」を参照してください。
登録可能なデータ型
SQLiteのデータベースに登録可能なデータ型は表の通りです。
| 登録可能なデータ型 | Kotlinのデータ型 | コメント |
|---|---|---|
| integer(1,2,4,8 byte) | Byte(1 byte) Short(2 byte) Int(4 byte) Long(8 byte) | Kotlinで扱われる整数はSQLiteのinteger型になる サイズは各々の型のバイト数で登録される |
| real(8 byte) | Float(4 byte) Double(8 byte) | Kotlinで扱われる実数はSQLiteのreal型になる サイズは必ず倍精度(8 byte)で登録される |
| text(L byte) | String(L byte) | AndroidはUTF-8が使われるのでUTF-8で登録される ※L:文字数 |
| null(0 byte) | Null | データのサイズ0 byteの時、nullとして扱われる |
| blob(N byte) | ByteArray(N byte) | BLOBはBinary Large OBjectの略 生のバイトデータがそのまま登録される ※N:要素数 |
| ※()内はサイズ | ||
表は「登録可能なデータ型」であって、「フィールドのデータ型」でないことに注意してください。
データベースに登録されるデータはレコード毎にデータ型が管理されています。従って、同一のフィールドだとしても、レコードが異なればデータ型も異なるものが持てます。
例えば、下記のようなデータベースが可能です。「フィールド:key」にデータ型の指定がありません。
class AllTypeDb(val context: Context, val name: String = "AllType.db", val version: Int = 1)
: SQLiteOpenHelper(context, name, null, version) {
private val DB_TABLE = "alltype"
private val COLUMNS = arrayOf(
"_id",
"key"
)
override fun onCreate(db: SQLiteDatabase) {
val _buff = StringBuffer()
_buff.append("create table ")
_buff.append(DB_TABLE)
_buff.append(" (")
_buff.append("_id integer primary key autoincrement,")
_buff.append("key") // <--- フィールドにデータ型の指定がない
_buff.append(")")
db.execSQL(_buff.toString())
}
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {}
...
fun insert(value: Int?): Long {
val _content = ContentValues()
_content.put("key", value) // <--- Int型を登録
return writableDatabase.insert(DB_TABLE, null, _content)
}
...
fun insert(value: Float?): Long {
val _content = ContentValues()
_content.put("key", value) // <--- Float型を登録
return writableDatabase.insert(DB_TABLE, null, _content)
}
...
fun insert(value: String?): Long {
val _content = ContentValues()
_content.put("key", value) // <--- String型を登録
return writableDatabase.insert(DB_TABLE, null, _content)
}
...
}
「フィールド:key」はデータ型の指定がないので、SQLiteは登録が要求されたデータを見てデータ型を判別します。その判別結果に従ってデータを登録し、登録されたデータの型が表で示したものになります。
ちなみに、フィールドに指定するデータ型は利用者側で「このフィールドはこのデータ型で使う」と制限を与えているに過ぎません。
ブール値の扱い
SQLiteは登録可能なデータ型にboolean型を持っていません。
ドキュメントに次の記述があります。
SQLite does not have a separate Boolean storage class. Instead, Boolean values are stored as integers 0 (false) and 1 (true).
-----
SQLiteは登録可能なboolean型を持ちません。代わりに、ブール値はinteger型の0(false)と1(true)として登録されます。
データベースへ0と1で登録できたとしても、取り出した時に0と1のままではブール値として役に立ちません。よって、変換が必要です。
val _value = (_cursor.getInt(_cursor.getColumnIndex("key")) == 1)
CusrorSubjectの作成
Subjectを継承して作成します。
作成方法は「TruthのSubjectの作り方」を参照してください。
Subject本体
actual(実測値)とexpexct(期待値)間で「レコード(Row)の数と順番」、「フィールド(Column)の数の順番」、「全フィールドの値」の3つが一致した時にパスする仕様です。
単純に、Cursor内のフィールド値を2重のfor文で走査しながら、各々の一致確認を行っているプログラムです。
class CursorSubject
private constructor(metadata: FailureMetadata, private val actual: Cursor)
: Subject(metadata, actual) {
private var ignoredColumn = -1
private var _failedRow: Int = -1
private var _failedColumn: Int = -1
// ----- テスト
fun isEqualTo(expect: Cursor) {
val _columnMatch = actual.columnNames.contentEquals(expect.columnNames)
val _countMatch = (actual.count == expect.count)
if(! _columnMatch || ! _countMatch) {
failWithActual("is equal to", expect.toString())
return
}
for(i in 0 until expect.count) {
expect.moveToPosition(i)
actual.moveToPosition(i)
for(j in 0 until expect.columnCount) {
if(ignoredColumn == j) continue
val _type = expect.getType(j)
val _contentsMatch = when(_type) {
Cursor.FIELD_TYPE_INTEGER -> {
val _expect = expect.getLongOrNull(j)
val _actual = actual.getLongOrNull(j)
val _typeMatch = (actual.getType(j) == Cursor.FIELD_TYPE_INTEGER)
_typeMatch && (_expect == _actual)
}
Cursor.FIELD_TYPE_FLOAT -> {
val _expect = expect.getDoubleOrNull(j)
val _actual = actual.getDoubleOrNull(j)
val _typeMatch = (actual.getType(j) == Cursor.FIELD_TYPE_FLOAT)
_typeMatch && (_expect == _actual)
}
Cursor.FIELD_TYPE_STRING -> {
val _expect = expect.getStringOrNull(j)
val _actual = actual.getStringOrNull(j)
val _typeMatch = (actual.getType(j) == Cursor.FIELD_TYPE_STRING)
_typeMatch && (_expect == _actual)
}
Cursor.FIELD_TYPE_NULL -> {
(actual.getType(j) == Cursor.FIELD_TYPE_NULL)
}
Cursor.FIELD_TYPE_BLOB -> {
val _expect = expect.getBlobOrNull(j)
val _actual = actual.getBlobOrNull(j)
val _typeMatch = (actual.getType(j) == Cursor.FIELD_TYPE_BLOB)
_typeMatch && _expect.contentEquals(_actual)
}
else -> false
}
if(! _contentsMatch) {
_failedRow = i
_failedColumn = j
failWithActual("is equal to", getSummary(expect, _failedRow, _failedColumn))
return
}
}
}
}
fun withIgnored(column: Int): CursorSubject {
this.ignoredColumn = column
return this
}
override fun actualCustomStringRepresentation(): String { // Fail時のActualの表示を定義
return getSummary(actual, _failedRow, _failedColumn)
}
// ----- ファクトリー
companion object {
fun <T : Cursor> assertThat(cursor: T): CursorSubject {
return Truth.assertAbout(cursors()).that(cursor)
}
fun <T : Cursor> cursors(): Factory<CursorSubject, T> {
return Factory {
metadata, actual -> CursorSubject(metadata, actual)
}
}
}
}
ポイントはCursor#getType(column)(登録されたデータ型を返す)をもとに算出している_typeMatchです。
_typeMatchは100.0f(FIELD_TYPE_FLOAT)と100(FIELD_TYPE_INTEGER)の違い(falseになる)を判別します。
データが100.0fと100の場合は値が同じなので、”==”の比較だけでは_contentsMatchが一致(trueになる)になってしまいます。これを回避しています。
Fail情報(Cursorの内容を出力)
private fun getSummary(cursor: Cursor, row: Int, column: Int): String {
val _buff = StringBuffer()
_buff.append(cursor.toString()).append('\n')
cursor.columnNames.forEach {
_buff.append(it).append(',')
}
_buff.deleteCharAt(_buff.lastIndex).append('\n')
if(row >= 0) {
cursor.moveToPosition(row)
_buff.append("${row}(${column}):")
for (i in 0 until cursor.columnCount) {
when(cursor.getType(i)) {
Cursor.FIELD_TYPE_INTEGER ->
_buff.append(cursor.getLongOrNull(i).toString()).append(',')
Cursor.FIELD_TYPE_FLOAT ->
_buff.append(cursor.getDoubleOrNull(i).toString()).append(',')
Cursor.FIELD_TYPE_STRING ->
_buff.append(cursor.getStringOrNull(i)).append(',')
Cursor.FIELD_TYPE_NULL ->
_buff.append("null").append(',')
Cursor.FIELD_TYPE_BLOB ->
_buff.append(getArrayString(cursor.getBlobOrNull(i))).append(',')
else -> {}
}
}
_buff.deleteCharAt(_buff.lastIndex).append('\n')
}
return _buff.toString()
}
private fun getArrayString(array: ByteArray?): String {
val _buff = StringBuffer()
array?.let {
if (it.size > 5) {
_buff.append("[0x%02x, 0x%02x, 0x%02x, 0x%02x, 0x%02x, ...]"
.format(it[0], it[1], it[2], it[3], it[4]))
} else {
_buff.append('[')
it.forEach { _buff.append("0x%02x, ".format(it)) }
_buff.deleteCharAt(_buff.lastIndex).deleteCharAt(_buff.lastIndex)
_buff.append(']')
}
} ?: _buff.append("null")
return _buff.toString()
}
期待値の作成(MatrixCursorの使用)
期待値はMatrixCursorクラスを用いて作成します。
MatrixCursorクラスはCursorインターフェイスが実装されていて、addRow()関数でレコード(Row)が追加できるようになっています。
通常のデータベースはデータの取り出し元がデータベースファイルですが、MatrixCursorの場合は内部に確保されたObjectクラスの配列です。
Objectであるため、先に述べた「ブール値の扱い」は適用されません。Boolean型がそのまま登録できます。期待値の作成で注意が必要です。
val _columns = arrayOf(
"_id",
"byteValue", "shortValue", "intValue", "longValue",
"floatValue", "doubleValue",
"stringValue",
"booleanValue",
"blobValue"
)
val _expect = MatrixCursor(_columns).apply {
addRow(arrayOf(
1, 0x10, 100, 1000, 10000, 0.1f, 0.001, "aaa", 1, // この行の末尾はBoolean
byteArrayOf(0x10, 0x20)))
addRow(arrayOf(
5, 0x20, 200, 2000, 20000, 0.2f, 0.002, "bbb", 0, // この行の末尾はBoolean
byteArrayOf(0x20, 0x40, 0x60)))
addRow(arrayOf(
3, 0x30, null, 3000, 30000, 0.3f, 0.003, "ccc", 0, // この行の末尾はBoolean
byteArrayOf(0x30)))
}
CursorSubjectの使い方
サンプルのデータベース(SampleDb)に対してSubjectの動作を確認しました。
@Test
fun sampleDb_test() {
...
sampleDb.insert(
0x10, 100, 1000, 10000, 0.1f, 0.001, "aaa", true,
byteArrayOf(0x10, 0x20))
sampleDb.insert(
0x20, 200, 2000, 20000, 0.2f, 0.002, "bbb", false,
byteArrayOf(0x20, 0x40, 0x60))
sampleDb.insert(
0x30, null, 3000, 30000, 0.3f, 0.003, "ccc", false,
byteArrayOf(0x30))
val _actual = sampleDb.fetch()
assertThat(_actual).isEqualTo(_expect) // Failする
assertThat(_actual).withIgnored(0).isEqualTo(_expect) // Passする
}
is equal to: android.database.MatrixCursor@49d3219 _id,byteValue,shortValue,intValue,longValue,floatValue,doubleValue,stringValue,booleanValue,blobValue 1(0):5,32,200,2000,20000,0.2,0.002,bbb,0,[0x20, 0x40, 0x60] but was: android.database.sqlite.SQLiteCursor@48822de _id,byteValue,shortValue,intValue,longValue,floatValue,doubleValue,stringValue,booleanValue,blobValue 1(0):2,32,200,2000,20000,0.2,0.002,bbb,0,[0x20, 0x40, 0x60]
CursorSubject#withIgnored(column)で特定のフィールドを無視することが出来ます。
例では、”_id”フィールドを無視しています。
IDはデータベースの編集を繰り返すとバラバラになります。そんな時に”_id”フィールドを無視させて、コンテンツ部分のみを確認します。
