第6章 テストコードと機能追加実装を同時にすすめる

今回は機能を実装しながらテストコードを実装してみますが、テストコードというのは一度作ると削除するのが難しくなり、最終的に実装のコードより大掛かりなことになってしまい、テストコード自体のメンテナンスが負担になることがあったりします。実装コードもそうですが、小さい単位で作るというのはとても大事なことです。

ここではテストコードの小さい単位での実装方法を紹介してみたいと思います。

6.1 テストコード拡張の準備

投稿記事の削除機能とそのテストコードを実装したいと思います。アクセスURLと画面遷移は下記のようなイメージになります。

  • URL:
    • POST - `/remove` - remove 掲示板の削除実行
  • 画面遷移
    • 一覧`/list`の表示記事ヨコの「削除」ボタンクリック
    • `/remove`アクション実行
    • 削除実行後、一覧画面に遷移、削除完了のメッセージ表示

実際のソースコードはこちらの github からみれます。

6.1.1 テストファイルを追加

% touch t/remove.t

t/remove.t の中身は下記のようにしておきます。

# 暫定的にトップページのリクエストテストを書いておく
use Mojo::Base -strict;
# ... 省略
subtest '/' => sub {
    $t->get_ok('/')->status_is(200);
};

done_testing();

6.1.2 既存のテストファイルと共通化の仕組みをつくる

% mkdir -p lib/Test/Mojo/Role
% touch lib/Test/Mojo/Role/Basic.pm

lib/Test/Mojo/Role/Basic.pm の中身は下記のようにしておきます。

package Test::Mojo::Role::Basic;
use Mojo::Base -role;

# ... 省略

sub init {
    my $self = shift;
    # ... 省略
    return;
}

sub init_db {
    my $self = shift;
    # ... 省略
    return;
}

1;

6.1.3 既存のテストファイルを調整する

t/bulletin.t の中身は下記のように修正しておきましょう

use Mojo::Base -strict;
use Test::More;
use Test::Mojo;
use Mojo::Util qw{dumper};
use FindBin;
use lib "$FindBin::Bin/../lib";

# web アプリを実行からテスト用DB準備まで
my $t = Test::Mojo->with_roles('+Basic')->new;
$t->init;

# ... 省略

実際にテストコードを実行して確認しておく

% docker-compose exec web carton exec -- prove

6.2 機能とテストを同時に作業する

具体的な手順はテストコードにテストの内容を書いてテストコード実行し失敗させる、そして失敗がしないように実装コードを書いてみる、この手順でいってみます。

6.2.1 やることを日本語で書く

コードを書く前に日本語でテキストにおとしこんでイメージするのは有効なやり方です。

t/remove.t の中身を下記のようにしてみます。

# ルーティング
subtest '/remove' => sub {
    # パラメーターなしでリクエスト
    # 削除には対象のIDを指定が必要なので失敗して一覧へリダイレクトする
};

# 画面遷移
# トップページから登録、削除までの挙動
subtest 'top -> list -> create -> store -> list -> remove -> list' => sub {
    # トップページから登録完了までの挙動再現
    # 一覧画面の削除ボタンのリンク確認
    # 削除リクエスト実行
    # 削除成功画面の確認
};

試しに実行してみます下記のように失敗するはずです

% docker-compose exec web carton exec -- prove
t/bulletin.t .. ok   
t/remove.t .... 1/?     # No tests run!
(省略 ...)

6.2.2 日本語の部分をソースコードに置き換える

remove のルーティングテストの日本語の部分をソースコードに置き換え、テストが通るように実装をすると下記のようになります。

t/remove.t のルーティングのところを中身を下記のようにしてみます。

subtest '/remove' => sub {
    $t->post_ok( '/remove' => form => +{} )->status_is(302);
};

bulletin.pl に remove のメソッドを追加します。

post '/remove' => sub ($c) {
    my $params = $c->req->params->to_hash;
    $c->redirect_to('/list');
};

