2012年12月21日

pgTAPを使ってPostgreSQL上でデータベースの単体テストを行う

PostgreSQL Advent Calendar 2012(全部俺)のDay 21です。

PostgreSQLはその拡張性の高さが大きな特徴となっており、「プロシージャ言語」、いわゆる「PL」として、一般的なSQLやPL/pgSQLだけではなく、PerlやPython、RubyやV8なども使うことができます。

これらのPLを使うと、自分の馴染んだ言語、特に広く一般的に使われているLLで簡単にロジックを書き、これをデータベース内で実行することができるようになります。このことが、最近PostgreSQLがアプリケーション開発プラットフォームとして注目を集めてきている大きな理由の一つでしょう。

一方で、ロジックを実装するということは、そのロジックが正しく動くことを確認するためのテストを行わなければなりません。

というわけで、今回はPostgreSQL上で開発を行う場合にユニットテストに使えるツール「pgTAP」を紹介します。

■単体テストツール「pgTAP」とは


pgTAPは、David E. Wheeler氏によって開発されているPostgreSQL用の単体テストツールです。

pgTAP: Unit Testing for PostgreSQL
http://pgtap.org/

単体テストを実行するのに必要なさまざまなSQL関数を提供しており、テストスクリプト内でこれらのSQL関数を使うことで、スキーマの構造やプロシージャ(ユーザ定義関数)のロジックの正しさなどをテストすることができます。

データベースの単体テストをする場合、一般的にはスキーマの構造、テーブルの内容、プロシージャやユーザ定義関数の動作が主なテスト対象となると思われますので、ここではこれらに絞ってテストの実施方法を解説します。

■インストール方法


pgTAPはcontribモジュールと同じようにインストールすることができます。

コンパイルする際には、pg_configコマンド(通常はPostgreSQLをインストールしたbinディレクトリにあります)にPATHが通っていることを確認し、また、環境変数USE_PGXSを "1" に設定してmakeします。
[snaga@devsv02 pgtap-0.90.0]$ ls
Changes  doc       META.json           pgtap-schema.control  src
compat   getos.sh  pgtap.control       README.md             test
contrib  Makefile  pgtap-core.control  sql                   tocgen
[snaga@devsv02 pgtap-0.90.0]$ env PATH=/usr/pgsql-9.1/bin:$PATH USE_PGXS=1 make
cp sql/pgtap.sql.in sql/pgtap.sql
sed -e 's,MODULE_PATHNAME,$libdir/pgtap,g' -e 's,__OS__,linux,g' -e 's,__VERSION__,0.90,g' sql/pgtap.sql > sql/pgtap.tmp
mv sql/pgtap.tmp sql/pgtap.sql
(...snip...)
cp sql/pgtap.sql sql/pgtap--0.90.0.sql
cp sql/pgtap-core.sql sql/pgtap-core--0.90.0.sql
cp sql/pgtap-schema.sql sql/pgtap-schema--0.90.0.sql
[snaga@devsv02 pgtap-0.90.0]$
コンパイルできたら、rootになってmake installを実行します。
[snaga@devsv02 pgtap-0.90.0]$ su
Password:
[root@devsv02 pgtap-0.90.0]# env PATH=/usr/pgsql-9.1/bin:$PATH USE_PGXS=1 make install
/bin/mkdir -p '/usr/pgsql-9.1/share/extension'
/bin/mkdir -p '/usr/share/doc/pgsql/extension'
/bin/sh /usr/pgsql-9.1/lib/pgxs/src/makefiles/../../config/install-sh -c -m 644 ./pgtap.control ./pgtap-core.control ./pgtap-schema.control '/usr/pgsql-9.1/share/extension/'
/bin/sh /usr/pgsql-9.1/lib/pgxs/src/makefiles/../../config/install-sh -c -m 644 ./sql/pgtap--0.90.0.sql ./sql/pgtap-core--0.90.0.sql ./sql/pgtap-schema--0.90.0.sql ./sql/pgtap--unpackaged--0.26.0.sql ./sql/pgtap--0.90.0.sql ./sql/pgtap-core--0.90.0.sql ./sql/pgtap-schema--0.90.0.sql  '/usr/pgsql-9.1/share/extension/'
/bin/sh /usr/pgsql-9.1/lib/pgxs/src/makefiles/../../config/install-sh -c -m 644 ./doc/pgtap.mmd '/usr/share/doc/pgsql/extension/'
[root@devsv02 pgtap-0.90.0]#
ここまで完了すれば、あとはcontribモジュールと同じように、データベースに対して登録(9.1以降ならCREATE EXTENSION)を行います。
[snaga@devsv02 pgtap-0.90.0]$ psql -U postgres testdb
psql (9.1.2)
Type "help" for help.

testdb=# CREATE EXTENSION pgtap;
CREATE EXTENSION
testdb=# \q
[snaga@devsv02 pgtap-0.90.0]$

■単体テストの構造


pgTAPでテストを書く場合には、「基本的なお作法」と言えるスクリプトの構造があります。

