💯

実践テスト駆動開発!ESのクエリビルダをテスト駆動で実装してみる

2022/09/19に公開

みなさんこんにちは。

みなさんはテスト駆動開発をしたことはあるでしょうか?
これは、テストを先に書いて、そのテストが通るようにまず実装をし、テストが通る状態を維持しつつコードをきれいに整え、そしてまた次のテストを書く...というサイクルを繰り返す開発手法です。
今回はテスト駆動開発について簡単におさらいしつつ、TDDを使って面倒くさそうな実装の見通しをよくできることをESのクエリビルダの実装を例に挙げてみていきましょう。

テストが駆動するもの

さて、このテスト駆動開発ですが、考え方自体は実はそんなにすごいものではなく、「とりあえず動くように作って、後できれいにしちゃおうぜ!」というくらいのものです。反対の考え方としては「初めに綿密に設計し、一撃できれいな実装を作ろう」というものもあります。
これら二つの考えは、まあ、どちらもいいとは思うのですが、難しいのは「どれくらいの塩梅でやればいいのか」です。

完了の定義

以下の図は上で述べた考え方をポンチ絵にしたものです。
実装のパターン
程度の差はあれ、皆さんが実装するときには、多少程度は違うかもしれませんが、この二つのどちらかで動いているのではと思います。

意外と大変なのは、成果物を出すまでの過程における完了の定義になります。「しっかり設計」ってどうやって示す?「とりあえず動く」っていってもどこまで動いていたらいいの?設計ってコード設計書みたいの書くのか?「とりあえず動く」ものって、そのまま本番持っていけるのか?

普段実装していると気にしていないというか、考慮していないかもしれませんが、どういう風に「実装完了した」ってことにしているの?を言語化しようとするのは、案外難しいものです。

テストによって明確化する「完了」

テスト駆動開発は、テストを導入することで、「とりあえず動くものを作る」実装方法において、明確な完了の定義を設定することができます。つまり以下のようになります。

  1. タスクの中でできていてほしいことをリストアップする(TODOリストの作成)
  2. リストアップしたものに対してテストを書く
  3. テストが通るように実装し、全部のテストが通ったらとりあえず完了
  4. テストが通る状態を維持しつつ、現時点で自分の思う一番いい感じの実装に整える

テスト駆動開発では、はじめにやるべきことをリストアップすることでゴールを設定していますし、それに対応するテストが全部通っていることを通して、実装のひとまずの完了を明確に意識できるようにしています。
最後の一つはどこまでやったら完了かわからないように思いますが、「とりあえず動いている」時点で、いったん成果物としては最低限をクリアえしているわけですので、究極的には「自分が満足する」状態であればいいのではと思います。

業務などでペアプロを導入しているのであれば、TODOリストの作成でお互いにゴールを確認しあったり、最後の一番良い実装についても相談し合えるので、テスト駆動開発は相性のいい開発手法であると覆います。

「とりあえず始める」ができる

とりあえず始めるという行動が、実は結構難しい行動であることは、経験したことがあるかもしれません。複雑もしくは実現方法がわからないタスクについて「とりあえず始め」ても、手が進まないことがあります。
テスト駆動開発はこういうときにも力を発揮します。
少なくとも、タスクで実現しておきたいTODOリストを作る、という作業を始めることはできますし、それによってゴールを明確化した後は、テストを書いて進捗を見える化します。
さらに、通したいテストが存在しているので、実装を始めてみて、どうしてもテストを通せないテストが見つかれば、そこが複雑さや困難の核心であると特定できます。

私が、特に新人さんほどテスト駆動開発をすべきであると考えている理由はこの辺にあります。経験の浅い新人さんにとって、実現方法のわからないタスクは経験を積んだ人間よりもはるかに多いわけです。そんな方々にポンとタスクを渡してしまうと、硬直してまったく進まないなんてことはよくある話です。
テスト駆動開発を導入し、「とりあえず始めて」もらうことで、どの時点でわからないのかがより明確になります。TODOリストを作れなければそもそもタスクの理解が足りていないので、そこを説明しようとなります。テストが書けなければ、ペアプロで一緒にテストを書いてみましょう。実装のわからないところが出てきたら、その部分をペアプロで一緒に解決しましょう。

Elasticsearchのクエリビルダをテスト駆動で実装する