今度は詳細が出力するようにテストコードを実行します。

% docker-compose exec web carton exec -- prove -v
t/bulletin.t .. 
(省略 ...)
# Subtest: /remove
[2021-04-18 06:40:24.91204] [82] [debug] [dcuRm3qo] POST "/remove"
[2021-04-18 06:40:24.91220] [82] [debug] [dcuRm3qo] Routing to a callback
[2021-04-18 06:40:24.91237] [82] [debug] [dcuRm3qo] 302 Found (0.000329s, 3039.514/s)
    ok 1 - POST /remove
    ok 2 - 302 Found
    1..2
ok 2 - /remove
# Subtest: top -> list -> create -> store -> list -> remove -> list
    1..0
    # No tests run!
(省略 ...)

6.2.3 画面遷移のテスト登録までを共通化する

t/remove.t に下記のように store_ok メソッドを実行するようにします。

subtest 'top -> list -> create -> store -> list -> remove -> list' => sub {
    # トップページから登録完了までの挙動再現
    $t->store_ok();
};

lib/Test/Mojo/Role/Basic.pm に store_ok メソッドを実装しますが、内容は bulletin.t のものを流用します。

# bulletin.t のテスト内容をそのまま流用
sub store_ok {
    my $t = shift;
    $t->get_ok('/')->status_is(200);
    # ... 省略
    return $t;
}

bulletin.t の内容も store_ok メソッドに置き換えることにします。

# こちらもメソッド呼び出しで共通化
subtest 'top -> list -> create -> store -> list' => sub {
    $t->store_ok();
};

きちんとおきかえられたか念のためにテストコードを実行してみます。すべてokが出ると思います。

% docker-compose exec web carton exec -- prove -v
(省略 ...)
ok 3 - top -> list -> create -> store -> list -> remove -> list
1..3
ok
All tests successful.
(省略 ...)

6.2.4 画面遷移の一覧画面の削除ボタン

t/remove.t に下記のように store_ok メソッドを実行したあとに一覧画面に削除ボタンが出現するところまでを再現します

subtest 'top -> list -> create -> store -> list -> remove -> list' => sub {
    # ... 省略

    # 一覧画面の削除ボタンのリンク確認
    # 一番最後につくったデータを取得
    my $row     = $t->app->teng->single( 'bulletin', +{ deleted => 0 } );
    my $last_id = $row->id;
    my $remove_form
        = "form[name=remove_$last_id][method=post][action=/remove]";
    my $input_id = "input[name=id]";
    my $input_ele = "$remove_form $input_id";
    $t->element_exists($input_ele);
    # ... 省略
};

bulletin.pl に app->teng と呼び出せるようにヘルパーメソッドを定義し、画面の実装をしてみます。

# ... 省略
# ヘルパーメソッドを使えるようにする
helper teng => sub { teng() };

# ... 省略
@@ list.html.ep
# ... 省略

  % for my $bulletin (@{$c->stash->{bulletin_list}}) {
    <tr>
      <td><%= $bulletin->{comment} %></td>
      <td><%= $bulletin->{created_ts} %></td>
      <td>
        <form name="remove_<%= $bulletin->{id} %>" method="post" action="/remove">
        <input type="hidden" name="id" value="<%= $bulletin->{id} %>">
        <input type="submit" value="削除">
        </form>
      </td>
    </tr>
  % }

# ... 省略

テストコードを実行してみましょう。すべてokが出ると思います。

% docker-compose exec web carton exec -- prove -v
(省略 ...)
ok 3 - top -> list -> create -> store -> list -> remove -> list
1..3
ok
All tests successful.
(省略 ...)

6.2.5 画面遷移の削除実行から完了まで

t/remove.t に下記のように削除リクエスト実行から完了までの動きを再現します。

