【PHPテスト入門 第6回】DBを使うテスト:テストデータの準備と後片付け

PHP
B!

はじめに

今回のゴールは、DBを実際に動かすテストを、速く・安定して書けるようになることです。

前回までは、在庫APIやメール送信のような外部の仕組みを、スタブやモックで置き換えてきました。しかし、DBを扱うコードは事情が違います。「SQLの書き方が正しいか」「データが本当に保存され、取り出せるか」は、偽物のDBでは確かめようがないからです。

そこで今回は、テストの中で本物のDBを動かします。第1回で紹介したテストピラミッドでいうと、ユニットテストの一つ上の層にあたる統合テストです。

この回を読み終えると、次のことができるようになります。

  • SQLiteのインメモリDBを使って、DBを使うクラスをテストできる
  • setUp() と tearDown() で、テストごとにDBを準備し、後片付けできる
  • 後片付けを怠ると何が起きるかを理解し、トランザクションでテストを独立させられる
  • テスト用DBと本番DBの違いによる落とし穴に注意できる

動作確認環境はこれまでと同じ、PHP 8.4.26、Composer 2.10.3、PHPUnit 13.3.5 です。今回はこれに加えて、PHPの pdo_sqlite 拡張を使います。記事中の実行結果は、すべてこの環境で実際に動かして取得したものです。

pdo_sqlite が有効かどうかは、php -m | grep -i sqlite で確認できます。pdo_sqlite と表示されれば準備完了です。HomebrewのPHPには最初から含まれています。WSLのUbuntuで表示されない場合は、sudo apt install php8.4-sqlite3 でインストールしてください。

題材:注文を保存するリポジトリ

前回の OrderService は注文を受け付けるだけで、どこにも記録していませんでした。今回は、注文をDBに保存し、取り出すためのクラスを作ります。

DBへの読み書きをひとまとめにしたクラスを、リポジトリと呼びます。SQLをリポジトリの中に閉じ込めておくと、ほかのクラスはSQLを意識せずに済みますし、テストの対象もはっきりします。

テーブル定義

プロジェクト直下に database/ フォルダを作り、database/schema.sql にテーブル定義を書きます。

CREATE TABLE IF NOT EXISTS orders (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL,
    sku TEXT NOT NULL,
    quantity INTEGER NOT NULL
);

注文を表すクラス

DBから取り出した1件の注文を表すクラスです。src/Order/Order.php に作成します。

<?php

declare(strict_types=1);

namespace App\Order;

final readonly class Order
{
    public function __construct(
        public int $id,
        public string $email,
        public string $sku,
        public int $quantity,
    ) {
    }
}

readonly class はPHP 8.2で追加された書き方で、すべてのプロパティが読み取り専用になります。一度作った注文の中身が、後から書き換わる心配がありません。

リポジトリ

src/Order/OrderRepository.php に作成します。DBへの接続(PDO)はコンストラクタで受け取ります。第4回で学んだ依存性注入です。

<?php

declare(strict_types=1);

namespace App\Order;

use PDO;

final class OrderRepository
{
    public function __construct(
        private readonly PDO $pdo,
    ) {
    }

    public function save(string $email, string $sku, int $quantity): int
    {
        $statement = $this->pdo->prepare(
            'INSERT INTO orders (email, sku, quantity) VALUES (:email, :sku, :quantity)'
        );
        $statement->execute([
            'email' => $email,
            'sku' => $sku,
            'quantity' => $quantity,
        ]);

        return (int) $this->pdo->lastInsertId();
    }

    public function find(int $id): ?Order
    {
        $statement = $this->pdo->prepare('SELECT * FROM orders WHERE id = :id');
        $statement->execute(['id' => $id]);
        $row = $statement->fetch(PDO::FETCH_ASSOC);

        return $row === false ? null : $this->toOrder($row);
    }

    /**
     * @return list<Order>
     */
    public function findByEmail(string $email): array
    {
        $statement = $this->pdo->prepare('SELECT * FROM orders WHERE email = :email ORDER BY id');
        $statement->execute(['email' => $email]);

        return array_map(
            fn (array $row): Order => $this->toOrder($row),
            $statement->fetchAll(PDO::FETCH_ASSOC),
        );
    }