というわけで、長めの前提が終わりましたが、Elasticsearch(ES)のクエリビルダをテスト駆動開発で実装してみましょう。
なんで突然こんなこと始めたかというと、先日故合ってESを使う必要が出てきてしまい、そこでやったわけではないのですが、ESの検索時のクエリビルダってどうやって作ればいいのかなと思ったりしたのです。

問題設定

Elasticsearchについては特に言及する必要もないかもしれませんが、定番の検索エンジンです。
https://www.elastic.co/jp/elasticsearch/
アピールしているようにとても検索が早く、ここ最近プロダクトが重要かつ喫緊の課題としてみなしていた検索速度の改善に大いに貢献しました。
また、公式もPHP用のパッケージも用意しているため、すぐに始めることができます。
https://github.com/elastic/elasticsearch-php
ただ、これはあくまでElasticsearchと接続するためのパッケージなので、クエリは自分で作らなきゃならないのですが。。。

$params = [
    'index' => 'my_index',
    'body'  => [
        'query' => [
            'match' => [
                'testField' => 'abc'
            ]
        ]
    ]
];
$response = $client->search($params);

SQLとはまた違ったクエリの書き方ですね。Solrにも似たような書き方はあるのですが、それともまた違った感じになっています。
で、このクエリをいちいち配列で作るのめんどくさいなぁって思って、もうプロダクト側の実装は終わっているのですが、個人でクエリビルダみたいなやつを作ってみようかなって思ったわけです。
しかしながら、入れ子のクエリとか作るの面倒くさくて、とりあえずどこまでできるのか、テスト駆動でやっちゃおうって思ったわけです。

というわけで、ESの検索クエリのクエリビルダを作っていきます。

リストアップ

何はともあれ、やることのリストアップをしましょう。
とりあえず、簡単な検索から、それなりに複雑な検索までを賄うとすると、とりあえず以下のようにリストアップできるかなと思います。

  • ワード検索ができる
  • IDなどで検索ができる
  • AND検索ができる
  • OR検索ができる
  • 特定のIDを除外する検索ができる
  • 値の範囲検索ができる
  • 部分的にOR検索ができる

こんな感じです。
こいつをもとにテストコードを書きます。
からっぽでいいです。

QueryBuilderTest.php


class QueryBuilderTest extends TestCase
{
    
    /**
     * @test
     */
    public function ワード検索ができる()
    {

    }

    /**
     * @test
     */
    public function IDなどで検索ができる()
    {

    }
// ...略
}

なんか進んでいるように見えるでしょ?

今は単純に先ほどのリストアップしたものをコードに落としただけですが、逆に言うと、すでにコードを書き始めているわけです。

テストを作りながら実装

それではいよいよテストと実装を進めていきましょう。
クエリビルダとしては、基本的に'body' => 'query' => []あたりまでは共通なので、この先を組み立てるものとして作りましょう。

ワード検索ができる

Elasticsearchではワード検索はmatchというクエリを使うといいらしいです。
https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html
例えば、'content'に対し「地方球場 真夏」で検索したい場合は

<?php
//
class QueryBuilderTest extends TestCase
{

    private QueryBuilder $builder;

    public function setUp(): void
    {
        $this->builder = new QueryBuilder;
    }
    
    /**
     * @test
     */
    public function ワード検索ができる()
    {
        $this->builder->match('content', '地方球場 真夏');
        $this->assertEquals([
            'match' => [
                'content' => [
                    'query' => '地方球場 真夏'
                ]
            ]
        ], $this->builder->getQuery());
    }
// 略
}

ってな感じでクエリが返ってくればいいわけです。
...必要なものを書いていたら、テストも同時に作れましたね。昔書いた記事や発表などでも述べてきたのですが、テスト自体は書くのは難しくありません。echoとかprint_rが書けるのなら、テストは書けてしまいます。

なにはともあれこの結果を出せばいいわけですが、念のためテストしてみましょうか。

   FAIL  Tests\Unit\Services\Elasticsearch\QueryBuilderTest
  ⨯ ワード検索ができる
  ! i dなどで検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:36
  ! a n d検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:44
  ! o r検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:52
  ! 特定の i dを除外する検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:60
  ! 値の範囲検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:68
  ! 部分的に o r検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:76

実装していないんだから、テスト通るわけがありません。ただの儀式にしか見えないかもしれませんが、ここで重要なのは、テストが落ちている=実装が必要であることを確認することです。これから実装すべき状態になったわけです。
では実装してみましょう。

