メソッドを書くときの3つのルール:IO処理と非IO処理を分ける

たった3つでいいの?

最初の3つ、と言ったほうがいいかもしれません。

(1)メソッドを小さく分ける
(2)決定表
(3)IO処理と非IO処理を分ける

ロングメソッドが多すぎます。「メソッドを小さく分ける」だけで、多くの問題(読みにくい、テスト書けない、再利用できない)が解決すると思います。

プログラムは、ifあってのプログラムです。が、ネストが深くなって大変なことになる原因でもあります。ifを整理するための手法が「決定表」です。

IO処理を含むメソッドのテストケースを作るとき、モックに頼りすぎです。「IO処理と非IO処理を分ける」ことで、非IO処理のテストケースを書くことができ、再利用しやすくなります。

この記事では「IO処理と非IO処理を分ける」について説明します。

モックを使ったユニットテストって、オレオレ証明書みたいだよね。

どういうこと?

モックに 123 を返せって書いておくでしょ。テストターゲットは、モックから受け取った値をそのまま返して、123かどうか確認するみたいなことしてるからさ。そりゃ123だよね。

テストターゲットのインスタンス化は確認できてるじゃない、タイポしていると、インスタンス化もできないことがあるから、テストに意味はあるわよ

IO処理とは

ほぼ全てのアプリケーションは、外部からデータを渡したり、外部からデータを読み込んで、アプリケーション内で処理し、外部にデータを出力します。

この「外部からデータを渡したり」「外部からデータを読み込む」「外部にデータを出力する」をIO処理といいます。

キーボードを読む、ターミナルに表示することもIO処理です。

ファイルの読み書き、データベース検索や保存、ネットワーク通信もIO処理です。

PHPの$_GET、$_POST、$_COOKIE、$_SERVERは、IO処理ではなく、アプリケーションへ渡す引数と考えていいです。

しかし、$_SESSIONは、保存先がファイルやデータベースなので、IO処理です。

IO処理と非IO処理が混在していると

ロジックだけのテストをしにくい

IO処理は本番用とテスト用で分けなくてはいけません。ファイルならテスト用ディレクトリを用意し、データベースの場合はテスト用サーバを用意しなくてはいけません。

メソッドにIO処理と非IO処理が混在していると、ロジック(非IO処理)だけをテストしたいのに、テスト用サーバやテストデータが必要ということです。テストケースを書くハードルが高くなり、充分なテストケースを書かないかもしれません。

そして、テスト用サーバを用意できない場合は、モックを用意しますが、モックのセットアップコードは読みにくくなりがちです。

モックのセットアップコードが並んでいると、何のテストかはっきりしないテストコードになってしまいます。

「非IO処理」だけをメソッドに分けることで、「非IO処理」のテストケースをテストサーバやモックなしで書けるようになります。

デザインパターンやDIを使って、IO処理を透過的なクラスでラップしたとしても、最終的にはIO処理です。テストケースを作るとき、そのクラスのモックが必要なら、IO処理と非IO処理を分けたほうがいいでしょう。

ロジックを再利用できない

メソッドにIO処理と非IO処理が混在していると、ロジック(非IO処理)だけを再利用したいのに、そのメソッドを再利用できないことも多いです。

また、フレームワークを利用していると、粒度の大きいメソッドを作りがちです。1つのメソッド内でデータベースから読み出して「foo処理」してデータベース保存する。ところが、別のメソッドで「foo処理」したくなっても、データベースの検索条件が違っていて、メソッドを再利用できないといったことがよくあります。

    public fucntion baz()
    {
        $user = $this->User->find($params);

        // $user のfoo処理

        $this->User->save($user);
    }Code language: PHP (php)

「非IO処理」だけをメソッドに分けることで、「非IO処理」を再利用できるようになります。

非IO処理の再利用の予定はないし、テストケースためだけに、非IO処理を分けるのは面倒な気がするんだけど...

既存のコードを急いでリファクタすることはないけど、

そのメソッドを修正するときや、新規にコードを書くときに気をつけてね。

IO処理と非IO処理を分ける

echo

簡単なところでは、echoです。echoはIO処理です。

// IO処理
    public fucntion foo($str)
    {
        echo "Name: $str\n";
    }Code language: PHP (php)

このメソッドのユニットテストはどのように書けばいいでしょうか?echoの出力を変数にキャプチャーすることができますが、少々煩雑なコードになってしまいます。

    public fucntion test_foo()
    {
        ob_start();
        $this->target->foo("Taro");
        $result = ob_get_clean();
        $this->assertEquals("Name: Taro\n", $result);
    }Code language: PHP (php)

メソッド内で echo せずに、返り値でreturnすると、

// 非IO処理
    public fucntion foo($str)
    {
        return "Name: $str\n";
    }Code language: PHP (php)

読みやすいユニットテストを書けます。

    public fucntion test_foo()
    {
        $result = $this->target->foo("Taro");
        $this->assertEquals("Name: Taro\n", $result);
    }Code language: PHP (php)

どこでechoすればいいの?

fooメソッドでechoしたい場合は、makeFooDataメソッドで表示データを組み立ててから、echoします。

    public fucntion foo($str)
    {
         $data = $this->makeFooData($str);
         echo $data;
    }

    public fucntion makeFooData($str)
    {
        return "Name: $str\n";
    }
    Code language: PHP (php)

ファイル保存

次の例は、配列を1行1要素づつ改行をつけてファイルに保存するメソッドです。

    public function foo($path, $arr)
    {
        $buf = '';
        foreach ($arr as $item) {
            $buf .= $item . "\n";
        }

        file_put_contents($path, $buf);
    }Code language: PHP (php)

