続々: いい加減 node.js で unit test りたくなってきて mocha

とにかく既存のコードの振る舞いを保存せねばと。

今のワタシの場合テストを書かずに保守してきたコードを TDD に移行するわけだから、「今とにかく正しく振舞ってるもの」を一気に assert.equal なりで記述していきたいわけだ。

どんな unit test framework を使おうとも共通の悩みというのが結局のところは、戻りの正しさをテストする際に、

  • パターンを網羅する観点から言えば全フィールドテストしなくてもいい
  • 全フィールドを一括でまとめてテストする方が楽なこともある

という2つの相反するニーズのバランスを取ること、だったりするわけね。つまりワタシのを例にすればたとえば:

 1 function readFile(fbase, cb) {
 2     fs.readFile(
 3         path.join(__dirname, "_got_pages", fbase),
 4         {encoding: 'utf-8'},
 5         cb);
 6 }
 7 describe('MalPageParser#parse_person', function() {
 8     it("normal", function(done) {
 9         readFile("people/Tomoyo_Kurosawa", function(err, data) {
10             var result = parser.parse_person(
11                 data, {
12                     no_timg: true,
13                     defail_fields: ["brth", "bld", "fav", "htwn"],
14                 });
15             assert.equal(result["#"], (11661).toString(26));
16             assert.equal(result["n"], "Kurosawa, Tomoyo");
17             assert.equal(result["/"], undefined);
18             assert.equal(result["p"], "/images/voiceactors/3/30873.jpg");
19             assert.equal(result["j"], "黒沢 ともよ");
20             assert.equal(result["dtl"]["brth"], "1996-04-10Z+09");
21             assert.equal(result["dtl"]["fav"], 1238);
22             assert.equal(result["dtl"]["bld"], "O");
23             assert.equal(result["dtl"]["htwn"], "Saitama, Japan");
24             assert.equal(result["va"].length, 39);
25             assert.equal(result["va"][23]["a"]["#"], (35557).toString(26));
26             assert.equal(result["va"][23]["a"]["n"], "Houseki no Kuni (TV)");
27             assert.equal(result["va"][23]["a"]["/"], undefined);
28             assert.equal(result["va"][23]["ms"], 1);
29             assert.equal(result["va"][23]["c"]["#"], (110355).toString(26));
30             assert.equal(result["va"][23]["c"]["n"], "Phosphophyllite");
31             assert.equal(result["va"][23]["c"]["/"], undefined);
32             done(err);
33         });
34     });
35 });

「網羅性」の意味で、このように「全フィールドテストするのではなく、パターン網羅に必要なものだけ選んでテストする」のは合理的で正しい。(まだカバレッジ計測しかけてないので「網羅」してるかどうかはわからんけれど。)

上のスタイルで非常に苦痛なのが、当然ながら「一個一個書くのがキツい、ダルい、ツラい」ということよりも、「一つでもアサーションが失敗すれば、そこでテスト全体が打ち切られてしまう」ということ。フィールドどうしが従属関係を持っている場合にはそれでも良いのだが、個々には原則として独立している場合、これは「一括で誤りを見つけたい」ということも多い。もちろん何か変更時、特にフィールド追加するような変更時に「テストを追加し忘れる」ということが増えてしまうことも、結構問題。(まぁ「追加にへこたれないテスト」というのも便利だったりはするんだけれども。つまり「元の振る舞いを壊してない」ことをまずは知りたいからね。)

で、「一つでもアサーションが失敗すれば、そこでテスト全体が打ち切られてしまう」ことを嫌う場合に何を考えるかと言えば、「テストを分割する」か、「構造全体を一気に比較する術を考える」のどちらか、もしくは両方、ということね。ただ、前者なんだけど、こういうようなことは出来ないかと:

これはダメ、出来ない
 1         readFile("people/Tomoyo_Kurosawa", function(err, data) {
 2             var result = parser.parse_person(
 3                 data, {
 4                     no_timg: true,
 5                     defail_fields: ["brth", "bld", "fav", "htwn"],
 6                 });
 7             it("test_id", function(done) {
 8                 assert.equal(result["#"], (11661).toString(26));
 9                 done();
10             });
11             it("test_name", function(done) {
12                 assert.equal(result["n"], "Kurosawa, Tomoyo");
13                 done();
14             });
15         });

it がどういう動きをしてるのか把握しきれていないのでなんとなくこういうことが出来そうと思ってしまうんだけれど、まぁ出来ないなら出来ないでもいいか…。ほんとにそうまで分割したいなら素直に「普通の分割」をすればいい。「観点ごとに」テストを書くてのはそういうことだしな。(だいたいほかの言語に付いてる unit test framework だってそんなこと出来ないわけだから、受け容れちゃえばいいだけのことで。)

もう一つが、「警告相当のものを使う」ということで、そういうのが出来るものがほかのテストフレームワークで経験した記憶はないけれど、そしてワタシは最初まさに誤解してたんだけど、ASSERTIONS は mocha 組み込みではないのね。これ、「デフォルトでは node.js そのものが持ってるもの」てことだけ、なんですと。なのでほかのアサーションに差し替えることも出来る、とあって、ひょっとしたら「差異検出はするがテストを続行する」というニーズに合うものもあったりするかしら、とちょっと期待してもいいのかしらね。(いずれ見てみようと思うが、今回は頭の片隅に置いておくに留める。)