まず、テストを開始する際には、トランザクションを開始し、plan()関数を呼び出して、これから実行しようとするテストの数を指定します。

わざわざplan()関数を呼び出してテストの数を指定するのは、どうやら「これから実行しようとしているテストについて、きちんと理解・把握しているべきだ」という思想からのようです。(ドキュメントを読む限り)

例えば、13個のユニットテストを実行するスクリプトの場合、
BEGIN;
SELECT plan(13);
のような形でスクリプトを始めます。

plan()関数を実行したら各種テストを実行します。各テストの書き方は後述します。

必要なテストを実行したら、最後にfinish()関数を呼び出してテストの完了を宣言し、トランザクションをロールバックします。
SELECT * FROM finish();
ROLLBACK;
これらを記述したテストスクリプトは以下のようになります。
BEGIN;
SELECT plan(1);

SELECT tables_are (
        'public',
        ARRAY[
        't1'
        ]
);

SELECT * FROM finish();
ROLLBACK;
このようなpgTAP用テストスクリプトをpsqlコマンドで実行すると、以下のような出力を得られます。
[snaga@devsv02 t]$ psql -f t1.sql testdb
BEGIN
 plan
------
 1..1
(1 row)

                     tables_are
-----------------------------------------------------
 ok 1 - Schema public should have the correct tables
(1 row)

 finish
--------
(0 rows)

ROLLBACK
[snaga@devsv02 t]$
ここでは、「tables_are」というテストをひとつだけ実行していますが、当該テストの結果は「Schema public should have the correct tables」となって成功しており、finish()関数の結果も何も無いことから、全体として問題なくテストが通ったことが分かります。

なお、テストに失敗すると、finish()関数が以下のような結果を返すので、それを見て判別することができます。
               finish
-------------------------------------
 # Looks like you failed 1 test of 1
(1 row)

■テーブルとカラムのテスト


それでは、まずはテーブルの存在のチェックからテストを行ってみます。テーブルの存在をチェックするためには tables_are() 関数を使います。

最初の引数はスキーマ名を、二番目の引数にはテーブル名のリスト(配列)を指定します。
SELECT tables_are (
        'pgperf',
        ARRAY[
        'snapshot',
        'snapshot_pg_stat_bgwriter'
        ]
);
成功すると、
                     tables_are
-----------------------------------------------------
 ok 1 - Schema pgperf should have the correct tables
(1 row)

失敗すると、
                           tables_are
-----------------------------------------------------------------
 not ok 1 - Schema pgperf should have the correct tables        +
 # Failed test 1: "Schema pgperf should have the correct tables"+
 #     Missing tables:                                          +
 #         snapshot_pg_stat_user_tables                         +
 #         snapshot_pg_database_size
(1 row)

といったメッセージが返されます。

上記のエラーメッセージでは snapshot_pg_stat_user_tables と snapshot_pg_database_size が足りないテーブル(Missing tables)として指摘されています。

次にカラムのテストを行います。指定したテーブル、指定した名前と型のカラムが存在しているかどうかを確認します。このテストには col_type_is() という関数を使います。

以下の例は、pgperfスキーマのsnapshot_pg_stat_bgwriterテーブルに、それぞれinteger型のsidカラム、bigint型のcheckpoints_timedカラムが存在しているかどうかをテストしています。
SELECT col_type_is('pgperf', 'snapshot_pg_stat_bgwriter', 'sid', 'integer', 'sid');
SELECT col_type_is('pgperf', 'snapshot_pg_stat_bgwriter', 'checkpoints_timed', 'bigint', 'checkpoints_timed');
カラムの有無のみを確認する(型のチェックをしない)has_column()という関数もあるのですが、col_type_is() 関数はカラムの有無もチェックしてくれるので、こちらを使えば十分でしょう。

成功すると、
 col_type_is
-------------
 ok 3 - sid
(1 row)
という結果を返し、失敗すると、
       col_type_is
-------------------------
 not ok 4 - sid         +
 # Failed test 4: "sid" +
 #         have: integer+
 #         want: bigint
(1 row)
という結果を返します。これは「bigintであることを期待しているのに(want)、実際にはintegerだった(have)」という意味です。

また、カラムそのものが存在しない場合には、
                           col_type_is
------------------------------------------------------------------
 not ok 4 - sid2                                                 +
 # Failed test 4: "sid2"                                         +
 #    Column pgperf.snapshot_pg_stat_bgwriter.sid2 does not exist
(1 row)
というエラーを返します。

■クエリ実行結果のテスト


次は、クエリの実行結果のテスト方法です。

以下のテーブルに対するクエリをサンプルとしてテストの書き方を見てみます。
testdb=> SELECT * FROM t1;
 uid |     uname
-----+----------------
 101 | Park Gyu-Ri
 102 | Han Seung-Yeon
 103 | Nicole
 104 | Koo Ha-Ra
 105 | Kang Ji-Young
(5 rows)
クエリを実行した結果をテストするためには、results_eq()関数を使います。

一つ目の引数は実行するクエリの文字列、二つ目のクエリは結果を記述します。