    /**
     * @param array<string, mixed> $row
     */
    private function toOrder(array $row): Order
    {
        return new Order(
            id: (int) $row['id'],
            email: (string) $row['email'],
            sku: (string) $row['sku'],
            quantity: (int) $row['quantity'],
        );
    }
}
メソッド役割
save()注文を1件保存し、採番された注文番号(ID)を返す
find()注文番号で1件取り出す。見つからなければ null
findByEmail()メールアドレスで絞り込み、注文番号の順に並べて返す

このクラスで確かめたいのは、SQLの書き方やパラメータの受け渡し、取り出した値の型変換が正しいかどうかです。どれも、本物のDBを動かさなければ確かめられません。

SQLiteのインメモリDBでテストする

インメモリDBとは

SQLiteは、ファイル1つで動く軽量なDBです。さらに、接続先に sqlite::memory: を指定すると、ファイルすら作らず、メモリ上だけに一時的なDBを作ることができます。

インメモリDBには、テスト用として次のような利点があります。

  • MySQLなどのDBサーバーを用意しなくても、PHPだけで動く
  • メモリ上で動くので、非常に速い
  • 接続を閉じると中身ごと消えるので、後片付けが要らない

setUp() でテストごとに新しいDBを作る

tests/Order/OrderRepositoryTest.php を作成します。

<?php

declare(strict_types=1);

namespace Tests\Order;

use App\Order\OrderRepository;
use PDO;
use PHPUnit\Framework\TestCase;

final class OrderRepositoryTest extends TestCase
{
    private PDO $pdo;
    private OrderRepository $repository;

    protected function setUp(): void
    {
        $this->pdo = new PDO('sqlite::memory:');
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->pdo->exec((string) file_get_contents(__DIR__ . '/../../database/schema.sql'));

        $this->repository = new OrderRepository($this->pdo);
    }

    protected function tearDown(): void
    {
        unset($this->repository, $this->pdo);
    }

    public function test_保存した注文を番号で取り出せる(): void
    {
        $id = $this->repository->save('taro@example.com', 'APPLE-001', 2);

        $order = $this->repository->find($id);

        $this->assertNotNull($order);
        $this->assertSame('taro@example.com', $order->email);
        $this->assertSame('APPLE-001', $order->sku);
        $this->assertSame(2, $order->quantity);
    }

    public function test_存在しない番号ならnullを返す(): void
    {
        $this->assertNull($this->repository->find(999));
    }
}

setUp() で行っていることは、次の3つです。

  1. インメモリDBに接続する
  2. SQLのエラーを例外として投げる設定にする(PDO::ERRMODE_EXCEPTION)
  3. schema.sql を読み込んで、テーブルを作る

第3回で説明したとおり、setUp() はテストメソッドごとに毎回呼ばれます。つまり、どのテストも、空のテーブルが1つあるだけの、まっさらなDBから始まります。

tearDown() では、unset() でDBへの接続を手放しています。接続がなくなると、インメモリDBは中身ごと消えます。PHPUnitはテストが終わった後もテストクラスのオブジェクトを保持し続けるため、こうして明示的に手放しておくと、テストが数百件に増えたときのメモリ使用量を抑えられます。

2つ目の設定(ERRMODE_EXCEPTION)は必ず入れておきましょう。PHP 8.0以降はこれが初期値ですが、明示しておくと意図がはっきりします。SQLの書き間違いがあったとき、例外が投げられてテストが確実に失敗します。

保存と取り出しをテストする

1つ目のテストでは、保存した注文をもう一度取り出して、中身を1項目ずつ確かめています。「保存するSQL」と「取り出すSQL」の両方が正しくないと通らないテストです。

ここで assertSame(2, $order->quantity) と、型まで確かめていることに注目してください。PHP 8.0以前のPDOでは、DBから取り出した数値が文字列で返ってくるのが普通でした。PHP 8.1以降のSQLiteでは整数のまま返りますが、DBの種類や設定によって扱いが変わることもあります。toOrder() で (int) と明示的に変換しているのはそのためで、assertSame なら「2 のつもりが '2' だった」という食い違いも見逃しません。