「構造丸ごと比較」は assert.deepEqual で可能だが、「期待値をファイルに書き込んでおく」とでもしない限りは、フィールド数が多いとかえって大変になったりもするわけで、なので以下のように「部分的に活躍させる」ことになるのかなと:

 1 describe('MalPageParser#parse_charas_and_staff', function() {
 2     it("normal", function(done) {
 3         readFile("characters/Houseki_no_Kuni_TV", function(err, data) {
 4             var result = parser.parse_charas_and_staff(
 5                 data,
 6                 {no_timg: true,
 7                  no_rating: false});
 8 
 9             assert.equal(result["#"], (35557).toString(26));
10             assert.equal(result["n"], "Houseki no Kuni (TV)");
11             assert.equal(result["/"], undefined);
12             assert.equal(result["p"], "/images/anime/3/88293.jpg");
13             assert.equal(result["j"], "宝石の国");
14             // 構造が深くないなら deepEqual は嬉しい
15             var detail = result["dtl"];
16             const expected_detail = {
17                 eng: 'Land of the Lustrous',
18                 syno: 'Country of Jewels',
19                 type: 'TV',
20                 eps: 12,
21                 aird: [ '2017-10-07Z+09', '2017-12-23Z+09' ],
22                 prem: [ '2017_4' ],
23                 bc: 'Saturdays at 21:30 (JST)',
24                 prod: 
25                 [ { '#': (159).toString(26), n: 'Kodansha' },
26                   { '#': (1143).toString(26), n: 'TOHO animation' } ],
27                 licn: [ { '#': (376).toString(26), n: 'Sentai Filmworks' } ],
28                 stdi: [ { '#': (1109).toString(26), n: 'Orange' } ],
29                 src: 'Manga',
30                 gnr: [ 'Action', 'Fantasy', 'Seinen' ],
31                 dur: 24,
32                 ratg: 'PG-13',
33                 scor: 8.5,
34                 scorb: 34745,
35                 rnk: 101,
36                 popl: 866,
37                 memb: 94631,
38                 fav: 1899
39             };
40             assert.deepEqual(detail, expected_detail);
41             //
42             assert.equal(result["chs"].length, 27);
43             // ↓これを deepEqual しようとすると 27 人分全部書かないと
44             //  いけないので却って大変、というわけで。
45             assert.equal(result["chs"][0]["c"]["#"], (110355).toString(26));
46             assert.equal(result["chs"][0]["c"]["n"], "Phosphophyllite");
47             assert.equal(result["chs"][0]["c"]["/"], undefined);
48             assert.equal(result["chs"][0]["ms"], 1);
49             assert.equal(result["chs"][0]["v"].length, 1);
50             assert.equal(result["chs"][0]["v"][0]["#"], (11661).toString(26));
51             assert.equal(result["chs"][0]["v"][0]["n"], "Kurosawa, Tomoyo");
52             assert.equal(result["chs"][0]["v"][0]["/"], undefined);
53             done(err);
54         });
55     });
56 });

「期待値をファイルに書き込んでおく」ことが受け容れられる場合はそうすれば良くて、特に今ワタシが置かれてる状況というのがまさに「現状保存」を主目的とするテスト記述なので、「既に正しいと思われる結果」を手持ちなわけよね。だからそれを予め json で書き出して、読み込んで、deepEqual するのは合理的ではある。

ただ、「未来永劫それが格調高い」かというとそれはまた話は別で、デザインや振る舞いを変更した場合に「期待値ファイルを更新するコスト」が嵩むようなら、ずっとそれを続けるわけにはいかない、ということでもあったりはするのよね。(上でも書いたけれど、「リグレッションテスト」の証明になりにくいのも場合によっては問題なわけね。「テストの変更を要するリグレッションテスト」は、テスト変更時に「既存部分のテストを変えてない」証明をしないといけないんだから。)

あとねぇ、こういう「一括比較」は、テスト実装者の「意図」を隠してしまうことも多いんだよねぇ。ワタシの例だと、実は「”/”」というキーが「存在したりしなかったりする」というデザインになってる。これは何かと言うと、link canonical name、つまり:

1 https://myanimelist.net/anime/35557/Houseki_no_Kuni_TV/characters

における「Houseki_no_Kuni_TV」部分。これを「名前(今の場合「Houseki no Kuni (TV)」)」から計算出来る場合は省略してるのね。なので、「”/” キーに対応する値が undefined」というテストにはちゃんとした意図があるわけだけれど、まとめて比較するテストにしちゃうと、注意深くやらないとそういった意図を隠してしまうことになる。


みたいなひっじょーに当たり前のことを考えながら mocha っている。今のところ「スタートに躓いた」こと以外は何の不満もないね。いいと思いマス。

だいたい上ではさらっと「全フィールドテストするのが楽な assert.deepEqual」と流してるけれど、これが「全項目一致してるかどうか」で終わってしまうフレームワークなら、これは「そうしないほうが断然楽」になるわけよ。実際大昔 java 1.5 の頃だったかなぁ、junit で基幹系業務の unit test をしてたときは、「自力で自作のコンペア処理」書いてたもん、わざわざ。つまり「期待と違うのはわかった、どう違うんだ?」が即座にわからないのが非効率なわけね。「どの項目が違ってる」ということすら何もしなけりゃわからないんだから。なので mocha が組み込みでレポートに「差異」を出してくれるのは、本来「すげーすげーすげー」ことなんですわよ。まぁいまどきはこんくらいは当たり前になっちゃったから、誰もこの程度では驚かないとは思うけれど。

にしても「it」ってもちっと長い名前にして欲しかったなぁ。あんまり it なんて命名しないから衝突しないんだろうけれど、いくらなんでも2文字は切り詰め過ぎじゃぁないのかね。