以下のように出力される結果が単一の値の場合、
testdb=> SELECT uname FROM t1 WHERE uid=103;
 uname
--------
 Nicole
(1 row)
出力は、単一の要素を持つ配列として定義します。
SELECT results_eq (
        'SELECT uname FROM t1 WHERE uid=103',
        ARRAY[ 'Nicole' ]
);
また、複数の値を返却するクエリの場合には、
testdb=> SELECT uname FROM t1 WHERE uid=103 OR uid=104;
   uname
-----------
 Nicole
 Koo Ha-Ra
(2 rows)
以下のように配列要素を増やすことで指定することができます。
SELECT results_eq (
        'SELECT uname FROM t1 WHERE uid=103 OR uid=104',
        ARRAY[ 'Nicole', 'Koo Ha-Ra' ]
);
また、レコードを返却するクエリの場合、
testdb=> SELECT * FROM t1 WHERE uid=103;
 uid | uname
-----+--------
 103 | Nicole
(1 row)
VALUESの表記を用いてレコードとして記述します。
SELECT results_eq (
        'SELECT * FROM t1 WHERE uid=103',
        $$ VALUES (103, 'Nicole') $$
);
レコードについても、複数の結果が返却される場合には、
testdb=> SELECT * FROM t1 WHERE uid=103 OR uid=104;
 uid |   uname
-----+-----------
 103 | Nicole
 104 | Koo Ha-Ra
(2 rows)
以下のように複数のレコードを記述することができます。
SELECT results_eq (
        'SELECT * FROM t1 WHERE uid=103 OR uid=104',
        $$ VALUES (103, 'Nicole'), (104, 'Koo Ha-Ra') $$
);

■プロシージャ、ユーザ定義関数のテスト


プロシージャやユーザ定義関数のテストも、クエリのテストと同じ方法で実行することができます。

SELECTクエリでプロシージャや関数を呼び出し、その結果を確認します。
SELECT results_eq(
        'SELECT pgperf.create_snapshot_pg_stat_bgwriter(1)',
        ARRAY[ true ]
);

■pg_proveコマンドによる一括実行と集計


なお、pgTAPによるテストの実行を支援するツールとして、同じ作者からpg_proveというツールがCPANで提供されています。

David E. Wheeler / TAP-Parser-SourceHandler-pgTAP - search.cpan.org
http://search.cpan.org/dist/TAP-Parser-SourceHandler-pgTAP/

このpg_proveは、pgTAPで使用するテストスクリプトを一括して実行して、その中に含まれるユニットテストの結果を集計してくれるツールで、実行すると「いくつのテストを実行したか、そのうちいくつ失敗したか、どのような失敗だったか」ということをレポートしてくれます。

以下はpg_proveコマンドを使ってテストを実行している様子ですが、「Files=1, Tests=18」とありますので、1つのファイルの中にある18のテストを実行し、その結果が「Result: PASS」、つまりすべて成功であったことを報告しています。
[snaga@devsv02 t]$ pg_prove -d testdb test_pg_stat_bgwriter.sql
t/test_pg_stat_bgwriter.sql .. ok
All tests successful.
Files=1, Tests=18,  0 wallclock secs ( 0.02 usr +  0.01 sys =  0.03 CPU)
Result: PASS
[snaga@devsv02 t]$ 
一方、以下はテストに失敗しているケースですが、「Failed tests: 4-5」、つまり4番目と5番目のテストに失敗しており、どこの結果が間違っていたのかもレポートされています。

また、結果として(当然ながら)テスト全体としても失敗(Result: FAIL)であったことをレポートしています。
[snaga@devsv02 t]$ pg_prove -d testdb test_pg_stat_bgwriter.sql
t/test_pg_stat_bgwriter.sql .. 1/21
# Failed test 4: "sid"
#         have: integer
#         want: bigint
# Failed test 5: "sid2"
#    Column pgperf.snapshot_pg_stat_bgwriter.sid2 does not exist
# Looks like you failed 2 tests of 21
t/test_pg_stat_bgwriter.sql .. Failed 2/21 subtests

Test Summary Report
-------------------
t/test_pg_stat_bgwriter.sql (Wstat: 0 Tests: 21 Failed: 2)
  Failed tests:  4-5
Files=1, Tests=21,  0 wallclock secs ( 0.02 usr +  0.01 sys =  0.03 CPU)
Result: FAIL
[snaga@devsv02 t]$ 

■まとめ


今回は、PostgreSQLでアプリケーションを開発する際に必要となる単体テストを実行するためツールであるpgTAPの基本的な使い方を紹介しました。

最初に述べたように、PostgreSQLの大きな特徴は拡張性であり、さまざまなプロシージャ言語でアプリケーションロジックをPostgreSQL上で動作させることができます。

また、単体テストのみならず、JenkinsなどのCIツールと組み合わせれば、さらにテストを自動化して開発プロセスの質を向上させることができるようになります。

ぜひ、こういったツールをうまく活用して、アプリケーション開発に役立てていただければと思います。

では、また。

0 件のコメント:

コメントを投稿