絞り込みもテストする

public function test_メールアドレスで注文を絞り込める(): void
{
    $this->repository->save('taro@example.com', 'APPLE-001', 1);
    $this->repository->save('hanako@example.com', 'ORANGE-001', 5);
    $this->repository->save('taro@example.com', 'GRAPE-001', 3);

    $orders = $this->repository->findByEmail('taro@example.com');

    $this->assertCount(2, $orders);
    $this->assertSame('APPLE-001', $orders[0]->sku);
    $this->assertSame('GRAPE-001', $orders[1]->sku);
}

「絞り込み」をテストするときは、絞り込まれて除外されるべきデータも一緒に入れておくのがポイントです。hanako@example.com の注文を入れていなければ、WHERE 句が抜けていてもテストは通ってしまいます。

...                                                                 3 / 3 (100%)

OK (3 tests, 8 assertions)

【スクショ:OrderRepositoryTest の3件のテストが通った実行結果】

後片付けを忘れると、何が起きるか

インメモリDBは接続を閉じれば消えるので、後片付けを意識せずに済みました。では、実務でよくあるようにファイルやDBサーバーに残るDBを使う場合はどうでしょうか。後片付けを忘れたときに何が起きるか、実際に試してみます。

実験:ファイルのDBで、後片付けをしない

接続先をインメモリからファイル(test.sqlite)に変え、tearDown() での後片付けをしないテストを用意しました。中身は先ほどの絞り込みのテストと同じです。

protected function setUp(): void
{
    $pdo = new PDO('sqlite:' . __DIR__ . '/test.sqlite');
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->exec((string) file_get_contents(__DIR__ . '/../../database/schema.sql'));

    $this->repository = new OrderRepository($pdo);
}

// tearDown() なし

1回目の実行は、問題なく通ります。

.                                                                   1 / 1 (100%)

OK (1 test, 1 assertion)

ところが、何も変えずにもう一度実行すると、失敗します。

F                                                                   1 / 1 (100%)

There was 1 failure:

1) NoCleanupTest::test_メールアドレスで注文を絞り込める
Failed asserting that actual size 4 matches expected size 2.

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

【スクショ:同じテストが1回目は成功し、2回目は失敗している実行結果】

2件のはずが4件になっています。1回目のテストで保存した注文がファイルに残り、2回目の分と合わさってしまったのです。

これが第3回で触れた「テストの独立性」が崩れた状態です。この状態になると、次のような厄介な現象が起きます。

  • 1回目は通るのに、2回目から失敗する
  • テストを1つだけ実行すると通るのに、全部まとめて実行すると失敗する
  • 他の人のPCでは通るのに、自分のPCでは失敗する

どれも、コードにバグがあるわけではないので、原因を突き止めるのに苦労します。

解決策:トランザクションでロールバックする

一番手軽な解決策は、テストの前にトランザクションを開始し、テストの後にロールバックすることです。ロールバックすると、トランザクション中に行った変更はすべて取り消されます。

private PDO $pdo;
private OrderRepository $repository;

protected function setUp(): void
{
    $this->pdo = new PDO('sqlite:' . __DIR__ . '/test.sqlite');
    $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $this->pdo->exec((string) file_get_contents(__DIR__ . '/../../database/schema.sql'));
    $this->pdo->beginTransaction();

    $this->repository = new OrderRepository($this->pdo);
}

protected function tearDown(): void
{
    $this->pdo->rollBack();
}

setUp() の最後で beginTransaction() を呼び、tearDown() で rollBack() するだけです。3回続けて実行しても、毎回通ります。

OK (1 test, 1 assertion)
OK (1 test, 1 assertion)
OK (1 test, 1 assertion)

実行後にファイルの中身を確かめると、orders テーブルの件数は0件のままです。テストで保存したデータは、すべて取り消されています。