QueryBuilder.php

<?php

class QueryBuilder
{
    private array $query;

    public function match(string $field, string $content)
    {
        $this->query = [
            'match' => [
                $field => [
                    'query' => $content
                ]
            ]
        ];
    }

    public function getQuery()
    {
        return $this->query;
    }
}

とりあえず愚直に作ってみました。
テストしてみます。

   WARN  Tests\Unit\Services\Elasticsearch\QueryBuilderTest
  ✓ ワード検索ができる
  ! i dなどで検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:36
  ! a n d検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:44
  ! o r検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:52
  ! 特定の i dを除外する検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:60
  ! 値の範囲検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:68
  ! 部分的に o r検索ができる → This test did not perform any assertions  /var/www/php_elastic/tests/Unit/Services/Elasticsearch/QueryBuilderTest.php:76

  Tests:  6 risky, 1 passed
  Time:   0.20s

オッケーですね。まだまだテストや実装すべきことは残っていますが、まずは一歩前進しました。

IDなどで検索する

IDみたいなのをmatch検索みたいなあいまいな検索にかけるというのは、あまり聞くものではないです。こういう、完全に一致させたい検索の場合はtermを使うみたいです。
https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html
これに従うと、テストはこうですかね?

<?php
//
   /**
     * @test
     */
    public function IDなどで検索ができる()
    {
        $this->builder->term('id', 1000);
        $this->assertEquals([
            'term' => [
                'id' => [
                    'value' => 1000
                ]
            ]
        ], $this->builder->getQuery());
    }

テストケースが作れましたので、テストしましょう。

  ✓ ワード検索ができる
  ⨯ i dなどで検索ができる

準備できたので、実装します。

QueryBuilder.php

<?php
//
    public function term(string $field, $value)
    {
        $this->query = [
            'term' => [
                $field => [
                    'value' => $value
                ]
            ]
        ];
    }

もう一回テスト

  ✓ ワード検索ができる
  ✓ i dなどで検索ができる

いいですね。

複数IDでの検索

ID検索を実装したときに、IDを複数指定し、どれかに当てはまっていればいいみたいな、SQLで言うところのwhere in見たいのもあったらいいなと思ったので、テストを足しました。
こんなのができるのもテスト駆動のいいところですね。
で、やることはID検索でやったtermtermsにし、valueに配列を渡せばいい模様。

<?php
//
    /**
     * @test
     */
    public function 複数のIDのどれかに一致する検索ができる()
    {
        $this->builder->terms('id', [1000, 1001]);
        $this->assertEquals([
            'terms' => [
                'id' => [
                    'value' => [1000, 1001]
                ]
            ]
        ], $this->builder->getQuery());
    }

というテストを書いて、

<?php
//
    public function terms(string $field, array $value)
    {
        $this->query = [
            'terms' => [
                $field => [
                    'value' => $value
                ]
            ]
        ];
    }

という実装をすれば、

  ✓ ワード検索ができる
  ✓ i dなどで検索ができる
  ✓ 複数の i dのどれかに一致する検索ができる

オッケーです。

AND検索

AND検索はboolean queryを使うらしい。
https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html
これによると、例えば二つ同時に満たしてほしい場合の検索は以下のようになっているとありがたいわけです。

<?php
//
    /**
     * @test
     */
    public function AND検索ができる()
    {
        $this->builder->match('content', '今日の朝ごはん')->terms('id', [1000, 1001, 1002]);
        $this->assertEquals([
            'bool' => [
                'must' => [
                    [
                        'match' => [
                            'content' => [
                                'query' => '今日の朝ごはん'
                            ]
                        ]
                    ],
                    [
                        'terms' => [
                            'id' => [
                                'value' => [1000, 1001, 1002]
                            ]
                        ]
                    ]
                ]
            ]
        ], $this->builder->getQuery());
    }

一気に面倒くさそうになりました。
そもそも、一番上でクエリビルダっぽくしていますが、matchtermも現状では返却値なしなので、動きません。最低限動かすために、各メソッドで$thisを返すようにしても、

  ✓ ワード検索ができる
  ✓ i dなどで検索ができる
  ✓ 複数の i dのどれかに一致する検索ができる
  ⨯ a n d検索ができる

当然失敗します。
何はともあれ、実装が必要だとわかりましたが、このテストを通すにはこれまで書いてきた部分についてももう少し手直しが必要そうです。