subtest 'top -> list -> create -> store -> list -> remove -> list' => sub {
    # ... 省略

    # 削除リクエスト実行
    my $remove_link_url = $t->tx->res->dom->at($remove_form)->attr('action');
    my $remove_params
        = +{ id => $t->tx->res->dom->at($input_ele)->attr('value'), };
    $t->post_ok( $remove_link_url => form => $remove_params )->status_is(302);

    # 削除成功画面の確認
    my $location_url = $t->tx->res->headers->location;
    $t->get_ok($location_url)->status_is(200);
    $t->content_like(qr{\Q削除しました\E});
};

最後に bulletin.pl に 削除のロジック一式を実装します。

# ... 省略
post '/remove' => sub ($c) {
    my $params = $c->req->params->to_hash;

    my $teng = teng();
    my $t    = localtime;
    my $date = $t->date;
    my $time = $t->time;
    my $row  = $teng->single( 'bulletin', $params );
    return $c->redirect_to('/list') if !$row;

    $row->update(
        +{  deleted     => 1,
            modified_ts => "$date $time",
        }
    );

    $c->flash( msg => "削除しました" );
    $c->redirect_to('/list');
};
# ... 省略
<h1>一覧表示</h1>
% if ( my $msg = flash('msg') ) {
  <p style="color: red;"><%= $msg %></p>
% }
# ... 省略

テストコードを実行してみましょう。すべてokが出ると思います。

% docker-compose exec web carton exec -- prove -v

6.3 テストコードについて考えてみる

実際にテストコードの実装例をみてどう感じたでしょうか、web の画面の確認という意味でテストコードを実装するのはなかなか大変ですし、そもそも web アプリ仕様が頻繁に変わることが多いですからテストコードのメンテも大変になるのでないかと想像できます。

テストコードとはどういうものか下記にまとめてみた

テスト駆動開発という言葉について

  • ネットなどで検索するとおそらく下記の様な内容が書かれてある
    • 失敗するテストコードを書く
    • テストが成功するコードを書く
    • テストが成功する状態を維持しつつ簡潔・明快なコードにする
  • 実際の開発の現場での感覚でいくと上記の様な指標を厳密に守るのは精神的にかなり負担がかかる
    • そもそも仕様があやふやですすめていたら?
    • 作業中に仕様が変更になったら?
    • 失敗とは? どういう条件までを考慮するのか
    • 発生することが限りなくありえない様な失敗ケースを想定することにどんな意味があるのか
    • テストコードを優先するあまり実装コードの作業時にエネルギーが尽きてしまう

テストコードとうまく付き合うためには

  • まったくテストコードがないというのも極端すぎるのでテストコードの的を絞る
    • テストコードの対象
      • URLリクエスト実行時のステータスコード確認
      • 成功系の画面遷移の再現
      • データベースとの接続確認
    • テストコードの対象にしない
      • js で動的に吐き出されるhtmlやcss
      • 画面の色や見栄えに関する領域

テストコードを実行しながらのリファクタリングについて

テストコードが存在すると既存のコードをリファクタリングする場合に楽にすすめられるため、リファクタリング目的でテストコードを実装するというやり方もあるかもしれない。

しかし、リファクタリングというものを考えてみると実際の挙動は変化はせず、保守がしやすい状態に既存のコードを書き換えるということなのでこの行為自体は直接的には売上や利益に貢献しない、長い目でみれば(保守のしやすさ)利益に貢献しそうだが、既存のシステムが打ち切りになる可能性があることも考えておいた方がよい。

システム全体を完璧に把握できることはほとんどないのでリファクタリングより新しく別にシステムを用意した方が良い場合もある

6.4 まとめ

6.5 次回

今回はテストコードを実装しながら機能を追加してみました。

最初は難しく感じるかもしれませんが、なれてくると実装だけするよりもテストコードを描きながらの方が早く実装できるようにはなります。

とはいえ、テストコードというものはやりだすとキリがありませんし、保守の問題もありますから結局はバランス感覚が大事ということなのかもしれません。

次回はアプリの保守や拡張をしやすくするための構築の仕方についてみていきます。