Transpec 開発記 – 前編
Aug 28th, 2014
Transpecという、RSpecの古い記法で書かれたspecを、最新の記法に自動で書き換えるツールを作った。
最初のバージョン0.0.1をリリースしたのが2013年8月9日なので、すでに一年前になる。 先日のRSpec 3の正式リリースからもしばらく経って一段落したところだし、 この辺で一旦振り返って、 開発中のその時々で何を考えていたのか、忘れてしまう前に長々と残しておくことにする。
きっかけ
そもそもTranspecを作り始めたきっかけは、 should記法を使っていた自分のプロジェクトのspecをexpect記法に書き換えようとしたところから。 これは2013年の7月下旬の話で、まだRSpec 2.99/3.0のベータ版も出ていない頃。
正直なところexpect記法が導入された当初は違和感があったし、 更にはThe Plan for RSpec 3も発表され、 「このままRSpecを使い続けるべきなのか?」と言う気持ちもあった。 しかし実際他のテストフレームワークを触ってみたり、変更に至った経緯を詳しく追っていくうちに、 なんだかんだでやっぱりRSpecかなという結論に。 例え乗り換えるにしても、既存のRSpecを使ったプロジェクトを今すぐ放り投げられる訳でもない。
また、別ブロジェクトでexpect記法を使っているうちにそっちに慣れてきたこともあって、 自分のプロジェクトも全部書き換えることにした。
流石にすべて手作業はしんどいので、
まずは安直に正規表現のワンライナーで
find spec -name '*.rb' | xargs perl -i -pe 's/(\w+)\.should/.../g'
1
とかやってちまちま変換しようと試みる。
が、実際やってみると誤変換や変換残りが結構あるし、
演算子マッチャがからんできたりもするので、
ワンライナーでスムーズにやれるレベルではないと認識。
そもそも正規表現なんて緩い仕組みで複雑なソースコードの書き換えをしようというのが間違っている。 単なる文字列処理ではなく、 ちゃんとRubyコードの意味を解釈して変換したいコードを狙い撃つ方がスマートだし、 正規表現と延々と格闘して消耗するより早い。 幸いRuboCopプロジェクトでの経験から、 ソースコードのパースによって得られるAST(Abstract Syntax Tree)の扱いと、 ソース書き換えのノウハウはあったので、 静的解析ベースの変換ツールを作ってみることにした。 この時点では自分用のツールで、汎用化するつもりはあまりなかった。
プロトタイプ
思い立ったが吉日、まずはプロトタイプを作った。
とりあえずはobj.should
からexpect(obj).to
への置き換えと、それに伴う演算子マッチャの変換のみで、
コードはせいぜい200行程度。
ちなみにこの時のプロジェクト名はExpectizeで、
その名残が未だに一つのメソッドとして残っている。
実際にプロトタイプを実行してみると、自分が想像していた以上にうまく変換できていて予想外に感動した。 何より一気に自動変換された後のdiffを眺めていると、なんかこう、気持ち良いというか、 まるで人間が書き換えたかのような魔法っぽさがすごい面白かった。 「お、おお〜、おほ、おほほほほ」とか言いながらニヤニヤしていたと思う。 これは面白いぞ。単なる退屈な作業の省力化という、マイナスをゼロへ近づけるだけのものでなく、 本来ネガティブな体験をポジティブなものに変えられる可能性があるというのはデカい。
モチベーション
というプロトタイプの感触から、汎用化してリリースすることを決めた。 Transpecの開発に対するモチベーションは他にも色々あったけど、 常にコアとなっていたのはこの「いかにポジティブな体験にするか」という部分だったように思う。
どうせやるならshould/expect記法の変換だけでなく、RSpec 3における各種変更もサポートしちゃおう、 ということでRSpec 3へのアップグレード補助ツールという位置付けを狙うことにした。
また、決定打となったのはプロトタイプの感触ではあったものの、 他にもいくつかのモチベーションがあった。
コミュニティへの還元
自分が困っている問題は、他にも困っている人がいるはず。 特にRSpec 3が正式リリースされたら、 アップグレード作業の面倒くささにコミュニティから不満が噴出するだろうと思った。
最初のきっかけの時点から、RSpecのコードを読んだりリポジトリをウォッチしたりして、 RSpecそのものやRSpec 3での変更に関する知見がかなり得られてきていた。 せっかくだから「RSpec 3へのアップグレードコンプリートガイド」みたいな記事書こうかな、 と考えたりもした。
しかし、そのノウハウの大半はアップグレード時の一過性のものだし、 その知識も地道なアップグレード作業も、即座に価値を産み出すものではない。 しかも、様々なケースを網羅してどう対処すべきかを緻密に自然言語で書き、 読者がそれを読み解いて忠実に実行する、 それって人間の仕事じゃないでしょ。 ということで、それらのノウハウは可能な限り実行可能なコードに落とし込み、 誰もが簡単に実行できるようにすることにした。
Rubyにおける実用レベルのコード変換の実現
単なる proof of concept 的な「なんか面白いもの作ってみた」程度のものではなく、 現実世界に存在する無数のプロジェクトでの利用に耐えうるクオリティのものを実現できるか、 試してみたかった。
言語やフレームワークの変更に対して、ソースコードの自動書き換えによって対応した前例としては、 自分が経験した中ではXcodeによるObjective-CのMRCからARCへの変換や、 Modern Objective-Cへの変換がある。 この変換の精度はそこまで完璧ではなかったけれど、 まあ必要な作業の8割くらいはやってくれたかな?くらいな印象だった、気がする。
他には、使ったことはないけれど、Python 2のコードをPython 3対応に変換する2to3
というツールもあるらしい。
Rubyの世界ではそういう大きな前例は聞いたことがなかったし、 動的型付け言語であるという時点で(静的解析で得られる情報が少ないため) 不利なことは否めなかったけど、 実現できるか?できたとしてコミュニティがどう受け止めるか? みたいな好奇心があった。
とは言っても、Rubyでは全然無理ですなんてことはまず有り得なくて、 少なくとも7〜8割くらいのケースはカバーできるだろうとは予想していた。 ただそれ以上の精度に関しては、言語の変更なのかフレームワークの変更なのか、 また、言語が静的型付けなのか動的型付けなのか、という辺りで大分変わると思うので未知数だった。
技術的課題
さて、モチベーションが湧いて夢が広がりんぐな構想を巡らせていると、 色々な課題も浮かび上がってくる。
人間の仕事ではないので機械にやらせようとは言ったものの、 ソースコードを正確に書き換えるという行為は、 機械がやるにはちょっと面倒くさくて、人間がやるには単調過ぎる、 というような微妙な領域にあると思う。
ソースの変換処理というのは、大きく以下の三つののフェーズに分けることができて、 それぞれに問題が見えてきた。
- 変換対象の検出
- どんな形に変換するかの判断
- 書き換え処理
コードスタイルの尊重
これは書き換え処理における問題。
今回Transpecが書き換えるソースコードはspecファイルであり、 変換後も人間がメンテナンスしていくものである。 そんなソースに対して、 変換前のコードスタイルを完全に無視して違和感バリバリのコードを生成するような、 おざなりな仕事をするツールは自分がユーザーだったら使いたくない。
という訳で、なるべく既存のコードスタイルを活かした変換をしたい。 もちろん、不正なコードを出力しないという大前提と共に。 さてどうすんのという話だけど、これについては銀の弾丸はない。
Transpecが行うのは、RSpecの等価なAPI間の変換である。
その等価なAPI — 例えばobj.should
とexpect(obj).to
— というのは、
RSpecという単なる1フレームワークがRubyレイヤーの上に作り上げたものに過ぎず、
Rubyのレベルで見ると変換前後でのプログラムの意味は等価ではなくなる。
プログラムの意味が変わるということは、つまりASTの構造も変わる。
そもそもASTって何なのという話だけど、Wikipediaによると、
抽象構文木(abstract syntax tree、AST)とは、通常の構文木(具象構文木、あるいはparse treeとも言う)から、言語の意味に関係ない情報を取り除き、意味に関係ある情報のみを取り出した(抽象した)木構造のデータ構造である。
らしい。 これは要するに、ASTはプログラムの意味を解析するのに便利なデータ構造であり、 その上でノイズにしかならない、ソースコード上での見栄えに関する情報を取り除いたもの、ということ。
例えば「if
文の中でputs
メソッドで文字列"foo"
を出力している処理」を検出したいという要求があった場合、
こういうコードも、
if some_condition
puts 'foo'
end
こんなコードも、
puts("foo") if some_condition
ソースコード上での形は違うが、プログラムとしての意味は全く同じなので、これらを等価に扱える必要がある。
もしソースコードを文字列としてそのまま解析するのであれば、 そんな処理を実装するのは気が遠くなる話だけど、 そんな気が遠くなることを代わりにやってくれるパーサーという有り難い存在がある。 パーサーは入力としてソースコードを受け取り、出力としてASTなどのデータを吐き出す。 上記の2つの例をパースして得られるASTは、なんと両方とも以下の構造になる。 これはParserによって生成されたASTをS式で表現したもので、 実際には木構造になっている。
(if
(send nil :some_condition)
(send nil :puts
(str "foo")) nil)
つまり、ASTだけを見ればソースコード上の細かな違いを気にせず、意味の解析に集中できる。 超便利。
さて、話をソースの書き換えに戻すと、プログラムの意味を変えるソース変換の一つの方法として、 ソースを文字列として操作するのではなく、 元のソースから得られたASTの構造を組み替え(AST変換)、 その組み替えられたASTから意味的に等価になるソースを自動生成するという方法がある。
この方法を使えば、ASTだけを見れば良いので変換のロジックはシンプルになるし、 少なくともRubyレベルで不正なソースコードを吐き出すことはなくなる。
しかし前述の通り、ASTはソースコード上の見栄えに関する情報が失われているため、 ASTから生成されたソースには、元のソースのスタイルが全く反映されない2。
ということでここまで長々と書いたけど、Transpecではこの方法は使っていない。 代わりに、ParserのASTは元のソースへのロケーションマップ情報を持っているので、 それを参照しながら地道に文字列操作をすることにした。 つまりロケーションマップという補助的な情報はあるけれど、 結局は原始的な文字列操作なので、 変換対象の周囲のコードに配慮をしないと不正なコードを出力してしまう可能性はある。
世の中には、Rubyの構文の自由度の高さを活かした変態的なコードを書く人もいるけど、 そんな未知のコードであろうが、Rubyとして正しく、RSpecのAPIを正しく使っている限り、 なるべく正常に変換できるようにしたい。 これについては結局、テストケースを充実させながら地道に実装していくしかないという結論に。
ASTだけでは情報が足りない
これは、変換対象の検出と、どんな形に変換するかの判断に関わる問題。
インターフェースが似ている同名のメソッドを見分けられない
例えば、今回Transpecが変換対象とする、mock
とstub
というRSpec 3で廃止されたメソッドがある。
これはdouble
の単純なaliasであり、
書き換え処理としてはmock
というメソッド名をdouble
に置換するだけで良い。
が、それはそのmock
が本当にRSpecによって定義されたmock
であればの話。
例えばこんなmock
呼び出しがあったとき、
it 'is an object' do
book = mock('book')
book.should be_an(Object)
end
これはRSpecのmock
だろうか?double
に変換して良いだろうか?
それはASTを見てもわからない。
上記のソースコードから得られるASTの中で、
コード片mock('book')
に対応するノードは以下の形になる。
(send nil :mock
(str "book"))
Rubyは動的型付け言語なので、静的に得られたデータであるASTに型情報はない3。
このASTからわかるのは、
それがメソッド呼び出しであり、レシーバーは省略されていて、メソッド名はmock
で、
文字列リテラル"book"
を引数として渡している、ということだけ。
この呼び出し方であれば、
Mochaのmock
であってもおかしくないし、
RRのmock
の可能性もある4。
もしくはユーザーが自分で定義したヘルパーメソッドかもしれない。
当然それらを間違ってdouble
に変換してしまうと、specが壊れる。
結局のところ、ASTのみを頼りに変換対象のメソッドを探す場合、使える情報としては
- メソッド名のユニークさ
- レシーバーを取るかどうか
- 引数の数
程度の、割とゆるい情報しかないという問題がある。
流石にshould
という名前のメソッドが自前で定義されている、というケースはあまりなさそうだけど、
上記のmock
とか、stub
なんかは結構他のフレームワークとかぶっていたりする。
またexample
というメソッドは、RSpec内だけでも2つ存在している上に、
普遍的な名前過ぎてユーザーのヘルパーメソッドやlet
によって不意にオーバーライドされてしまっていることがある。
どんな形に変換するべきか判断できない
もう一つの例として、RSpec 3で廃止されたhave(n).items
マッチャというものがある。
これはテスト対象オブジェクトの性質によってその挙動を変える、
やり過ぎ感あふれるマッチャだったりする。
以下、その挙動のバリエーション。
class NumberSet
def initialize(*numbers)
@numbers = numbers
end
def count
@numbers.size
end
def odd_numbers
@numbers.select(&:odd?)
end
def even_numbers
@numbers.select(&:even?)
end
def numbers_larger_than(threshold)
@numbers.select { |number| number > threshold }
end
private
def negative_numbers
@numbers.select { |number| number < 0 }
end
end
describe 'have(n).items' do
let(:set) { NumberSet.new(-7, -1, 1, 2, 5) }
it 'is complicated' do
# テスト対象が、末尾のitems部と同名のメソッドに応答せず、
# size, length, countいずれかのメソッドに応答する場合
expect(set).to have(5).items
expect(set).to have(5).numbers
expect(set).to have(5).qawsedrftgyhujikolp
expect(set.count).to eq(5) # 等価
# テスト対象がodd_numbersメソッドに応答する場合
expect(set).to have(4).odd_numbers
expect(set.odd_numbers.size).to eq(4) # 等価
# ActiveSupport::Inflectorがロードされていない場合
expect {
expect(set).to have(1).even_number # Fail
}.to raise_error(RSpec::Expectations::ExpectationNotMetError)
expect(set).to have(1).even_numbers # Pass
# ActiveSupport::Inflectorがロードされている場合
require 'active_support/inflector'
expect(set).to have(1).even_number # Pass
expect(set).to have(1).even_numbers # Pass
expect(set.even_numbers.size).to eq(1) # 等価
# 引数が渡された場合
expect(set).to have(2).numbers_larger_than(1)
expect(set.numbers_larger_than(1).size).to eq(2) # 等価
# テスト対象がsize, length, countいずれにも応答せず、
# プライベートなnegative_numbersメソッドに応答する場合
class << set
undef_method(:count)
end
expect(set).to have(2).negative_numbers
expect(set.send(:negative_numbers).size).to eq(2) # 等価
end
end
これは実際廃止されてもしょうがないよね、と思ってしまうくらいにやり過ぎな挙動だけど、 皮肉なことにTranspecはそういうものを一掃するためのツールなので、 これに対処する必要がある。
have(n).items
マッチャの変換後の形を決めるには、ASTからは得られない以下の情報が必要になる。
- テスト対象が
size
,length
,count
のいずれかに応答するか - テスト対象が
have(n).items
のitems
部のメソッドに応答するか- 応答するのであれば、それはプライベートメソッドか
ActiveSupport::Inflector
がロードされているか
で、どうする
この「ASTだけでは情報が足りない」問題は、 どう考えても単純な静的解析では手に負えない問題で、 とりあえず初期リリースの段階では考えないことにした。 衝動ドリブンで開発している時にあまり遠くの問題を見過ぎると、 失速して完成せずに終わってしまう恐れがある。
一応このとき、 「最悪、他にスマートな方法がなければ強引にアレをこうすればいけるかも」という案は考えていて、 最終的にはその方法を使うことになる。
リリースに向けて
といったようないくつかの問題を考えたところで、 平均的な使い方として1プロジェクトに対して1回使ったら終わりのツールに、 そこまでの労力をかける意義はあるのか?という疑問もあったのだけど、 RSpecのシェアを考えれば自分一人がそのくらいの労力をかけるのはアリだと思った。 もっとマイナーなフレームワークだったらやらなかったかもしれない。
つづく