<?php
//
    private array $query = [];

    public function match(string $field, string $content)
    {
        $this->query[] = [
            'match' => [
                $field => [
                    'query' => $content
                ]
            ]
        ];

        return $this;
    }

このように、クエリを配列で持つようにしたうえで、最後のクエリ取得を以下のように変えてみます。

<?php
//
    public function getQuery()
    {
        return [
            'bool' => [
                'must' => $this->query
            ]
        ];
    }

これでテストを実行してみましょう。

  ⨯ ワード検索ができる
  ⨯ i dなどで検索ができる
  ⨯ 複数の i dのどれかに一致する検索ができる
  ✓ a n d検索ができる
  
 //
    --- Expected
    +++ Actual
  @@ @@
   Array (
  -    'match' => Array (...)
  +    'bool' => Array (...)
   )
 //

先ほどと結果が逆転しました。
変更の結果、他のものも全部'bool' => 'must' => の中に突っ込まれるようになってしまったわけです。
ここでどのような実装をするかについて2つの方針が作れます。
一つはこれまでの単一の検索のクエリについても一様に'bool' => 'must' => の中に突っ込むように仕様を変え、テストのほうを変更する方法です。
もう一つは単一の場合と複数の場合で結果の返し方を変えること、つまり、もう少し実装することです。
今回はgetQueryにさらに実装を追加していきます。

<?php
//
    public function getQuery()
    {
        return (count($this->query) === 1)
            ? $this->query[0]
            : [
            'bool' => [
                'must' => $this->query
            ]
        ];
    }

このように変更したら、テストを実行します。

  ✓ ワード検索ができる
  ✓ i dなどで検索ができる
  ✓ 複数の i dのどれかに一致する検索ができる
  ✓ a n d検索ができる

これでオッケーでしょう。

OR検索ができる

先のboolean query的には、shouldというのが最低どれか一個の条件が満たせているものを意味しているらしく、以下のようなクエリになればよさそうです。

<?php
//
    /**
     * @test
     */
    public function OR検索ができる()
    {
        $this->builder
            ->or()
            ->match('content', '今日の朝ごはん')
            ->terms('id', [1000, 1001, 1002]);
	    
        $this->assertEquals([
            'bool' => [
                'should' => [
                    [
                        'match' => [
                            'content' => [
                                'query' => '今日の朝ごはん'
                            ]
                        ]
                    ],
                    [
                        'terms' => [
                            'id' => [
                                'value' => [1000, 1001, 1002]
                            ]
                        ]
                    ]
                ]
            ]
        ], $this->builder->getQuery());
    }

orMatchとかのメソッド作るのめんどくさいので、orメソッド使ったらOR検索になるでいったん妥協しましょう。
実装は

<?php
//
    private string $mode = 'must';
//
    public function or(): self
    {
        $this->mode = 'should';
        return $this;
    }

    public function getQuery()
    {
        return (count($this->query) === 1)
            ? $this->query[0]
            : [
            'bool' => [
                $this->mode => $this->query
            ]
        ];
    }

それではテストを実行します。

  ✓ ワード検索ができる
  ✓ i dなどで検索ができる
  ✓ 複数の i dのどれかに一致する検索ができる
  ✓ a n d検索ができる
  ✓ o r検索ができる

しっかり作れているようです。

特段しっかり設計しているわけではないですが、徐々に実装が進んでいるのがわかると思います。TODOを着実にこなせているため、やることをどれくらい消化してどれくらい残っているのかが一目でわかるため、進捗が確認が容易なのはテスト駆動というより、完了の定義が明確化されていること効果が大きいかと思います。

ID除外検索

クエリに否定条件を込めることもありますが、これもboolean queryでできるようです。
期待値は以下のような感じです。

<?php
//
    /**
     * @test
     */
    public function 特定のIDを除外する検索ができる()
    {
        $this->builder->not()->terms('id', [1000, 1001, 1002]);
        $this->assertEquals([
            'bool' => [
                'must_not' => [
                    [
                        'terms' => [
                            'id' => [
                                'value' => [1000, 1001, 1002]
                            ]
                        ]
                    ]
                ]
            ]
        ], $this->builder->getQuery());
    }

実装はこんな感じです。

<?php
//
    public function not(): self
    {
        $this->mode = 'must_not';
        return $this;
    }

    public function getQuery()
    {
        return ($this->mode !== 'must_not' and count($this->query) === 1)
            ? $this->query[0]
            : [
            'bool' => [
                $this->mode => $this->query
            ]
        ];
    }

