Cursorの一致を確認するTruthのSubject

投稿日:  更新日:

プログラムの動作は「入力を処理して出力する」ことの繰り返しです。出力先がデータベースであった場合、ソフトウェアテストでデータベースの内容を確認したくなります。

データベース(バイナリ)自体のアサーションは不一致した時の検証が困難なので、データを取り出した時の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”フィールドを無視させて、コンテンツ部分のみを確認します。

サンプルのデータベース(SampleDb)
class SampleDb(val context: Context, val name: String = "Sample.db", val version: Int = 1)
    : SQLiteOpenHelper(context, name, null, version) {

    private val DB_TABLE = "sample"
    private val COLUMNS = arrayOf(
        "_id",
        "bytevalue", "shortValue", "intValue", "longValue",
        "floatValue", "doubleValue",
        "stringValue",
        "booleanValue",
        "blobValue"
    )

    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("byteValue integer,")
        _buff.append("shortValue integer,")
        _buff.append("intValue integer,")
        _buff.append("longValue integer,")
        _buff.append("floatValue real,")
        _buff.append("doubleValue real,")
        _buff.append("stringValue text,")
        _buff.append("booleanValue integer,")
        _buff.append("blobValue blob")
        _buff.append(")")
        db.execSQL(_buff.toString())
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {}

    fun insert(
        byteValue: Byte?, shortValue: Short?, intValue: Int?, longValue: Long?,
        floatValue: Float?, doubleValue: Double?,
        stringValue: String?,
        booleanValue: Boolean?,
        blobValue: ByteArray?
    ): Long {
        val _content = ContentValues()
        _content.put("byteValue", byteValue)
        _content.put("shortValue", shortValue)
        _content.put("intValue", intValue)
        _content.put("longValue", longValue)
        _content.put("floatValue", floatValue)
        _content.put("doubleValue", doubleValue)
        _content.put("stringValue", stringValue)
        _content.put("booleanValue", booleanValue)
        _content.put("blobValue", blobValue)
        return writableDatabase.insert(DB_TABLE, null, _content)
    }

    fun fetch(): Cursor {
        return writableDatabase.query(DB_TABLE, COLUMNS, null, null, null, null, null)
    }

    fun delete() {
        val _content = ContentValues().apply { put("seq", 0) }
        writableDatabase.update("sqlite_sequence", _content, "name = ?", arrayOf(DB_TABLE))
        writableDatabase.delete(DB_TABLE, null, null)
    }
}
スポンサーリンク
スポンサーリンク