たった3つでいいの?
最初の3つ、と言ったほうがいいかもしれません。
(1)メソッドを小さく分ける
(2)決定表
(3)IO処理と非IO処理を分ける
ロングメソッドが多すぎます。「メソッドを小さく分ける」だけで、多くの問題(読みにくい、テスト書けない、再利用できない)が解決すると思います。
プログラムは、ifあってのプログラムです。が、ネストが深くなって大変なことになる原因でもあります。ifを整理するための手法が「決定表」です。
IO処理のあるメソッドのテストケースを作るとき、モックに頼りすぎです。「IO処理と非IO処理を分ける」ことで、非IO処理のテストケースを書くことができ、再利用しやすくなります。
この記事では「決定表」について説明します。
決定表って、ウォーターフォール時代のものじゃないの?
UMLにも、決定表はないしね
決定表とは
決定表とは、複数の入力パラメータの状態によって、何を返すのか、どのような処理をするのかについて整理した表です。
つまり、入力と出力の関係を整理した表です。
決定表を見ると、
(1)入力パラメータの種類と状態
(2)入力パラメータの組み合わせ
(3)結果の種類
が、わかります。
決定表があると、
(1)テストケースを作りやすいだけでなく、
(2)コードを実装しやすくなり、
(3)コメントよりも、コードを読む手がかりになります
次の表は、あるアプリの機能をandroid versionや機種によって、オン/オフする決定表です。
番号 | SDK_INT | 除外モデルか? | GooglePlay Serviceがある | 返り値 |
---|---|---|---|---|
1 | 〜18 | ーーー | ーーー | No |
2 | 19〜 | Yes | ーーー | No |
3 | 19〜 | No | Yes | Yes |
4 | 19〜 | No | No | No |
入力パラメータの種類は?
「SDK_INT」、「除外モデルか?」、「GooglePlayServiceがある」の3つね
「SDK_INT」の状態は?
「〜18」「19〜」の2つの状態があるのね
入力パラメータの組み合わせは?
番号1〜4の4つ
結果の種類は?
No/Yesの2つね
でも、決定表って役に立たない気がするんだけど...
「上流工程で決定表を書いてから、下流工程で実装する」と説明されることが多いよね
穴だらけの仕様書のひとつって感じよね
ぼくはそんなこと言ってないからね
開発完了後の資料提出のために、ソースから決定表や資料を作ることもあるよね
誰も読まないのにね
だから、ぼくはそんなこと言ってないからね
ここで説明する決定表は、開発者自らが決定表を作り、それにしたがって実装し、テストケースを作ります。どういうことかというと、
実装してみたけど、
↓
コードがすっきりしないな、
入力と出力の関係を理解していないみたいだ、
↓
開発者自ら、決定表を作ってロジックを整理する。
決定表を作る過程で理解が深まるのだ!
↓
その決定表にしたがって、リファクタする。
↓
その決定表にしたがって、テストケースを作る。
のように使います。
もっと単純化すると、
実装
↓
決定表を作る
↓
リファクタ
↓
テストケース
という流れです。
プロジェクトWikiには、決定表とクラスファイル名やメソッドを書いておきます。
またソースのコメントにも、決定表とプロジェクトWikiのURLを残しておきます。
ケース1: android java
android javaの例です。
次の表は、あるアプリの機能をandroid versionや機種によって、オン/オフする決定表です。
GooglePlayServiceの有無の処理や、除外モデルかどうかなどをメソッドに分けたり、この判断処理をまるごとクラスに分けようかというリファクタ前に、いったん決定表で整理することにしました。
番号 | SDK_INT | 除外モデルか? | GooglePlay Serviceがある | 返り値 |
---|---|---|---|---|
1 | 〜18 | ーーー | ーーー | No |
2 | 19〜 | Yes | ーーー | No |
3 | 19〜 | No | Yes | Yes |
4 | 19〜 | No | No | No |
「SDK_INT」、「除外モデルか?」、「GooglePlayServiceがある」の3つが入力パラメータです。
入力パラメータの状態によって、番号1〜4の4つの組み合わせがあります。
返り値は、Yes と No の2種類です。
番号3だけが Yes だね
決定表をつくるのは大げさじゃない?
決定表をつくらなくても、次のコードを書けそうだよ?
// 疑似コード
public boolean available(...) {
return (19 <= SDK_INT) && !isExclusionModel(model) && isGooglePlayService;
}
Code language: Java (java)
決定表を見たから、簡単そうだって思えるのよ、そこが大事よ!
たしかに、&& 連結したの1つの式で書くことができます。しかし、デバッガでトレースすることになったとき、決定表の何番の状態だから、false/trueになった、ということがわかりません。
そこで、ムダに思えますが、次のような if else if で書きます。決定表の番号をコメントに残しておくと、決定表を見ながら、コードを追跡しやすくなります。
// 疑似コード
public boolean available(int sdk_int, String model, boolean isGooglePlayService) {
if (sdk_int <= 18) {
// # 1
return false;
} else if (isExclusionModel(model)) {
// # 2
return false;
} else if (isGooglePlayService) {
// # 3
return true;
} else {
// # 4
return false;
}
}
Code language: Java (java)
決定表を見ながら、ユニットテストを書きます。
// 疑似コード
@Test
public void available() {
// #1
Assert.assertEquals(false, foo.available(18, "AB123", true));
// #2
Assert.assertEquals(false, foo.available(19, "CD999", true));
// #3
Assert.assertEquals(true, foo.available(19, "AB123", true));
// #4
Assert.assertEquals(false, foo.available(19, "AB123", false));
}
Code language: Java (java)
ケース2: javascript
javascriptの jquery.jpostal.js の例です。jquery.jpostal.jsは、郵便番号を入力すると、住所欄を自動入力するjavascriptライブラリです。
3桁を入力したところで、県や市まで自動入力し、7桁すべて入力したところで、町名を自動入力します。
ソースをgithubに公開しています。そのソースのコメントに書いてあるのが、次の決定表1です。
番号 | ユーザが入力した 郵便番号の桁数 | 7桁一致のデータ が見つかった | 3桁一致のデータ が見つかった | 返り値 |
---|---|---|---|---|
1 | 1 | ーーー | ーーー | 空 |
2 | 2 | ーーー | ーーー | 空 |
3 | 3 | ーーー | Yes | 3桁一致のデータ |
4 | 3 | ーーー | No | 空 |
5 | 4〜6 | ーーー | Yes | 3桁一致のデータ |
6 | 4〜6 | ーーー | No | 空 |
7 | 7 | Yes | ーーー | 7桁一致のデータ |
8 | 7 | No | Yes | 3桁一致のデータ |
9 | 7 | No | No | 空 |
3桁と、4〜6桁とで、処理が違ってくるかもしれないと予想して、番号を分けたんだと思います。結果的には同じ処理でした。
次にソースです。
決定表どおりのロジックですが、決定表の番号とソースの対応がわかりにくく、そのため、決定表の番号をコメントに残していません。
少々無理やりに、決定表の番号をコメントに追記しました。(コード中の★)
var defaults = ['', '', '', '', '', '', '', '', ''],
address,
head3;
switch (i_postcode.length) {
case 3:
case 4:
case 5:
case 6:
// # 3,4,5,6 ★
head3 = i_postcode.substr(0, 3);
address = this.find(head3);
address = $.extend(defaults, address);
break;
case 7:
// # 7, 8, 9 ★
address = this.find(i_postcode);
if (address.length === 0) {
// # 8, 9 ★
head3 = i_postcode.substr(0, 3);
address = this.find(head3);
}
address = $.extend(defaults, address);
break;
default:
// # 1, 2 ★
address = defaults;
break;
}
return address;
Code language: JavaScript (javascript)
リファクタするとしたら、決定表を次のように整理します(決定表2)。
番号 | ユーザが入力した 郵便番号の桁数 | 7桁一致のデータ が見つかった | 3桁一致のデータ が見つかった | 返り値 |
---|---|---|---|---|
1 | 1〜2 | ーーー | ーーー | 空 |
2 | 3〜6 | ーーー | Yes | 3桁一致のデータ |
3 | 3〜6 | ーーー | No | 空 |
4 | 7 | Yes | ーーー | 7桁一致のデータ |
5 | 7 | No | Yes | 3桁一致のデータ |
6 | 7 | No | No | 空 |
これをもとに、次のようにコードをリファクタします。決定表の番号を書きやすくなりました。
var defaults = ['', '', '', '', '', '', '', '', ''],
addressFrom3 = [],
addressFrom7 = [],
head3;
if (i_postcode.length >= 3) {
head3 = i_postcode.substr(0, 3);
addressFrom3 = this.find(head3);
}
if (i_postcode.length == 7) {
addressFrom7 = this.find(i_postcode);
}
if (i_postcode.length <= 2) {
// #1
return defaults;
} else if (i_postcode.length <= 6) {
if (addressFrom3.length > 0) {
// #2
return addressFrom3;
} else {
// #3
return defaults;
}
} else {
if (addressFrom7.length > 0) {
// #4
return addressFrom7;
} else if (addressFrom3.length > 0) {
// #5
return addressFrom3;
} else {
// #6
return defaults;
}
}
return defaults;
Code language: JavaScript (javascript)
コードの後半の、郵便番号の桁数のif文は必要かな?addressFrom3、addressFrom7だけで判定できるよ。
if (addressFrom7.length > 0) {
// #4
return addressForm7;
} else if (addressFrom3.length > 0) {
// #2, 5
return addressForm3;
} else {
// #1, 3, 6
return defaults;
}
return defaults;
Code language: JavaScript (javascript)
リファクタしたコードをもとに、決定表も作り直したよ(決定表3)
番号 | 7桁一致のデータ が見つかった | 3桁一致のデータ が見つかった | 返り値 |
---|---|---|---|
1 | Yes | ーーー | 7桁一致のデータ |
2 | No | Yes | 3桁一致のデータ |
3 | No | No | 空 |
ちょっと待って、やりすぎじゃない?
たしかに、決定表3はやりすぎかもしれません。
なぜなら、決定表1、決定表2、決定表3では、テストケースが違ってくるからです。
テストする 桁数 | 決定表1 | 決定表2 | 決定表3 |
---|---|---|---|
1 | ○ | ○ | |
2 | ○ | ||
3 | ○ | ○ | ○ |
4 | ○ | ||
5 | |||
6 | |||
7 | ○ | ○ | ○ |
決定表に記載されている数値から、テストケースの値を考えます。極端な場合、決定表3からは、7桁と3桁のテストケースしか作られないかもしれません。
なので、決定表があるときは、ムダのあるコードと思っても、過度のリファクタはしないで、決定表のとおりの素朴な実装のほうがいいと思います。
ケース3: PHP
スマホアプリのようにGUIのあるものに比べると、サーバーサイドのアプリケーションはhttpリクエストごとに分かれるので、比較的シンプルです。
それでも、GET/POST変数のバリデーションをしたり、ファイル操作のエラー処理、データベースのエラー処理をしていると、ぐちゃぐちゃと読みづらくなってきます。
あるサイト内画像のサムネイル画像を返す処理です。
thumbnail.php?w=200&h=150&p=1234.jpg のように呼ばれます。
ある程度まで実装したところで、ロジックを整理するために、決定表を作りました。
番号 | p 画像ファイル名 | w サムネイル幅 | h サムネイル高さ | アクション |
---|---|---|---|---|
1 | 指定なし | ーーー | ーーー | 400 |
2 | 不正なパス (..を含んでいるなど) | ーーー | ーーー | 400 |
3 | ーーー | 指定なし | 指定なし | 400 |
4 | ーーー | 0>= | ーーー | 400 |
5 | ーーー | ーーー | 0>= | 400 |
6 | 他 | 0< | 0< | 決定表2へ |
番号 | 元 ファイル | キャッシュ ファイル | ファイル日時 | アクション |
---|---|---|---|---|
11 | なし | ーーー | ーーー | 404 |
12 | ある | ない | ーーー | キャッシュファイル作成 (決定表3へ) |
13 | ある | ある | キャッシュが古い | キャッシュファイル作成 (決定表3へ) |
14 | ある | ある | キャッシュが新しい | キャッシュファイルを返す |
番号 | 処理結果 | アクション |
---|---|---|
21 | エラー | 500 |
22 | 成功 | キャッシュファイルを返す |
この決定表にしたがってリファクタしたのが、次の疑似コードです。
// 疑似コード
public function thumbnail()
{
$case = $this->validate($_GET); // 決定表1
if (1 <= $case && $case <= 5) {
return 400;
}
$case = $this->shouldCreateCache(); // 決定表2
if ($case == 11) {
return 404;
}
if ($case == 12 || $case == 13) {
$case = $this->createCache(); // 決定表3
if ($case === 21) {
return 500;
}
}
return $this->loadCache();
}
Code language: plaintext (plaintext)
validateメソッドは、1〜6を返して
shouldCreateCacheメソッドは、11〜14を返すのね
なんか読みづらいね
決定表1〜3の番号を重複しないように作ったとはいえ、thumbnailメソッドの中で、決定表1〜3の番号があらわれてしまい、どうもすっきりしません。
そこで、全体を整理するために、決定表1〜決定表3をまとめた大きな決定表を作りました。
番号 | p 画像ファイル名 | w サムネイル幅 | h サムネイル高さ | 元 ファイル 有無 | キャッシュ ファイル 有無 | ファイル 日時 | キャッシュ ファイル 作成 | アクション |
---|---|---|---|---|---|---|---|---|
1 | 指定なし | ーーー | ーーー | ーーー | ーーー | ーーー | ーーー | 400 |
2 | 不正なパス (..を含んでいるなど) | ーーー | ーーー | ーーー | ーーー | ーーー | ーーー | 400 |
3 | ーーー | 指定なし | 指定なし | ーーー | ーーー | ーーー | ーーー | 400 |
4 | ーーー | 0>= | ーーー | ーーー | ーーー | ーーー | ーーー | 400 |
5 | ーーー | ーーー | 0>= | ーーー | ーーー | ーーー | ーーー | 400 |
6 | 他 | 0< | 0< | なし | ーーー | ーーー | ーーー | 404 |
7 | ↑ | ↑ | ↑ | ある | なし | ーーー | 失敗 | 500 |
8 | ↑ | ↑ | ↑ | ↑ | ↑ | ーーー | 成功 | キャッシュファイルを返す |
9 | ↑ | ↑ | ↑ | ↑ | ある | キャッシュが古い | 失敗 | 500 |
10 | ↑ | ↑ | ↑ | ↑ | ↑ | キャッシュが古い | 成功 | キャッシュファイルを返す |
11 | ↑ | ↑ | ↑ | ↑ | ↑ | キャッシュが新しい | ーーー | キャッシュファイルを返す |
番号1〜5のアクションは、400なので、p,w,hをまとめて検証して、false/tureで返します。
番号6のアクションだけが、404です。元ファイル=なし/あり、は残します。
キャッシュファイル有無、ファイル日時をまとめて、キャッシュファイルが古い(ない)/新しいを返すようにします。
そのように列をまとめてリファクタしたのが、次の決定表です。
番号 | p,w,h の検証 | 元ファイル 有無 | キャッシュ ファイル 日時 | キャッシュ ファイル 作成 | アクション |
---|---|---|---|---|---|
1 | false | ーーー | ーーー | ーーー | 400 |
2 | true | なし | ーーー | ーーー | 404 |
3 | ↑ | ある | 新しい | ーーー | キャッシュファイルを返す |
4 | ↑ | ↑ | 古い(ない) | 失敗 | 500 |
5 | ↑ | ↑ | ↑ | 成功 | キャッシュファイルを返す |
次は「p,w,hの検証」の決定表です。最初の決定表1とほぼ同じです。返り値がfalse/trueになりました。
番号 | p 画像ファイル名 | w サムネイル幅 | h サムネイル高さ | 返り値 |
---|---|---|---|---|
1 | 指定なし | ーーー | ーーー | false |
2 | 不正なパス (..を含んでいるなど) | ーーー | ーーー | false |
3 | ーーー | 指定なし | 指定なし | false |
4 | ーーー | 0>= | ーーー | false |
5 | ーーー | ーーー | 0>= | false |
6 | 他 | 0< | 0< | true |
「キャッシュファイルが古いか」の決定表も、最初の決定表2とほぼ同じです。返り値がfalse/trueになりました。
番号 | 元 ファイル | キャッシュ ファイル | キャッシュ ファイル 日時 | アクション |
---|---|---|---|---|
1 | なし | ーーー | ーーー | (ないはず) |
2 | ある | ない | ーーー | true |
3 | ある | ある | 古い | true |
4 | ある | ある | 新しい | false |
リファクタした3つの決定表にしたがって、リファクタした疑似コードです。
// 疑似コード
public function thumbnail() // リファクタした全体の決定表
{
if (!$this->isValidate($_GET)) { // リファクタ決定表1
// #1
return 400;
}
if (!file_exists($srcPath)) {
// #2
return 404;
}
if ($this->isCacheOlder($srcPath, $dstPath)) { // リファクタ決定表2
$err = $this->createCache();
if ($err) {
// #4
return 500;
}
}
// #3, #5
return $this->loadCache();
}
Code language: plaintext (plaintext)
file_exists($srcPath)の判断を追加しました。
基本的な処理の流れはリファクタ前のコードと同じですが、決定表の番号によるif文がなくなったので、格段に読みやすくなりました。
決定表を作っているとき、「次の決定表へ」と書きたくなったら、全体をまとめて、見直したほうがいいかもしれません。
そして、リファクタした決定表2: isCacheOlderのコード例です。
<?php
public function isCacheOlder($srcPath, $dstPath)
{
if (!file_exists($srcPath)) {
// #1
throw new \Exception("file not exists: $srcPath");
}
if (!file_exists($dstPath)) {
// #2
return true;
}
$srcTime = filemtime($srcPath);
$dstTime = filemtime($dstPath);
if ($dstTime < $srcTime) {
// #3
return true;
} else {
// #4
return false;
}
}
Code language: PHP (php)
#1 には到達しないはずです。「実践テスト駆動開発」では、Defectクラスを定義して、到達しないはずの箇所には、throw new Defect("メッセージ"); としていました。
isCacheOlderの番号2のユニットテストです。
番号 | 元 ファイル | キャッシュ ファイル | キャッシュ ファイル 日時 | アクション |
---|---|---|---|---|
1 | なし | ーーー | ーーー | (ないはず) |
2 | ある | ない | ーーー | true |
3 | ある | ある | 古い | true |
4 | ある | ある | 新しい | false |
IO処理があるとユニットテストを書くのは難しいことが多いのですが、この場合は、/tmp 下にファイルを作成することで、ユニットテストを作ることができました。
<?php
public function test_isCacheOlder_case2()
{
$srcPath = "/tmp/src.jpg";
$dstPath = "/tmp/dst.jpg";
touch($srcPath);
if (file_exists($dstPath)) {
unlink($dstPath);
}
$this->assertTrue($this->target->isCacheOlder($srcPath, $dstPath));
}
Code language: PHP (php)
isCacheOlderの番号3のユニットテストです。
<?php
public function test_isCacheOlder_case3()
{
$srcPath = "/tmp/src.jpg";
$dstPath = "/tmp/dst.jpg";
$t = time();
touch($srcPath, $t);
touch($dstPath, $t);
$this->assertTrue($this->target->isCacheOlder($srcPath, $dstPath));
}
Code language: PHP (php)
テストケースを作っていると、エッジケースについて明確に考えていないことに気づきます。
この場合は、元ファイルとキャッシュファイルのファイル日時が同じとき、キャッシュは古いのか、新しいのかです。
そんなに神経質にならなくても、どっちでもいいんじゃないの?
そうね、同じファイル日時になることは、まずないわね
でも、どちらだと思う?
00:00:00.0秒に元画像をアップロードして、
00:00:00.9秒にキャッシュを作成したら、
同じファイル日時だよね。
だから、キャッシュが新しい、かな?
じゃあ、
だいぶ前に、元画像をアップロード、
00:00:01.0秒にキャッシュを作成、
00:00:01.9秒に元画像を再アップロード、
しても、同じファイル日時だわ!
だから、キャッシュが古い、が正解ね。
そんなことありえない、とも言い切れない、ね