テストも通します。

  ✓ ワード検索ができる
  ✓ i dなどで検索ができる
  ✓ 複数の i dのどれかに一致する検索ができる
  ✓ a n d検索ができる
  ✓ o r検索ができる
  ✓ 特定の i dを除外する検索ができる

この調子で進めていきましょう。

値の範囲検索

範囲検索はこちらを参照します。
https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html
テストはこうしておきます。

<?php
//
    /**
     * @test
     */
    public function 値の範囲検索ができる()
    {
        $this->builder->range('age', 20, 30);
        $this->assertEquals([
            'range' => [
                'age' => [
                    'gte' => 20,
                    'lte' => 30
                ]
            ]
        ], $this->builder->getQuery());
    }

実装を以下のように修正します。

<?php
//
    public function range(string $field, $min = null, $max = null): self
    {
        $this->query[] = [
            'range' => [
                $field => [
                    'gte' => $min,
                    'lte' => $max
                ]
            ]
        ];

        return $this;
    }

これでテストを実行します。

 ✔ ワード検索ができる
 ✔ I dなどで検索ができる
 ✔ 複数の i dのどれかに一致する検索ができる
 ✔ A n d検索ができる
 ✔ O r検索ができる
 ✔ 特定の i dを除外する検索ができる
 ✔ 値の範囲検索ができる

あと少しです。

一部OR検索

AND検索の中でOR検索が含まれているものです。例えば、ワード検索をしつつ、特定のIDもしくは特定の年齢のものだけを検索するというクエリを作ってみます。今回は入れ子のクエリを作ってみます。

<?php
//
    /**
     * @test
     */
    public function 部分的にOR検索ができる()
    {
        $this->builder
            ->match('content', '夏の思い出')
            ->or(function ($builder) {
                $builder->term('id', 1000)
                    ->range('age', 20, 30);
            });

        $this->assertEquals([
            'bool' => [
                'must' => [
                    [
                        'match' => [
                            'content' => [
                                'query' => '夏の思い出'
                            ]
                        ]
                    ],
                    [
                        'bool' => [
                            'should' => [
                                [
                                    'term' => [
                                        'id' => [
                                            'value' => 1000
                                        ]
                                    ]
                                ],
                                [
                                    'range' => [
                                        'age' => [
                                            'gte' => 20,
                                            'lte' => 30
                                        ]
                                    ]
                                ]
                            ]
                        ]
                    ]
                ]
            ]
        ], $this->builder->getQuery());
    }

入れ子であることを表現するために、無名関数を使っています。
実装は以下のようになりました。

<?php
//
    public function or(callable $func = null): self
    {
        if ($func === null) {
            $this->mode = 'should';
            return $this;
        }

        $builder = new self;
        $func($builder->or());
        $this->query[] = $builder->getQuery();
        return $this;
    }

これでテストを実行しましょう。

 ✔ ワード検索ができる
 ✔ I dなどで検索ができる
 ✔ 複数の i dのどれかに一致する検索ができる
 ✔ A n d検索ができる
 ✔ O r検索ができる
 ✔ 特定の i dを除外する検索ができる
 ✔ 値の範囲検索ができる
 ✔ 部分的に o r検索ができる

予定していたテストはすべて完了しました。

リファクタリング

実際に自分がどうリファクタリングするかは後でリポジトリを上げるので、そっちを見てもらえればいいのですが、基本的に自分がリファクタリングするときは以下の方針でやってたりします。

  • 重複コードはないか
  • 長すぎるメソッドはないか
  • 分岐を消せないか
  • 拡張性を高められないか

例えば、各検索クエリのメソッドの処理を別のクラスに分けて、以降のクエリメソッドの追加を、クラスの追加にすることで、追加や修正をやりやすくするなどが考えられますが、この辺は究極的には自分の好みかなと思います。

まとめ

というわけで、テスト駆動開発をどのように進めるのかを、Elasticsearchの検索クエリのクエリビルダを作ることを通してみてみました。
いや、正直な話、クエリビルダ面倒くさそうでプロダクトのほうでは実装しなかったのですが、TODOリスト作ってテスト駆動でやってみると、結構簡単に作れたんで、テスト駆動の例題ということにかこつけて実装したかっただけだったりします。

今回はこんなところです。

Discussion