tearDown() は、テストが失敗したときでも必ず呼ばれます。アサーションが失敗してテストメソッドが途中で止まっても、ロールバックは実行されるので、失敗したテストのデータが次のテストに残ることはありません。

この方法には1つ注意点があります。テスト対象のコード自身が beginTransaction() や commit() を呼んでいると、テスト側のトランザクションとぶつかってしまいます。そうしたコードをテストするときは、テストごとにテーブルを空にする(DELETE FROM orders を実行する)方法を使います。第7回のLaravel編では、フレームワークがこのあたりを自動で面倒を見てくれる仕組みを紹介します。

テストデータの準備を楽にする

DBを使うテストでは、テストのたびにデータを何件も用意することになります。毎回すべての項目を書いていると、テストが長くなり、「このテストで大事なのはどの値なのか」が埋もれてしまいます。

関心のない項目は、ヘルパーのデフォルト値に任せる

テストクラスに、次のようなヘルパーメソッドを用意します。

/**
 * テストで関心のない項目には、ありふれた値を入れておく
 */
private function saveOrder(
    string $email = 'taro@example.com',
    string $sku = 'APPLE-001',
    int $quantity = 1,
): int {
    return $this->repository->save($email, $sku, $quantity);
}

すべての引数にデフォルト値があるので、テストではそのテストで意味のある項目だけを名前付き引数で指定すれば済みます。

public function test_メールアドレスで注文を絞り込める(): void
{
    $this->saveOrder(email: 'taro@example.com', sku: 'APPLE-001');
    $this->saveOrder(email: 'hanako@example.com');
    $this->saveOrder(email: 'taro@example.com', sku: 'GRAPE-001');

    $orders = $this->repository->findByEmail('taro@example.com');

    $this->assertCount(2, $orders);
    $this->assertSame('APPLE-001', $orders[0]->sku);
    $this->assertSame('GRAPE-001', $orders[1]->sku);
}

public function test_該当する注文がなければ空の配列を返す(): void
{
    $this->saveOrder(email: 'hanako@example.com');

    $this->assertSame([], $this->repository->findByEmail('taro@example.com'));
}

絞り込みのテストでは、数量はどうでもよい値です。数量を書かないことで、「このテストで大事なのはメールアドレスと商品コードだ」と読む人に伝わります。

....                                                                4 / 4 (100%)

OK (4 tests, 9 assertions)

名前付き引数はPHP 8.0から使える機能です。引数の順番を気にせず、必要なものだけを指定できるので、テストデータの準備と特に相性が良い書き方です。第7回で紹介するLaravelの「Factory」は、この考え方をさらに推し進めた仕組みです。

SQLiteと本番のDBの違いに注意する

SQLiteのインメモリDBは手軽で速い反面、本番でよく使われるMySQLやPostgreSQLとは細かな違いがあります。たとえばSQLiteは、INTEGER の列に文字列を入れても、エラーにせず受け入れてしまいます。

$pdo->exec("INSERT INTO orders (email, sku, quantity) VALUES ('taro@example.com', 'APPLE-001', 'たくさん')");

var_dump($pdo->query('SELECT quantity FROM orders')->fetchColumn());
string(12) "たくさん"

本番のDBなら型の違いでエラーになるはずの操作が、SQLiteのテストでは通ってしまうわけです。ほかにも、日付関数や AUTO_INCREMENT の書き方、文字列の大文字・小文字の扱いなど、DBごとに違いがあります。

こうした違いを踏まえて、次のように使い分けるのがおすすめです。

テスト用DB向いている場面注意点
SQLite(インメモリ)入門・学習、標準的なSQLだけを使うリポジトリ、とにかく速く回したいとき本番DBとの違いによるバグは見逃す
本番と同じ種類のDB本番と同じ条件で確かめたいとき、DB固有の機能を使うときDBサーバーの準備が必要(Dockerなどを使うのが一般的)

実務では、手元では速いSQLiteで回しつつ、第9回で扱うCI(自動テスト環境)では本番と同じDBで実行する、という組み合わせもよく使われます。

今回の完成コード

今回追加したファイルは次のとおりです。