配列から保存データを組み立てる処理を別メソッドに分けます。

    public function foo($path, $arr)
    {
        $buf = $this->makeFooContent($arr);
        file_put_contents($path, $buf);
    }

    public function makeFooContent($arr)
    {
        $buf = '';
        foreach ($arr as $item) {
            $buf .= $item . "\n";
        }
        return $buf;
    }Code language: PHP (php)

makeFooContentは、array_mapを使ってリファクタできるね

ユニットテストを書いてから、リファクタしてね

データベース

ファイル保存の例では、IO処理と非IO処理を分ける前でもユニットテストを書くことができますが、保存先がデータベースとなると、ユニットテストを書くのは簡単ではありません。

    public function foo($id, $arr)
    {
        $buf = '';
        foreach ($arr as $item) {
            $buf .= $item . "\n";
        }

        $data = [
            'id'  => $id,
            'foo' => $buf,
        ];
        $this->Foo->save($data);
    }Code language: PHP (php)

$arrから$bufの組み立てをmakeFooContentメソッド、$dataの組み立てをmakeDataメソッドに分けます。

データベースやモックを用意することなく、makeFooContentメソッドやmakeDataメソッドのユニットテストを書くことができます。

    public function foo($id, $arr)
    {
        $data = $this->makeData($id, $arr);
        $this->Foo->save($data);
    }

    public function makeData($id, $arr)
    {
        $foo = $this->makeFooContent($arr);
        $data = [
            'id'  => $id,
            'foo' => $foo,
        ];
        return $data;
    }

    public function makeFooContent($arr)
    {
        $buf = '';
        foreach ($arr as $item) {
            $buf .= $item . "\n";
        }
        return $buf;
    }Code language: PHP (php)

fooメソッドのユニットテストはしなくていいっこと?

fooメソッドのユニットテストを書かなくても、早い段階で、makeFooContentメソッドのユニットテストを書けるってことね。

いずれ、fooメソッドのユニットテストは書かなきゃね

セッションとデータベース

次は、セッションとデータベースにアクセスしている例です。

    public function foo()
    {
        $foo = $this->session->read('foo');
        $data = $this->barTable->find(...);

        // ほにゃらら処理

        $this->session->write('foo', $foo);
        $this->barTable->save($data);
    }
Code language: PHP (php)

「ほにゃらら処理」だけを別のfooMainメソッドにします。

fooMainメソッドは、ユニットテストを作りやすくなり、再利用しやすくなります。

    public function foo()
    {
        $foo = $this->session->read('foo');
        $data = $this->barTable->find(...);

        list($foo, $data) = $this->fooMain($foo, $data);

        $this->session->write('foo', $foo);
        $this->barTable->save($data);
    }

    public function fooMain($foo, $data)
    {
        // ほにゃらら処理

        return [$foo, $data];
    }Code language: PHP (php)

fooMainを実装するとき、引数のデータ由来を明確にしたい場合は、配列を用意します。

    public function foo()
    {
        $foo = $this->session->read('foo');
        $baz = $this->session->read('baz');
        $data = $this->bar->find(...);
        $context = [
            'session' => [
                'foo' => $foo,
                'baz' => $baz,
             ],
            'bar' => $data,
        ];

        $context = $this->fooMain($context);

        $this->session->write('foo', $context['session']['foo']);
        $this->bar->save($context['bar']);
    }

    public function fooMain($context)
    {
        // ほにゃらら処理

        return $context;
    }Code language: PHP (php)

小さくメソッドに分けてるだけの気がしてきた?

IO処理と非IO処理を混在しないようにメソッドを分けるのがポイントね

メール送信

次は、メール送信の例です。かなり単純化していますが、このような感じです。

    public function sendMail($user, $item, $options)
    {
        $to = $user['email'];
        $title = 'メールタイトル';
        $vars = [
            'user_name' => $user['name'],
            'item_name' => $item['name'],
            'item_description' => $item['description'],
            'something'  => $options,
        ];
        $body = $this->template($vars);

        $this->mail->send($to, $title, $body);
    }Code language: PHP (php)

何が気になるかというと、タイトルや本文をテストするのに、$this->mail->send()のモックを用意しなくてはいけません。

この例は、少しリファクタするだけで、モック不要でテストできます。

    public function sendMail($user, $item, $options)
    {
        list($to, title, $body) = $this->makeMailParams($user, $item, $options);
        $this->mail->send($to, $title, $body);
    }

    public function makeMailParams($user, $item, $options)
    {
         $to = $user['email'];
         $title = 'メールタイトル';
         $vars = [
             'user_name' => $user['name'],
             'item_name' => $item['name'],
             'item_description' => $item['description'],
             'something'  => $options,
         ];
         $body = $this->template($vars);

         return [$to, $title, $body];
    }Code language: PHP (php)

データの組み立てと、メール送信を分けることで、次のような機能追加にも対応しやすくなります。

1回の処理で、データを準備して、メール送信する。

2回の処理に分ける。1回目のhttpリクエストでデータを準備して、2回目のhttpリクエストでメール送信する。

3回の処理に分ける。1回目のhttpリクエストでデータを準備して、2回目のhttpリクエストでメール送信する。2回目のhttpリクエストがこなかった場合に備えて、バッチでメール送信する。

コールスタックの深いところで、セッションデータを参照して、その場でメール送信していたりすると、再利用が難しいよね。

タイトルとURLをコピーしました