クリアコードでは来年の夏にインターンシップを実施する予定です。(2009年8月〜9月を予定)
そこで、インターンシップ参加希望者の募集を開始しました。
詳しくは募集ページに書いていますが、ここにも簡単に概要を書いておきます。
応募条件は「プログラミングが好きなこと」です。他は、学生であることとまとまった時間がとれることなので、「プログラミングが好きなこと」だけが条件といってよいかと思います。もっと具体的な条件にしたり、細かく条件を並べるよりも、一番大切なこと1つだけとしました。「プログラミングが好きなこと」がプログラミング技術が飛躍的に上達するために必須のことだからです。
インターンシップでは、フリーソフトウェアの開発を参加者とクリアコードのエンジニアがペアプログラミングで行います。このようなカリキュラムにしたことにはいくつが理由がありますが、一番の理由はこのカリキュラムが参加者もクリアコードのエンジニアもともに成長できると信じているからです。
開発対象としてフリーソフトウェアを選んだことには2つの理由があります。1つはクリアコードのエンジニアが業務で活かしている技術はフリーソフトウェアの開発で身につけたものだからです。もう1つは業務でFirefox/ThunderbirdやRubyなどの既存のフリーソフトウェアを利用したり、新規に開発することも多いからです。フリーソフトウェアではない開発業務もありますが、その場合でもフリーソフトウェアの開発で身につけた技術は活きています。クリアコードの業務が成り立つのはフリーソフトウェアの開発に関わっているからといっても過言ではありません。そして、参加者にとってもフリーソフトウェアの開発に関わることが有益だと思っています。
インターンシップという短い期間で、参加者には2つのことを学んでほしいと考えています。1つはプログラミング技術の向上、あるいは、向上するための方法です。もう1つはソフトウェア開発業務についてです。フリーソフトウェアの開発をペアプログラミングで行うことでそれらを実現できると考えています。
ペアプログラミングで開発を行うことにより、参加者には技術面において短い期間で大きな効果がでることを期待しています。また、クリアコードのエンジニアも教えることで学んでいけると期待しています。経験がある方も多いと思いますが、教えることで自分の理解が進むことは多いものです。
また、フリーソフトウェアの開発という内容はクリアコードでのソフトウェア開発業務にも結びつきます。クリアコードでは業務の中でフリーソフトウェアを利用するだけではなく、開発することもあるからです。例えば、C言語用のテスティングフレームワークのCutterや迷惑メール対策を支援するmilter-managerなどです。
フリーソフトウェアの開発をペアプログラミングで行うというカリキュラムで、参加者もクリアコードのエンジニアもともに有意義なインターンシップになることを期待しています。
参加者もそうなると思いますが、このインターンシップはクリアコードにとっても初めてのインターンシップになります。よりよい内容にするためには試行錯誤を重ねていく必要があり、今はまだ最初の一歩です。そのため、現段階ではよりていねいにインターンシップを行うことができるように、今回は1名のみの募集としました。
興味がある方はインターンシップ応募申込書(Calc用|Excel用)に必要事項を記入し、minami@clear-code.comにメールで提出してください。疑問点なども同じアドレスで受け付けています。
余談ですが、インターンシップ参加希望者募集にあわせて会社紹介パンフレットを作成しました。(インターンシップページの最下部にリンクがあります。)
2週間ほど前になりますが、オブジェクト倶楽部2008冬イベント『オブラブ忘年会 〜ふりかえり2008〜』のライトニングトークスで話してきました。
発表資料: xUnit 2008
一番伝えたかったことは「テスティングフレームワークはデバッグ支援の機能を提供することが重要」だということです。時間もないので、その中でも「期待値と実際の値の違いの見せ方」についてだけ触れました。(資料でいうと24枚目からの話)
表向きは上の通りなのですが、今回の発表では1つ試してみたことがあります。それは、「会社紹介は一番最後がよいのではないか」ということです。一般的には自己紹介・会社紹介をしてから内容に入ることが多い気がしますが、それとは少し異なります。
先日、Email Security Expo & Conference 2008(来年の今頃はこのURLになっていそう)に参加しました。参加の目的は情報収集です。商用の迷惑メール対策用製品がどこをウリにしているのかとどのようなWebインターフェイスを提供しているかに興味がありました。
現在、IPAの2008年度 オープンソフトウェア利用促進事業 上期テーマ型(開発) 公募に採択された「迷惑メール対策ポリシーを柔軟に実現するためのmilterの開発」のために、milter-managerを開発しているのですが、それを開発する上で参考にするためです。
イベントでは製品紹介のセッションを中心に受講したのですが、その中で気づいたことがありました。 プレゼン資料の中で、一番最後に会社紹介を持ってきているセッションが多かったのです。そして、いくつかのセッションを聴いているうちにこれはとてもよい方法だということがわかりました。
この方法が有効なのは、セッションを聴いて、この製品がよさそうだなぁと思ったときです。プレゼンの一番最後に会社紹介を持ってくることで、よさそうだと思ってもらった後のアクションを誘導することができるのです。
プレゼンしている側としては、製品を紹介し、興味を持ってもらった人に問い合わせてもらいたいものです。一番最後に会社紹介を持ってくることで問い合わせ先を伝え、問い合わせるというアクションを誘導しているのです。(イベントではブースコーナーもあったので会社名さえわかれば、ブースコーナーに行って、デモを試したりより詳しい説明を受けることができた)
一番最後に会社紹介がないセッションを聴いているときにも、この製品はよさそうだなぁと思い、デモを見にいこうと思うことがありました。しかし、プレゼンの最後の頃には最初の方にあった会社紹介の内容はすでに覚えていなかったため、配布されていた資料を見直して、自分でどの会社かを調べ直す必要がありました。興味を持った人にこのような手間をかけさせると、その手間のために次のアクションを起こさないかもしれません。せっかく興味を持った人がスムーズに次のアクションに進めるように、ちょうど良いタイミングで次のアクションのための情報を提供することは有効だと思います。
このような気づきがあったので、オブジェクト倶楽部でのライトニングトークスでは「一番最後に会社紹介をする」ということを試してみました。今回は、話す内容のベースに「動作するきれいなコードを維持し続けることが重要」ということがあったので、それと会社紹介をつなげやすそうだったということもあります。(クリアコード→きれいなコード)
今回は、発表の内容で「動作するきれいなコードを維持し続けることが重要」だと思ってくれた人が「話している人の会社もきれいなコードを維持し続けるのか、よさそうな会社だな」と思ってくれるかどうかを試してみました。つまり、今回の「次のアクション」は「動作するきれいなコードを維持し続ける」人たちがいる会社としてクリアコードを認識してもらうということでした。
何人かには成功したようなので、次に話すときも試してみようと思っています。そして、次の「次のアクション」はもう少し行動を促すことにしようと思っています。
tDiaryをローカルなネットワークに配置して、tDiaryが表示する内容を静的なHTMLとして公開したい場合はよくありますよね。ククログもそんなよくある使い方の1つです。
tDiaryには静的なHTMLを生成するためのsqueezeプラグインがありますが、squeezeプラグインが出力するHTMLは以下の点でCGIで表示される内容と異なります。
ただし、これはsqueezeプラグインが検索エンジンへの入力データとしてのHTML生成を目的としているためです。よくある使い方では、生成されたHTMLはCGIで出力されているように表示できることが目的なので、上記のようなミスマッチが発生します。
そこで、ククログではhtml-archiver.rbという静的なHTMLを生成するスクリプトを使っています。html-archiver.rbは最後の方に載せています。
html-archiver.rbを使うと、CGIで出力されている内容と同じように表示されるHTMLが生成されます。生成例は今見ているこのページです。
使い方はこうなります。
% ruby html-archiver.rb --tdiary tdiayr.rbのあるディレクトリ --conf tdiary.confのあるディレクトリ 出力先ディレクトリ
例えば、以下のような場合を考えます。
この場合はこのようなコマンドになります。
% ruby html-archiver.rb --tdiary ~tdiary/work/ruby/tdiary/core/ --conf ~tdiary/public_html/ ~tdiary/public_html/html/
GPL3あるいは3以降の新しいバージョンのGPL
#!/usr/bin/env ruby # -*- coding: utf-8; ruby-indent-level: 3; tab-width: 3; indent-tabs-mode: t -*- require 'uri' require 'cgi' require 'fileutils' require 'pathname' require 'optparse' require 'ostruct' require 'enumerator' require 'rss' options = OpenStruct.new options.tdiary_path = "./" options.conf_dir = "./" opts = OptionParser.new do |opts| opts.banner += " OUTPUT_DIR" opts.on("-t", "--tdiary=TDIARY_DIRECTORY", "a directory that has tdiary.rb") do |path| options.tdiary_path = path end opts.on("-c", "--conf=TDIARY_CONF", "a path of tdiary.conf") do |conf| options.conf_dir = conf end end opts.parse! output_dir = ARGV.shift Dir.chdir(options.conf_dir) do $LOAD_PATH.unshift(File.expand_path(options.tdiary_path)) require "tdiary" end module HTMLArchiver class CGI < ::CGI def referer nil end private def env_table {"REQUEST_METHOD" => "GET", "QUERY_STRING" => ""} end end module Image def init_image_dir @image_dest_dir = @dest + "images" end end module Base include Image def initialize(rhtml, dest, conf) @ignore_parser_cache = true cgi = CGI.new setup_cgi(cgi, conf) @dest = dest init_image_dir super(cgi, rhtml, conf) end def eval_rhtml(*args) link_detect_re = /(<(?:a|link)\b.*?\bhref|<img\b.*?\bsrc)="(.*?)"/ super.gsub(link_detect_re) do |link_attribute| prefix = $1 link = $2 uri = URI(link) if uri.absolute? or link[0] == ?/ link_attribute else %Q[#{prefix}="#{relative_path}#{link}"] end end end def save return unless can_save? filename = output_filename if !filename.exist? or filename.mtime != last_modified filename.open('w') {|f| f.print(eval_rhtml)} filename.utime(last_modified, last_modified) end end protected def output_component_name dir = @dest + output_component_dir name = output_component_base FileUtils.mkdir_p(dir.to_s, :mode => 0755) filename = dir + "#{name}.html" [dir, name, filename] end def mode self.class.to_s.split(/::/).last.downcase end def cookie_name; ''; end def cookie_mail; ''; end def load_plugins result = super @plugin.instance_eval(<<-EOS, __FILE__, __LINE__ + 1) def anchor( s ) case s when /\\A(\\d+)#?([pct]\\d*)?\\z/ day = $1 anchor = $2 if /\\A(\\d{4})(\\d{2})(\\d{2})?\\z/ =~ day day = [$1, $2, $3].compact day = day.collect {|component| component.to_i.to_s} day = day.join("/") end if anchor then "\#{day}.html#\#{anchor}" else "\#{day}.html" end when /\\A(\\d{8})-\\d+\\z/ @conf['latest.path'][$1] else "" end end def category_anchor(category) href = "category/\#{u category}.html" if @category_icon[category] and !@conf.mobile_agent? %Q|<a href="\#{href}"><img class="category" src="\#{h @category_icon_url}\#{h @category_icon[category]}" alt="\#{h category}"></a>| else %Q|[<a href="\#{href}">\#{h category}</a>]| end end def navi_admin "" end @image_dir = #{@image_dest_dir.to_s.dump} @image_url = "#{@conf.base_url}#{@image_dest_dir.basename}" EOS result end private def setup_cgi(cgi, conf) end end class Day < TDiary::TDiaryDay include Base def initialize(diary, dest, conf) @target_date = diary.date @target_diaries = {@target_date.strftime("%Y%m%d") => diary} super("day.rhtml", dest, conf) end def can_save? not @diary.nil? end def output_filename dir, name, filename = output_component_name filename end def [](date) @target_diaries[date.strftime("%Y%m%d")] or super end def relative_path "../../" end private def output_component_dir Pathname(@target_date.strftime("%Y")) + @target_date.month.to_s end def output_component_base @target_date.day.to_s end def setup_cgi(cgi, conf) super cgi.params["date"] = [@target_date.strftime("%Y%m%d")] end end class Month < TDiary::TDiaryMonth include Base def initialize(date, dest, conf) @target_date = date super("month.rhtml", dest, conf) end def can_save? not @diary.nil? end def output_filename dir, name, filename = output_component_name filename end def relative_path "../" end private def output_component_dir @target_date.strftime("%Y") end def output_component_base @target_date.month.to_s end private def setup_cgi(cgi, conf) super cgi.params["date"] = [@target_date.strftime("%Y%m")] end end class Category < TDiary::TDiaryView include Base def initialize(category, diaries, dest, conf) @category = category diaries = diaries.reject {|date, diary| !diary.visible?} _, diary = diaries.sort_by {|date, diary| diary.last_modified}.last @target_date = diary.date super("latest.rhtml", dest, conf) @diaries = diaries @diary = diary end def can_save? not @diary.nil? end def output_filename category_dir = @dest + "category" category_dir.mkpath category_dir + "#{@category}.html" end def relative_path "../" end def latest(limit=5) @diaries.keys.sort.reverse_each do |date| diary = @diaries[date] yield(diary) end end protected def setup_cgi(cgi, conf) super cgi.params["date"] = [@target_date.strftime("%Y%m")] end end class Latest < TDiary::TDiaryLatest include Base def initialize(date, index, dest, conf) @target_date = date @index = index super("latest.rhtml", dest, conf) end def relative_path if @index.zero? "" else "../" end end def can_save? true end def output_filename if @index.zero? @dest + "index.html" else latest_dir = @dest + "latest" FileUtils.mkdir_p(latest_dir.to_s, :mode => 0755) latest_dir + "#{@index}.html" end end protected def setup_cgi(cgi, conf) super return if @index.zero? date = @target_date.strftime("%Y%m%d") + "-#{conf.latest_limit}" cgi.params["date"] = [date] end end class RSS < TDiary::TDiaryLatest include Base def initialize(dest, conf) super("latest.rhtml", dest, conf) end def mode "latest" end def relative_path "" end def can_save? true end def output_filename @dest + output_base_name end def output_base_name "index.rdf" end def do_eval_rhtml(prefix) load_plugins make_rss end private def make_rss base_uri = @conf['html_archiver.base_url'] || @conf.base_url rss_uri = base_uri + output_base_name @conf.options['apply_plugin'] = true feed = ::RSS::Maker.make("1.0") do |maker| setup_channel(maker.channel, rss_uri, base_uri) setup_image(maker.image, base_uri) @diaries.keys.sort.reverse[0, 15].each do |date| diary = @diaries[date] maker.items.new_item do |item| setup_item(item, diary, base_uri) end end end feed.to_s end def setup_channel(channel, rss_uri, base_uri) channel.about = rss_uri channel.link = base_uri channel.title = @conf.html_title channel.description = @conf.description channel.dc_creator = @conf.author_name channel.dc_rights = @conf.copyright end def setup_image(image, base_uri) return if @conf.banner.nil? return if @conf.banner.empty? if /^http/ =~ @conf.banner rdf_image = @conf.banner else rdf_image = base_uri + @conf.banner end maker.image.url = rdf_image maker.image.title = @conf.html_title maker.link = base_uri end def setup_item(item, diary, base_uri) section = nil diary.each_section do |_section| section = _section break if section end return if section.nil? item.link = base_uri + @plugin.anchor(diary.date.strftime("%Y%m%d")) item.dc_date = diary.last_modified @plugin.instance_variable_set("@makerss_in_feed", true) subtitle = section.subtitle_to_html body_enter = @plugin.send(:body_enter_proc, diary.date) body = @plugin.send(:apply_plugin, section.body_to_html) body_leave = @plugin.send(:body_leave_proc, diary.date) @plugin.instance_variable_set("@makerss_in_feed", false) subtitle = @plugin.send(:apply_plugin, subtitle, true).strip subtitle.sub!(/^(\[([^\]]+)\])+ */, '') description = @plugin.send(:remove_tag, body).strip subtitle = @conf.shorten(description, 20) if subtitle.empty? item.title = subtitle item.description = description item.content_encoded = body item.dc_creator = @conf.author_name section.categories.each do |category| item.dc_subjects.new_subject do |subject| subject.content = category end end end end class Main < TDiary::TDiaryBase include Image def initialize(cgi, dest, conf, src=nil) super(cgi, nil, conf) calendar @dest = dest @src = src || './' init_image_dir end def run @date = Time.now load_plugins copy_images all_days = archive_days archive_categories archive_latest(all_days) make_rss copy_theme end private def copy_images image_src_dir = @plugin.instance_variable_get("@image_dir") image_src_dir = Pathname(image_src_dir) unless image_src_dir.absolute? image_src_dir = Pathname(@src) + image_src_dir end @image_dest_dir.rmtree if @image_dest_dir.exist? if image_src_dir.exist? FileUtils.cp_r(image_src_dir.to_s, @image_dest_dir.to_s) end end def archive_days all_days = [] @years.keys.sort.each do |year| @years[year].sort.each do |month| month_time = Time.local(year.to_i, month.to_i) month = Month.new(month_time, @dest, conf) month.save month.send(:each_day) do |diary| all_days << diary.date Day.new(diary, @dest, conf).save end end end all_days end def archive_categories cache = @plugin.instance_variable_get("@category_cache") cache.categorize([], @years).each do |category, diaries| categorized_diaries = {} diaries.keys.each do |date| date_time = Time.local(*date.scan(/^(\d{4})(\d\d)(\d\d)$/)[0]) @io.transaction(date_time) do |diaries| categorized_diaries[date] = diaries[date] DIRTY_NONE end end Category.new(category, categorized_diaries, @dest, conf).save end end def archive_latest(all_days) conf["latest.path"] = {} latest_days = [] all_days.reverse.each_slice(conf.latest_limit) do |days| latest_days << days end latest_days.each_with_index do |days, i| date = days.first.strftime("%Y%m%d") if i.zero? latest_path = "./" else latest_path = "latest/#{i}.html" end conf["latest.path"][date] = latest_path end latest_days.each_with_index do |days, i| latest = Latest.new(days.first, i, @dest, conf) latest.save conf["ndays.prev"] = nil conf["ndays.next"] = nil end end def make_rss RSS.new(@dest, conf).save end def copy_theme theme_dir = @dest + "theme" theme_dir.rmtree if theme_dir.exist? theme_dir.mkpath tdiary_theme_dir = Pathname(File.join(TDiary::PATH, "theme")) FileUtils.cp((tdiary_theme_dir + "base.css").to_s, theme_dir.to_s) if @conf.theme FileUtils.cp_r((tdiary_theme_dir + @conf.theme).to_s, (theme_dir + @conf.theme).to_s) end end end end cgi = HTMLArchiver::CGI.new conf = TDiary::Config.new(cgi) conf.show_comment = true conf.hide_comment_form = true def conf.bot?; false; end output_dir ||= Pathname(conf.data_path) + "cache" + "html" output_dir = Pathname(output_dir).expand_path output_dir.mkpath HTMLArchiver::Main.new(cgi, output_dir, conf, options.conf_dir).run
Firefoxアドオン開発者向け自動テストツールのUxUは、新たに発見したバグの修正にも活用することができます。本日リリースされたXUL/Migemo バージョン0.11.7で行われた修正の場合を例に、実際のデバッグ作業の流れを解説します。
XUL/Migemoは、Firefoxで表示しているページ内のテキストを検索する機能を提供するアドオンですが、検索を開始する際に、「現在のスクロール位置から検索を開始する」という処理を含んでいます。0.11.6以前では、この機能を使用している時に、ページ先頭から検索が始まるべき場面で、先頭以外の場所から検索が始まってしまうことがあるという問題が起こっていました。
いくつか条件を変えて調査した結果、スクロールが発生しているページでは期待通りの結果になっているのに対して、スクロールが全く発生していないページ(ページ全体がウィンドウの現在の大きさの中に収まっているページ)では期待と異なる結果になっていることが判明しました。
前述の処理の肝となっているのは、pXMigemoFindクラスのfindFirstVisibleNode()というメソッドです。このメソッドは、渡されたフレーム(DOMWindow)において現在のスクロール位置で見えている最初の要素を検索して返す物です。このメソッドの戻り値を確認した所、前述の条件下では戻り値が期待と異なっている事が判明しました。
このことから、今回主な修正対象になるのはこのfindFirstVisibleNode()というメソッドであるということになります。
上記メソッドの実装を見直す前に、UxU用のテストケースを作成します。これにより、これから行う修正で目指すべきゴールが明確になります。つまり、このテストが成功する状況まで持って行くことが、今回の修正のゴールとなります。
テストケースはJavaScriptのコードだけで完結する場合もありますが、今回のような場合は実際のWebページを使ってテストを行う必要があります。そこで、問題が発生する条件と発生しない条件の両方の事例としてHTMLドキュメントを用意します。
これらのドキュメントを使い、shortPage.htmlではスクロールが発生せずlongPage.htmlではスクロールが発生する、という条件の下でテストを行うテストケースをこれから作成することになります。
ところで、現在の実装で問題が起こっている場合だけでなく、すでに正常に動いている場合の事例も同時に作成していることに気がついたでしょうか? 両方の場合を常にテストすることで、「ある問題を修正したら、今度は、今までは正常に動いていた物が動かなくなった」という状況、いわゆる後退バグを未然に防ぐことができます。 *1
それではテストケースを作成します。
utils.include('pXMigemoClasses.inc.js'); var findModule; function setUp() { yield utils.setUpTestWindow(); findModule = new pXMigemoFind(); findModule.target = utils.getTestWindow().gBrowser; } function tearDown() { findModule.destroy(); findModule = null; utils.tearDownTestWindow(); } function testFindFirstVisibleNode() { // ここに実際のテスト内容を記述する }
pXMigemoFindクラスの単体テストはまだ作成されていなかったので、今回はsetUpとtearDownから作成します。すでに作成済みのテストケースがあり、それにテスト項目を追加する場合、この作業は不要となります。
pXMigemoFindクラスはtabbrowser要素を用いて初期化する必要があるため、setUpでテスト用のFirefoxウィンドウを開き、そのウィンドウのtabbrowser要素で初期化します。また、tearDownではsetUpで開いたテスト用のFirefoxウィンドウを閉じて、pXMigemoFindクラスのインスタンスを破棄します。UxUは関数名を見て自動的にその種類を判別するため、これだけで、これらの関数はテストの初期化処理と終了処理として認識されるようになります。
なお、インクルードしているpXMigemoClasses.inc.jsというファイルは、pXMigemoFindクラスやそのクラスが依存しているすべての関連クラスの定義を読み込む物です。
次に、テストの内容を作成していきます。
function testFindFirstVisibleNode() { var win = utils.getTestWindow(); win.resizeTo(500, 500); assert.compare(200, '<', utils.contentWindow.innerHeight); // ここに実際のテスト内容を記述する }
関数名を「test」で始めると、その関数はテストの内容として自動的に認識されます。
最初に、ウィンドウの大きさを調整して、「shortPage.htmlではスクロールが発生せずlongPage.htmlではスクロールが発生する」という条件を整えておきます。ここでは、テスト自体が期待通りの条件下で実行されていることを確認するために、assert.compare()でテスト用フレームの大きさを調べています。
yield utils.addTab(baseURL+'../res/shortPage.html', { selected : true }); var frame = utils.contentWindow; var node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame); assert.equals(utils.contentDocument.documentElement, node); item = frame.document.getElementById('p3'); node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame); assert.equals(item, node);
テスト用のドキュメントを新しいタブで開き、findFirstVisibleNode()メソッドの返り値が期待通りかどうかを検証します。1つ目の検証は前方検索、2つ目は後方検索です。
同様にして、スクロールが発生する場合のテストも作成します。
yield utils.addTab(baseURL+'../res/longPage.html', { selected : true }); frame = utils.contentWindow; node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame); assert.equals(utils.contentDocument.documentElement, node); item = frame.document.getElementById('p10'); frame.scrollTo(0, item.offsetTop); node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame); assert.equals(item, node); frame.scrollTo(0, item.offsetTop - frame.innerHeight + item.offsetHeight); node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame); assert.equals(item, node); item = frame.document.getElementById('p21'); frame.scrollTo(0, item.offsetTop - frame.innerHeight + item.offsetHeight); node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame); assert.equals(item, node);
スクロールされていない時、ページ途中までスクロールされている時、ページの最後までスクロールされている時の各ケースで前方検索と後方検索を行い、結果を検証します。
ここで、かなりの部分のコードが重複していることに気がついたでしょうか。このような場合、それぞれの検証の前で重複しているコードと検証とをひとまとめにして実行する関数(カスタムアサーション)を定義しておくと、テスト項目の追加が簡単になります。以下は、カスタムアサーションを使ってここまでのテスト内容を書き直した物です。
function testFindFirstVisibleNode() { var win = utils.getTestWindow(); win.resizeTo(500, 500); assert.compare(200, '<', utils.contentWindow.innerHeight); function assertScrollAndFind(aIdOrNode, aFindFlag) { var frame = utils.contentWindow; var item = typeof aIdOrNode == 'string' ? frame.document.getElementById(aIdOrNode) : aIdOrNode ; frame.scrollTo( 0, (aFindFlag & findModule.FIND_BACK ? item.offsetTop - frame.innerHeight + item.offsetHeight : item.offsetTop ) ); var node = findModule.findFirstVisibleNode(aFindFlag, frame); assert.equals(item, node); } yield utils.addTab(baseURL+'../res/shortPage.html', { selected : true }); assertScrollAndFind(utils.contentDocument.documentElement, findModule.FIND_DEFAULT); assertScrollAndFind('p3', findModule.FIND_BACK); yield utils.addTab(baseURL+'../res/longPage.html', { selected : true }); assertScrollAndFind(utils.contentDocument.documentElement, findModule.FIND_DEFAULT); assertScrollAndFind('p10', findModule.FIND_DEFAULT); assertScrollAndFind('p10', findModule.FIND_BACK); assertScrollAndFind('p21', findModule.FIND_BACK); }
テストケースが完成したら、テストを実行してみましょう。実装の修正前なので、当然、このテストは「失敗」という結果が出ます。ですが、この段階では問題ありません。これから、このテストの結果が「成功」になることを目指して実装を修正していきます。
実装の修正内容については省略します。良いアイディアを思いついたら、それを実装に反映して、再度テストを実行してみましょう。テストに成功しないようであれば、まだ修正が必要です。
何度テストを実行しても結果が「成功」になるようになれば、実装の修正はひとまず完了です。修正内容をリポジトリにコミットするなり、修正済みの新しいバージョンとしてリリースするなりしましょう。
以上で、今回発見された問題の修正は完了しました。
しかし、上記のテストだけではテストしきれないような、より複雑な条件でだけ発生するバグが新たに見つかるかもしれません。そのような場合は、テストを新たに追加して、それらがすべて「成功」するようになるまで修正してやりましょう。その時はもちろん、他のテストも同時に実行することを忘れないようにしましょう。
また、開発を進める中で、他の部分に加えた変更の影響を受けて上記のテストが失敗するようになることがあるかもしれません。そのような場合、再びテストが通るようになるように実装を修正する必要があります。
実装の仕様を変更した時にも、ここで作成したテストケースは「成功」しなくなる場合があります。このような場合は「ゴール」自体が変わったということになりますので、実装ではなくテストケースの側を修正しなくてはなりません。
自動テストを使った開発では、メンテナンスする必要があるコードが「実装」と「テストケース」の2つになるため、一見すると、手間だけが倍増するように思えるかもしれません。
しかし、一連のテスト手順を自動化しておくことで、人の手によるテストでは見落としてしまいかねない思わぬ後退バグの発生に迅速に気づけるようになります。後退バグの発生に日々頭を悩ませている人は、是非、自動テストを開発に取り入れてみてください。
*1 もちろん、後退バグの発生自体は未然には防ぎきれません。しかし、後退バグの発生にすぐ気がつくことができれば、コミットやリリースの前にその後退バグを修正できるため、他の共同開発者やユーザには後退バグの影響を与えずに済むようになります。
みなさんはtDiaryの日記データをどのようにバックアップしているのでしょうか。cronでアーカイブしていたり、dbi_ioでデータベースに保存して、データベースの内容をアーカイブしていたりしているのでしょうか。
開発者の人なら日記のデータもソースコードと同じようにバックアップしたいですよね。つまり、バージョン管理をして、レポジトリの内容をアーカイブするバックアップです。そんな人はこのようなSubversionIOはいかがでしょうか。保存する毎にSubversionリポジトリに日記データをコミットします。
require 'tdiary/defaultio' module TDiary class SubversionIO < DefaultIO def transaction( date, &block ) dirty = TDiaryBase::DIRTY_NONE result = super( date ) do |diaries| dirty = block.call( diaries ) diaries = diaries.reject {|_, diary| /\A\s*\Z/ =~ diary.to_src} dirty end unless (dirty & TDiaryBase::DIRTY_DIARY).zero? run( "svn", "add", File.dirname( @dfile ) ) run( "svn", "add", @dfile ) Dir.chdir( @data_path ) do run( "svn", "ci", "-m", "update #{date.strftime('%Y-%m-%d')}" ) end end result end private def run( *command ) command = command.collect {|arg| escape_arg( arg )}.join(' ') result = `#{command} 2>&1` unless $?.success? raise "Failed to run #{command}: #{result}" end result end def escape_arg( arg ) "'#{arg.gsub( /'/, '\\\'' )}'" end end end # Local Variables: # ruby-indent-level: 3 # tab-width: 3 # indent-tabs-mode: t # End:
使い方は以下の2ステップです。
まず、上記のソースコードをsubversionio.rbとしてどこかに保存してください。ここでは/home/tdiary/lib/以下に保存したとします。
次にtdiary.confに以下の内容を追記します。
subversion_io_dir = "/home/tdiary/lib" # <- 保存した場所にあわせて変更 require "#{subversion_io_dir}/subversionio" @io_class = TDiary::SubversionIO
データディレクトリ(tdiary.conf内の@data_pathで指定したディレクトリ)は/home/tdiary/data/として進めます。また、作業しているユーザはtDiaryのCGIを動かすユーザとします。(ここではtdiaryユーザ)
まず、レポジトリに日記データ用のパスを作ります。既存のSubversionリポジトリを利用する場合は、例えばこのようになります。
[tdiary]% svn mkdir -m 'create tDiary data path' https://.../repos/tdiary-data
ローカルに新しくSubversionのリポジトリを作成して、そこに日記データをコミットするようにする場合はこのようになります。新しく作成するリポジトリは/home/tdiary/repos/に作ることにします。
[tdiary]% svnadmin create /home/tdiary/repos
Subversionリポジトリから日記データ保存用のパスを、tDiaryのデータディレクトリにチェックアウトします。今すでにあるtDiaryのデータディレクトリはどこかによけておきます。
[tdiary]% mv /home/tdiary/data /home/tdiary/data.bak [tdiary]% svn co file:///home/tdiary/repos /home/tdiary/data
既存のデータをワーキングコピーに移動し、日記データだけをレポジトリにコミットします。
[tdiary]% cd /home/tdiary/data [tdiary]% cp -rp ../data.bak/* ./ [tdiary]% svn add 200* [tdiary]% svn add 199* # <- もし2000年より前のデータがあるなら [tdiary]% svn ci -m 'import'
以上で設定は完了です。日記を保存するとリポジトリにコミットされます。
日記本文しか対応していません。ツッコミや画像には対応していません。
svnコマンドをインストールしている必要があります。
tDiary本体のコーディングスタイルに合わせているためタブインデントになっています。
AGPL3あるいは3以降の新しいバージョンのAGPL
Test::Unit 2.0.1が RubyForge上でリリースされました。RubyGemsも提供されているの で以下のようにインストールできます。
% sudo gem install test-unit
Test::UnitはRuby 1.8.xに標準添付されている単体テストフレーム ワークです。しかし、Ruby 1.9.1からは miniunitが標準 添付され、Test::UnitはRubyForgeで開発が継続されることになりま した。これからもTest::Unitを使うときはRubyGemsでインストール することになるでしょう。
Ruby 1.8.xに標準添付されているTest::Unitは互換性のために、 Test::Unit 1.2.3としてリリースされています。Ruby 1.9.1でも Ruby 1.8.xに標準添付されているTest::Unitと同じTest::Unitを使 用したい場合は以下のようにします。
% sudo gem install test-unit --version '= 1.2.3'
テストファイル内(変更前):
require 'test/unit' ...
テストファイル内(変更後):
require 'rubygems' gem 'test-unit', '= 1.2.3' require 'test/unit' ...
余談ですが、Ruby 1.9.1でTest::Unitが標準添付から外れ、 miniunitが標準添付になったのはTest::Unitのソースがメンテナン スしづらくなっていたのが主な理由です。
Ruby 1.8.xに標準添付されているTest::Unitは長い間メンテナンス はされていましたが、特に機能拡張などは行われていませんでした。 しかし、その間にもテスト環境を便利にするライブラリが公開され てきました。例えば、RSpecのような BDD用のフレームワークや、 expectations のような軽量の単体テストフレームワーク、 Shoulda/ test/spec/ Mochaのように Test::Unitを拡張するライブラリなどです。Test::Unitは少し時代 遅れになってしまったのです。
最近のテスト用のフレームワークは DSL化の方向に向かっているようにも見えます。 これはRSpecの影響が大きいのでしょう。expectationsやShouldaもテスト用の DSLを提供します。
しかし、Test::UnitはDSLを提供しません。テストを「英語らしく」 ではなく「Rubyプログラムらしく」書きます。好みにもよりますが、 これはTest::Unitのメリットの1つと言えます。
Test::Unitに他のフレームワークやライブラリの機能を、 Test::Unitの「Rubyプログラムらしく」テストを書ける特性を活か したまま追加すれば、Test::Unitはもっと便利で使いやすいテスト フレームワークになるでしょう。Test::Unit 2.x系列はRuby 1.8.x に標準添付されていた頃とは違い、そのような方針の元で活発に開 発されていく系列になります。
例えば、以下のような機能が他のフレームワークやライブラリから 移植されています。
ここでは「差分表示」と「ネストしたテスト定義」だけ紹介します。 *1
RSpecでは比較結果が異なった場合に差分を表示して違いをわかり やすく表示してくれます。
diff_spec.rb:
require 'rubygems' require 'spec' describe String do it do ["I", "am", "a", "boy"].join("\n").should == ["I", "was", "a", "boy"].join("\n") end end
実行結果(差分表示部分のみ):
% ruby diff_spec.rb -D ... Diff: @@ -1,5 +1,5 @@ I -was +am a boy ...
同様の機能がTest::Unit 2.0.1にもあります。
test_diff.rb:
require 'rubygems' gem 'test-unit' require 'test/unit' class TestDiff < Test::Unit::TestCase def test_diff assert_equal(["I", "am", "a", "boy"].join("\n"), ["I", "was", "a", "boy"].join("\n")) end end
実行結果(差分表示部分のみ):
% ruby test_diff.rb ... diff: I - am + was a boy ...
この例では、ほとんど同じ差分表示ですが、Test::Unit 2.0.1の 差分表示がRSpecの差分表示よりも便利なこともあります。
今度は"\n"ではなく" "でjoinして1行の文字列として比較します。
test_diff.rb:
require 'rubygems' gem 'test-unit' require 'test/unit' class TestDiff < Test::Unit::TestCase def test_diff assert_equal(["I", "am", "a", "boy"].join(" "), ["I", "was", "a", "boy"].join(" ")) end end
実行結果(差分表示部分のみ):
% ruby test_diff.rb ... diff: - I am a boy ? ^ + I was a boy ? + ^ ...
Test::Unit 2.0.1では必要なら同じ行のうち、どの列が異なってい るのかも表示します。RSpecでは行単位の差分までで列単位までの 差分は表示しません。
余談ですが、この差分表示形式はPython の difflib ライブラリで使われている形式です。
Shouldaではcontextをネストさせることにより、便利にテストを書 くことができます。以下はShouldaのページからの引用です。 *2
class UserTest < Test::Unit::TestCase context "A User instance" do setup do @user = User.find(:first) end should "return its full name" do assert_equal 'John Doe', @user.full_name end context "with a profile" do setup do @user.profile = Profile.find(:first) end should "return true when sent #has_profile?" do assert @user.has_profile? end end end end
ネストされた"with a profile"のcontext内では上位の"A User instance"のcontext内のsetupが実行された後に実行されます。つ まり、以下のような実行順序になります。
実行されるフィクスチャ(setup)がネストで自然に表現されていま す。
Test::Unit 2.0.1では以下のように書きます。
class UserTest < Test::Unit::TestCase def setup @user = User.find(:first) end def test_full_name assert_equal('John Doe', @user.full_name) end class ProfileTest < UserTest def setup super @user.profile = Profile.find(:first) end def test_profile assert_true(@user.has_profile?) end end end
これは以下のように実行されます。
実行されるフィクスチャ(setup)がネストとクラス階層で自然に表現 されています。
Test::UnitはRuby 1.9.1からは標準添付ではなくなりましたが、 Test::Unit 2.xとして活発に開発が続けられています。既存の他の フレームワークやライブラリのよいところは積極的に導入している ため、Ruby 1.8.xに標準添付されているTest::Unitよりもはるかに 使いやすくなっています。
今回は紹介しませんでしたが、他のフレームワークやライブラリに はないTest::Unit 2.x独自の便利な機能もあります。Test::Unitの 「Rubyプログラムらしい」テストの書き方が好きな場合はこれから もTest::Unitを使ってみてはいかがでしょうか。
Ruby-GetText-Packageだけというわけではないですが、app/controllers/application.rbで何かを行う*1gemを使っているとrake gems:installで足りないgemをインストールできません。経験したことがあるけど別に手動でインストールすればいいやということで、おそらく、わりとうやむやにされていることが多い問題ではないでしょうか。
例えば、Ruby-GetText-Packageだと以下のようにapp/controllers/application.rbを変更する必要があります。
class ApplicationController < ActionController::Base init_gettext "blog" end
Ruby-GetText-Packageのgemがない場合は「init_gettext」が定義されていないため「NameError」が発生します。そのため、rake gems:installをしようとすると以下のように失敗してしまいます。
% rake gems:install (in /tmp/blog) rake aborted! undefined method `init_gettext' for ApplicationController:Class (See full trace by running task with --trace)
これを回避するために「足りないgemがあるときはinit_gettextを使わない」という方法があります。 あまりきれいな方法ではありませんが、紹介します。
class ApplicationController < ActionController::Base if Rails.configuration.gems.reject {|gem| gem.loaded?}.empty? init_gettext "blog" end end
もし、config/environment.rbでconfig.active_record.observersを指定しているなどして、app/model/以下も読み込まれるのであれば、ダミーのN_を定義しておくとよいでしょう。
class ApplicationController < ActionController::Base if Rails.configuration.gems.reject {|gem| gem.loaded?}.empty? init_gettext "blog" else class ActiveRecord::Base def self.N_(*args); end end end end
これでRuby-GetText-Packageを使っているときでもrake gems:installが動くようになります。
% rake gems:install (in /tmp/blog) gem install gettext Bulk updating Gem source index for: http://gems.rubyforge.org/ Successfully installed gettext-1.93.0 1 gem installed Installing ri documentation for gettext-1.93.0... Installing RDoc documentation for gettext-1.93.0...
以下、「足りないgemがあるとき」の判断方法について少し書いてみます。
Railsでは「足りないgemがあるかどうか」を示すAPIを提供しているのはRails::Initializerです。ただ、残念ながらconfig/environment.rbの中で作ったRails::Initializerはどこにも保存されていないので、無理やり引っ張り出す必要があります。具体的には以下のようなコードになります。
initializer = nil ObjectSpace.each_object(Rails::Initializer) do |object| initializer = object break end initializer.gems_dependencies_loaded
しかし、この方法ではRails::InitializerがGCされてしまっていると動きません*2。
また、ObjectSpaceはできれば使いたくないものです。そのため、もう少し安全で、何をしているのかがまだわかりそうな方法の方がよさそうです。そのための「足りないgemがあるかどうか」を判断する方法が以下のようになるというわけです。
Rails.configuration.gems.reject {|gem| gem.loaded?}.empty?
ただ、この方法はRails 2.1.2(や2008-10-30でのmaster)では動きますが、もし、Rails内部の「足りないgemがあるかどうか」を判断する方法が変わった場合は動かなくなる可能性もあります。 「あまりきれいな方法ではありませんが」と書いたのはこのためです。
Ruby-GetText-Packageを使っている場合でもrake gem:installを利用する方法を紹介しました。
同じような問題は他のライブラリでも起こりうると思うので、そのような場合も同じように問題を回避できると思います。
GaUnit 0.1.6がリリースされました。 ([アナウンス]|[ダウンロード])
GaUnitは便利なSchemeインタプリタであるGauche用のxUnitベースの単体テストフレームワークです。0.1.6ではGauche標準のgauche.testモジュール(リンク切れしそうなリンク)用に書かれたテストを実行することができるようになりました。(簡単なものであれば)
gauche.testのテストを実行するために、テストスクリプトを読み込み、GaUnitが理解できるようにS式を変形します。変形されたS式はGaUnitが提供するgauche.test互換APIが使える無名モジュール内で評価され、GaUnitのテストとして認識されるようになります。
SchemeではマクロでS式を変換するということはよく行われます。もし、それで十分でない場合は上記のように、スクリプト自体をS式として読み込んで、変換して、評価、ということも行うことができます。これは、S式がプログラムで扱いやすいためにできると言えるでしょう。他の言語、例えばJavaScriptでは、プログラムを変換するためにtoSourceしたものをreplaceしてevalするということが行われることもあるようです。
ただ、非常に多くの場合はこのような方法は必要ありません。今回は(use gauche.test)をどうしてもうまく扱えなかったのでこの方式をとっています。
Gaucheスクリプトを書くときのちょっとした豆知識を紹介しておきます。
Gaucheスクリプトの内容をS式のリストとして読み込む処理は以下のようになります。ファイル先頭のcoding: utf-8などのような文字エンコーディング指定を考慮するためにopen-coding-aware-portをはさむことを忘れてはいけません。
(define (file->sexp-list file)
(call-with-input-file file
(lambda (input)
(port->sexp-list (open-coding-aware-port input)))))
GaUnit 0.1.6ではgauche.testのテストも実行できるようになったため、既存のgauche.testのテスト(大事な資産)を捨てることなくGaUnitに移行できるようになりました。これを機にGaUnitも試してみてはいかがでしょうか。
gauche.testであれGaUnitであれ、自動化されたテストがあるということは安心できるものです。
Ruby-GNOME2を使って実装されているプレゼンテーションツールRabbit 0.5.8がリリースされました。
0.5.8では部分的にClutterをサポートしています。
Clutterは高速で、視覚的にリッチで、アニメーションするGUIを作成するためのライブラリです。Clutterのこれらの特徴はOpenGLをバックエンドに使う事で実現されています。 ClutterはLinux/Mac OS X/Windowsなどマルチプラットフォームで動作します。さらに、組み込み環境でも動作し(OpenGL ESを利用)、デモ動画も公開されています。
ライブラリを使用する視点で見ると、GStreamer/cairo/Pango/GTK+などGNOME関連のライブラリと親和性が高いこともあり、便利で使いやすいAPIになっています。
ClutterにはRuby/Python/Perl/Valaなど各種言語用のバインディングがあります。リッチなインターフェイスを作成したい場合にClutterを利用してみてはどうでしょうか。
ここには書いていませんでしたが、Cutter 1.0.3のリリース のリリースの約1ヶ月後に1.0.4がリリースされました。 さらにその約2ヶ月後の昨日、1.0.5がリリースされました。 CutterとはC言語用の単体テス トフレームワークです。
詳細は NEWSに 書いてありますが、1.0.5での目玉新機能は以下の3点です。
gdk-pixbufをサポートすることにより、画像が等しいかどうかを検 証できるようになりました。また、もし、画像が異なる場合は画像 間の差分を示し、画像のどこが異なるのかをわかりやすくしていま す。
例えば、このような画像を比較したとします。
これらの画像では赤い丸の部分が異なっています。
これらの画像の差分画像は以下のようになります。
左上に期待画像、右上に実際の画像、左下に異なるピクセルを示し た画像、右下に異なるピクセルを強調表示、同じピクセルを弱めに 表示した画像を配置しています。左下の画像を見ることでどの部分 が異なるのかが具体的にわかり、右下の画像を見ることで比較画像 はどのあたりが異なるのかを相対的に確認する事ができます。
もっとよい表現方法があるかもしれませんが、しばらくはこの方法 を採用しする予定です。もしかすると、今後、よりよい表現方法に 変更されるかもしれません。
ちなみに、この機能はPDF操作・レンダリングライブラリである Popplerのテスト で利用されていま す。
Cutterを使用していて、こんな検証・便利関数があったら便利だ、 と感じたものは積極的にCutter本体に取り込んでいます。
1.0.5では12個の検証、9個の便利関数が追加されました。追加され た検証・便利関数のリストは NEWSに 書かれています。
今までもユーザが独自で検証を定義することはできたのですが、バッ クトレースを取得するためにマクロとして定義する必要がありまし た。
1.0.5では cut_trace() というマクロが追加され、検証を関数として定義してもバックトレー スを取得することができるようになりました。マクロは可変長引数 が簡単に書けるなど便利な事も多いのですが、構文エラーが見つけ にくいなどという問題もあります。1.0.5からは関数とマクロを使い 分けられるようになり、より便利にデバッグのしやすいテストが書 けるようになりました。
Cutter 1.0.5ではテスト作成・デバッグ支援の機能が強化され、よ り便利なテスト環境を提供するようになりました。今までよりもC 言語でのテスト作成が楽しくなるかもしれません。
[チュートリアル ] [リファレンスマニュアル ] [ダウンロード ]