php-test-cart/
├── database/
│   └── schema.sql                   ← 追加
├── src/
│   └── Order/
│       ├── Notifier.php
│       ├── Order.php                ← 追加
│       ├── OrderRepository.php      ← 追加
│       └── OrderService.php
└── tests/
    └── Order/
        ├── OrderRepositoryTest.php  ← 追加
        └── OrderServiceTest.php

schema.sql、Order.php、OrderRepository.php は本文に掲載したコードが完成形です。テストクラスは本文で分けて紹介したので、全体をまとめて載せておきます。

tests/Order/OrderRepositoryTest.php

<?php

declare(strict_types=1);

namespace Tests\Order;

use App\Order\OrderRepository;
use PDO;
use PHPUnit\Framework\TestCase;

final class OrderRepositoryTest extends TestCase
{
    private PDO $pdo;
    private OrderRepository $repository;

    protected function setUp(): void
    {
        $this->pdo = new PDO('sqlite::memory:');
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->pdo->exec((string) file_get_contents(__DIR__ . '/../../database/schema.sql'));

        $this->repository = new OrderRepository($this->pdo);
    }

    protected function tearDown(): void
    {
        unset($this->repository, $this->pdo);
    }

    /**
     * テストで関心のない項目には、ありふれた値を入れておく
     */
    private function saveOrder(
        string $email = 'taro@example.com',
        string $sku = 'APPLE-001',
        int $quantity = 1,
    ): int {
        return $this->repository->save($email, $sku, $quantity);
    }

    public function test_保存した注文を番号で取り出せる(): void
    {
        $id = $this->repository->save('taro@example.com', 'APPLE-001', 2);

        $order = $this->repository->find($id);

        $this->assertNotNull($order);
        $this->assertSame('taro@example.com', $order->email);
        $this->assertSame('APPLE-001', $order->sku);
        $this->assertSame(2, $order->quantity);
    }

    public function test_存在しない番号ならnullを返す(): void
    {
        $this->assertNull($this->repository->find(999));
    }

    public function test_メールアドレスで注文を絞り込める(): void
    {
        $this->saveOrder(email: 'taro@example.com', sku: 'APPLE-001');
        $this->saveOrder(email: 'hanako@example.com');
        $this->saveOrder(email: 'taro@example.com', sku: 'GRAPE-001');

        $orders = $this->repository->findByEmail('taro@example.com');

        $this->assertCount(2, $orders);
        $this->assertSame('APPLE-001', $orders[0]->sku);
        $this->assertSame('GRAPE-001', $orders[1]->sku);
    }

    public function test_該当する注文がなければ空の配列を返す(): void
    {
        $this->saveOrder(email: 'hanako@example.com');

        $this->assertSame([], $this->repository->findByEmail('taro@example.com'));
    }
}

プロジェクト全体のテストを実行すると、これまでの分と合わせて25件がすべて通ります。

.........................                                         25 / 25 (100%)

OK (25 tests, 38 assertions)

まとめ

  • SQLやデータの保存・取り出しは、本物のDBを動かす統合テストで確かめる
  • SQLiteのインメモリDBなら、DBサーバーなしで速くテストでき、接続を閉じれば中身ごと消える
  • setUp() でテストごとにまっさらなDBを用意し、テスト同士が影響し合わないようにする
  • 残るDBを使うときは、トランザクションを開始してテスト後にロールバックする。tearDown() は失敗時も必ず呼ばれる
  • 絞り込みのテストには「除外されるべきデータ」も入れる。関心のない項目はヘルパーのデフォルト値に任せる
  • SQLiteと本番DBには違いがある。本番と同じ条件で確かめる場も用意する

次回予告

第7回からは第2部、Laravel編に入ります。ここまで素のPHPで手作りしてきたテストの仕組み(DBの準備と後片付け、テストデータの作成、依存性注入)を、Laravelはフレームワークの機能として用意しています。RefreshDatabase、Factory、HTTPテストを使いながら、第1部で学んだ考え方がLaravelのどの機能に対応するのかを見ていきます。

参考資料

B!
← 一覧へ戻る