今川館

都内勤務の地味OLです

AWS RDS Aurora MySQL で膨大な件数をSUMしてSELECT INSERTするときのプラクティス

データコンバートは楽しい

この度データコンバートの仕事が来たので、そのときに行ったチューニングのことをここにメモしておく。
データコンバートの仕事は何度か経験があるが、チューニングに結構頭を使う仕事なので好きな方だ。
さて、前置きはほどほどにして本題に入るとしよう。

今回のお仕事の概要

  • 8億件入っているテーブルをSUMして、同じ定義の別のテーブルにSELECT INSERTする(6億件程度)
    • 集約関数を伴うので、クエリのソート処理は避けられない
    • 移行先のテーブルには1つの複合UNIQUEインデックスと、7つの単一インデックスの合計8つのインデックス作成が必要
  • データベースはAWS RDS AuroraのMySQLエンジン

少し考えれえば何もチューニングせずに現実的な時間内にコンバートを終えることは難しいことがわかる。
今回はSELECT文のクエリのチューニングは割愛して、それ以外の部分で行った事項を説明したい。

ソート処理の高速化が必要

コンバートの方法がSUMした結果を別のテーブルに投入することなので、集約関数に伴うソート処理を避けることはできない。
よくあるSELECT文のチューニングとしては無駄なソートを避けろと言われることが多いが、今回はそれは無理なので、ソートする前提でなるべく高速に処理する方針でチューニングが必要。

そうすると、まず検討すべきはMySQLのパラメータをチューニング。

ORDER BY や GROUP BYするクエリのパラメータチューニング

MySQLでチューニングするのは何と7年ぶりのことで、そもそもこういうときにどのパラメータを変えれば効果的なのか調べるところからスタートだった。
調べていくと、どうやらsort_buffer_sizeを変えると効果がありそうだ。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 5.1.4 サーバーシステム変数

ソートを実行する必要がある各セッションは、このサイズのバッファーを割り当てます。

SHOW GLOBAL STATUS の出力に表示される秒あたりの Sort_merge_passes の数が多い場合、sort_buffer_size 値を増やすことで、クエリー最適化またはインデックスの改善によって改善できない ORDER BY または GROUP BY 操作を高速化することを検討できます。

現在の設定値はいくらなのか?と思い、show variables like '%sort_buf%' と叩いてみると何と256 KiB。これは少ない。
ここに設定されたサイズを超えるソートが必要になった場合、ディスクIOに回されるのでソート対象のデータが大きくなればなるほど遅くなる。
思い切って6 GiBに拡大した。

大量のレコードをSELECT INSERTするときはインデックスを後で作る方がトータルで速い

何しろ今回は8億件のデータを6億件にSUMして別のテーブルに挿入しなければならないので、挿入のコストもできるだけ減らす必要がある。
カットしやすいのはインデックスの作成コストなので、まず移行先テーブルはCREATE TABLEだけ行い後でインデックスを作成することにした。

そもそも、インデックスのお約束として

  • SELECTの頻度がINSERT/UPDATE/DELETEよりも相対的に多い場合に作っておくと有利。
  • 逆の場合は不利。

というルールがある。
このため、新規テーブルを作って膨大な件数を挿入する場合はインデックス無しで行い、後でインデックスを作った方がトータルの所要時間は短くなる。
合計8つもインデックスがあるので挿入時のインデックス更新コストは馬鹿にならないだろう。

実際に試すと、

説明 所要時間
CREATE TABLE時に8つのインデックスも作って70万件のSELECT INSERTした場合 17分10秒
CREATE TABLEだけ行い、インデックスなしでSELECT INSERTした場合 9分5秒
インデックスなし && sort_buffer_size=6 GiBに拡大して70万件のSELECT INSERTした場合 5分30秒

(Auroraのサイズ=db.r3.xlarge)

良いじゃないか、3分の1以下まで処理速度が向上した。

net_write_timeoutも延ばしておいた

MySQL :: MySQL 5.6 リファレンスマニュアル :: 5.1.4 サーバーシステム変数

書き込みを中止する前にブロックが接続に書き込まれるのを待機する秒数

SELECT INSERTの途中でタイムアウトされたくないので、これも600->3600(秒)にのばしておいた。

RDSのパラメータの変更が反映されるタイミングはいつなのか?

RDSでMySQLのパラメータを変えた場合、反映されるのはどのタイミングなのか?要は再起動が必要か否かということだが、これはちゃんとドキュメントに書いてあった。

DB パラメータグループを使用する - Amazon Relational Database Service

動的なパラメータを変更し、DB パラメータグループを保存する場合、[Apply Immediately] の設定にかかわらず、変更は直ちに適用されます。静的パラメータを変更し、DB パラメータグループを保存する場合、パラメータの変更は DB インスタンスを手動で再起動した後に有効になります。

sort_buffer_sizeもnet_write_timeoutもdynamicなので、即座に適用され、再起動は不要。

気をつけよう!あなたが老害化しているかどうか確かめる基準

こんばんは。皆さんは老害が嫌いですか?
今日は老害になりたくない人のために、わたしが見つけた老害化しているかどうか確かめる基準をご紹介します。

相手に言葉合わせをさせる

それは、「相手に言葉合わせをさせていたら危ない」という基準です。
誤解しないで欲しいのですが、相手が言った言葉が自分の中でしっくりこないときに、自分から語彙を提供して言葉を合わせに行くうちは老害ではありません。
やばいのは、言葉が違うと言うのに、自分から合わせに行かないで、相手に合わせさせてしまう人です。
これは老害になってしまっている可能性が高いです。

老害は基本的に何もしないでふんぞり返っています。だから嫌がられるんです。
相手が言った言葉が気に入らないと言うだけで、自分からは認識合わせを試みず、一方的に相手が悪いかのような態度を取ります。

例えば、相手がシステムの開発環境の「構築」が漏れている件を相談してきて、違和感を感じたとしましょう。
老害はこういうときに「ん?」とか言って憮然とした顔で黙るんです。
そういう態度を取られて困った相手が「設定」とか「整備」とか他の言葉を出しても、依然として不満そうな態度と、ちょっと相手を馬鹿にした態度を取ってしまったら自分はやばいと思った方が良いです。

しかも、自分は博識だが寛容な人間でもあることを示したくなって、「べつに言葉なんてどうでもいいんだけどね、でも『構築』って、なんかしっくりこないんだよな」とか言っちゃったら、相当焦った方が良いでしょう。

相手は言葉合わせがしたいわけじゃありません。
今現在、何か困っていたり、大事なことを見落としていたことに気づいて焦っているのですから、本当は助けてあげるのが正解でしょう。
自分と相手が話す言葉が合わないのは普通のことです。片方が悪いわけじゃありません。

老害化は能力の陳腐化に先行する

では、なぜ老害はこのような行動に走ってしまうのでしょうか?
難しいのが、「無能は老害になれない」という法則があって、老害に変貌しつつある人はたくさんの知識や経験を持っているのです。また、老害は他人から見た属性なので、そういう人に一人で働かせると実は仕事がよくできてしまったりします。

ここでまずわかることは、老害化はその人の能力の陳腐化よりも先行するということです。

成功体験を重ねるにつれ、思考が固定化してしまう

そして、老害は「間違っているのは相手」と決めつけたり、「正しいのは自分」ということに固執します。
これもその人が豊富な知識や経験に基づく成功体験を重ねてきたという事実に目を向ければ想像に難くないことです。

恐ろしいのは、失敗は人の行動を変える契機になりやすいが、成功は逆に人の行動を固定化する要因になりやすい点です。
つまり、成功体験を重ねるにつれ、過去の成功体験への自信を強め過ぎると、転じて自分中心のものの見方に陥りやすくなってしまうんです。
そうして他人や外部の環境に目を向けることが億劫になって、周りを自分に合わせさせようと考え始めてしまいます。
そのとき、人はまさに老害になっているわけです!

以上、わたしが考えた老害化の基準をご紹介しました。

おまけ: 阿Qの「精神勝利法」のケース

この文章の前段の「老害化の基準」の部分になるほどと感じても、後段の「なぜ老害化するのか?」という部分には納得しない人がいらっしゃるかもしれません。

実は、わたし自身も文章を読み返して「あれ?なんか違うな」と思いつつも、「でもこれはこれで正しそうだからちゃんと書いておこう」と思いました。

そういう違和感のヒントが、魯迅の『阿Q正伝』に出てくる「精神勝利法」という心理ではないかと思いました。
細かいことははしょりますが、実際にはまったく成功していないにもかかわらず、精神勝利法によって架空の成功体験を重ねることで老害化するケースもあるのではないかという示唆です。

今度こそ以上、終わりです。ではまた!

現代アート 『KATUHIKO DESUYO』

f:id:imagawa_yakata:20180304094555p:plain
見切れててよくわかりません

f:id:imagawa_yakata:20180304094605p:plain
真っ白な徳利の口がカツヒコの鼻にかかっている様が富士の日の出に匹敵する景観です

f:id:imagawa_yakata:20180304094619p:plain
ほおをつまんで難しい顔をしている 遠藤周作でないことは脇にある徳利から明らかだ

f:id:imagawa_yakata:20180304094628p:plain
小指を立ててビールを飲む それがまちやスタイル

あなたは今週何をしましたか?わたしは不確実性と闘ってました

一年前は、自分が必要だった

一年前に転職してきたときは、自分が必要とされている実感があった。
あの頃は積み上がったPython3とGoのコードベースをハンマーで叩き割り、火炎放射を放ち、焼畑に種を蒔き、苗を育てる段階だった。
ジョブキューに詰まるバッチ処理を押し流したり、APIが掴んだメモリを解放したり、ワーカーが一向に手放さないファイルディスクリプタを引き剥がしたり。
とにかく日々一歩でも前に進むことが必要だった。馬力があれば誰でも良かったが、わたしはその場にいて、懸命に働いていた。

今、自分は使えないおじさんになってしまった

それから状況は良くなって、気づいたら自分はPythonが少し読めるだけの役立たずになってしまった。
データ分析ができるわけでもない、低レベルプログラミングが上手いわけでもない、インフラの知識があるわけでもない。
会社にはどんどん若くて優秀な人が入ってくる。その人たちから見たら、どうしてあの人がここにいるのだろうと思われても不思議ではない。

潮が引いた時に、初めて誰が裸で泳いでいたかわかる。

そういうことだ。
今まで自分はそこそこできると思っていたが、それは妄想に過ぎなかったのだ。

チームメンバーは優秀なのに、どうして成功体験の方からこっちに来ないのか?

ところがこの数ヶ月、わたしのいるチームはどうしてもあと一歩ゴールへたどり着けない状況を繰り返していた。
機械学習で作ったモデルをサーバーに載せて配信すれば、今までよりもずっと良いパフォーマンスが得られるはずなのに、ABテストをしてもなぜか勝てない。
これから開発予定の新規機能も、ミーティングを開けば開くほど不明点が増え、一向にトンネルの向こうが見えてこない。

自分にはないスキルを持った人たちがこれだけ集まって懸命に働いているのだから、成功体験が向こうからこっちへ訪れて然るべきなのに、いっこうにドアをノックする音がしない。

スクラムというやつが効くらしい

ところで、転職したとき職場では既にスクラムを何年も実践していた。
会社のそれまでの道のりは必ずしも平坦ではなくて、チームに亀裂が入ったり、組織に不安がよぎった時期もあったそうだ。
そんな中、スクラムを導入することでチームビルディングに成功し、今に至るらしい。
一昨年、二次面接の後にオフィスの中を見せてもらい、壁やホワイトボードに整然と並んだ色とりどりの付箋紙を見たとき、わたしはこんな職場で働ける人が羨ましいと思った。

ところが、今ではホワイトボードに構築されたカンバンを見ても他人が昨日何をして、今日は何に取り組み、どんなことに困っているのかわからない。
スプリントの振り返りの場でKPTをやっても、同じ失敗を繰り返す状態から抜け出せない感じがする。
その原因として、チームメンバー同士の協力がうまく噛み合っていないような気がしてならない。なぜなのか。

現状を把握し、活動を改善する必要に迫られる

先月は、イベントや研修に行って、スクラムのことを学ぶ機会が多かった。
そこで学んだのは、スクラムは現状を把握するためのフレームワークであり、スクラムマスターの責務とは活動を改善すること、ということだ。
そう、わたしの漠然とした不安の正体は、活動が停滞していることだったのだ。
そして、今の我々には現状を把握することが必要だったのだ。

具体的には、とにかく話し合うことが必要だと思った。
でも、会議室にチームメンバーを集めて、現状を変えなければならないと熱く語ったところで、何も状況は好転しないと思った。
それはまるで、能力の劣ったおじさんが、たまたまスクラムイベントへ行って感化されて、孤独に熱く語っているだけのおバカさんではなかろうか?

だから身近なことを話し合おうと思った。
例えば、朝会をやる時間とか、ホワイトボードの使い方とか、そんなことが良い。
わたし自身はそれらが今はあまり機能していないと思うが、他の人はどう思っているのか?そんな簡単な話題で構わない。
他人が仕事に対して何を考えているのか知らないから不安になったり、協力できないだけじゃないか。

現状を変えることが必要ではなくて、必要なのはあくまでも現状を「把握すること」なのではないか。
そして、現状を変えるのは自分ではなく、チームではないか。

あなたは今週何をしましたか?わたしは不確実性と闘ってました

実際に話し合ってみると色々な意見が出てきて、残り時間の管理に細かく気を使うくらいだった。
やる前は全然喋らない人が発生するんじゃないかと心配だったが、結果はまったく逆だった。
普段よく発言する人の方が控えめで、普段あまり話さない人の方が饒舌なことに驚いた。

それから一週間、朝会をやめて夕会に変えたり、ホワイトボードと付箋を使うことをやめてGithub projectを使ってみたり、午後に全員でディスプレイの前に集まってデータ分析の結果を元に次のトライを話し合ったりと、毎日少しずつ活動に変化が現れ始めた。新規機能の見積もりもやり直した。

今週は人と話すことが本当に多かった。
話し合いをするときに認識や意見が合わないことは非常に多く、長時間話し合っても結局それらが解消しないことも多い。
しかし、話し合いの過程で人は必ず「何か」を学習している。そして、それは次につながっていく手がかりに変わる。

優秀な人材を募って職場にチームを結成しても、バラバラに働いているとひとりの認知の限界で行き詰まったときに抜け出せない。
単純にスキルセットの違う人材を集めても、違う視点や経験がひとつの方向へ向かわなければ、状況は好転しない。
そんなことを感じた。

テストで誤差を許容するアサーション assert.InEpsilon と assert.InDelta

追記: InDeltaというもっと簡単に使えるメソッドがあった

assert.InDelta(t, 37500, 38000, 500.0) // ok
assert.InDelta(t, 37500, 38001, 500.0) // Max difference between 37500 and 38001 allowed is 500, but difference was -501

Goのassertモジュール

ユニットテストを書くときにassertモジュールを使っていて、テスト対象物実行した結果が100であることを確かめる場合は

assert.Equal(t, 100, result)

と書けばよいけど、「結果は99.8〜100.2の間で、だいたい100になる」みたいなチェックをするときは当然ながらEqualは使えない。

assert.InEpsilonというメソッドがある

そういうときはassertにはInEpsilonというメソッドが使えるらしい。

https://godoc.org/github.com/stretchr/testify/assert#InEpsilon

InEpsilon asserts that expected and actual have a relative error less than epsilon

Returns whether the assertion was successful (true) or not (false).

float64の引数epsilonに許容誤差を指定して使う。

やってみた

戻り値のxとyが3:1でだいたい500を限界にずれる関数を作る。

package epsilon

import "math/rand"

func Foo() (x, y int) {
	d := rand.Intn(500)
	return 37500 - d, 12500 + d
}

これをInEpsilon使ってテスト。

package epsilon

import (
	"math/rand"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
)

func init() {
	rand.Seed(time.Now().UnixNano())
}

func TestEpsilon(t *testing.T) {
	for i := 0; i < 10000; i++ {
		x, y := Foo()
		assert.InEpsilon(t, 37500, x, 0.014)
		assert.InEpsilon(t, 12500, y, 0.041)
	}
}
許容した誤差を超えてしまった場合
assert.InEpsilon(t, 37500, x, 0.01)

などと誤差を狭めるとこういうメッセージが出てテストが失敗します。

        Error Trace:    epsilon_test.go:18
	Error:      	Relative error is too high: 0.01 (expected)
	            	        < 0.01176 (actual)

Goのimageモジュールを使って絵を描く

この記事のソースコードこちら

imageモジュールを使った描画の記事いろいろ

今月のGoの勉強の締めくくりに、画像ライブラリを使って絵を描いてみたくなった。
とはいえ、わたしは今までPythonのPILを使って画像のペーストやリサイズ程度のことしか経験がない。
一から絵を描く場合、どうしたら良いかまったく知見が無いので、他の人の記事を参考にやり方を調べた。

Go 言語でソースコードから画像生成する - てっく煮ブログ

「プログラムでシダを描画する」をGoで描画する - Qiita

これらの記事がどういうロジックで正確な画像を作っているのか恥ずかしながらよく分からなかったのだが、とりあえずimage.Rectで四角いパレットを作って、その中の座標にひたすらcolor.RGBAの色成分を埋めていけば絵が描けそうなことは分かった。

imageのRGBAとcolorのRGBA

Goのライブラリにはimage.RGBAとimage/color.RGBAの同じ名前の2種類の構造体が存在する。
color.RGBAの方が赤・緑・青の三原色を表す色成分を、image.RGBAの方がRGBAで表すインメモリの画像オブジェクトに該当するらしい。

imageのRGBAはimage.NewRGBA()で作れる。

円を描く

円を描くためには

if ある座標が円の中に入っていれば {
	円の色を指定
} else {
	背景色を指定
}

こうすれば良いことまで理解した。

問題は、「円の中に座標が入っている」ことを確かめる方法なのだが、以下記事にやり方が書いてあったので拝借。

任意の点(x, y)が円内に含まれているかどうか

冒頭の「てっく煮ブログ」さんのサンプルコードも参考に円を表す構造体のメソッドを定義した。(以下)

type Circle struct {
	X, Y, R float64
}

func (c *Circle) Inside(x, y int) bool {
	// sqrt((x-a)^2 + (y-b)^2) は二点(x, y), (a, b)間の距離
	// これが(x, y)を中心とする円の半径r以内の長さであれば円の内側

	xx, yy := c.X-float64(x), c.Y-float64(y)
	return math.Sqrt(xx*xx+yy*yy) <= c.R
}

サンプルコード

f:id:imagawa_yakata:20161221184327p:plain

以下の通り、DrawingHandlerを定義して、/newyearというPathに紐付けたので、localhost:8080/newyearを問い合わせると上記の画像が返却される。

[lib/drawing/main.go]

package drawing

import (
	"image"
	"image/color"
	"image/png"
	"math"
	"net/http"
)

type Circle struct {
	X, Y, R float64
}

func (c *Circle) Inside(x, y int) bool {
	// sqrt((x-a)^2 + (y-b)^2) は二点(x, y), (a, b)間の距離
	// これが(x, y)を中心とする円の半径r以内の長さであれば円の内側

	xx, yy := c.X-float64(x), c.Y-float64(y)
	return math.Sqrt(xx*xx+yy*yy) <= c.R
}

func NewImage() *image.RGBA {
	var w, h int = 280, 240
	m := image.NewRGBA(image.Rect(0, 0, w, h))

	large := Circle{140, 240, 80}
	middle := Circle{140, 180, 50}
	small := Circle{140, 120, 20}

	white := color.RGBA{
		255,
		255,
		255,
		255,
	}
	another := color.RGBA{
		170,
		179,
		0,
		255,
	}
	orange := color.RGBA{
		255,
		102,
		0,
		255,
	}

	for x := 0; x < w; x++ {
		for y := 0; y < h; y++ {
			var c color.RGBA
			if small.Inside(x, y) {
				// ちっちゃい円の中にある座標はオレンジ色(橙の絵)
				c = orange
			} else if large.Inside(x, y) || middle.Inside(x, y) {
				// 大きい円、または中くらいの円の中にある座標は白(お餅の絵)
				c = white
			} else {
				// それ以外は背景とする
				c = another
			}
			m.Set(x, y, c)
		}
	}
	return m
}

func DrawingHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "image/png")
	img := NewImage()
	png.Encode(w, img)
}

[main.go]

// import "github.com/oyakata/kanjo/lib/drawing"

func init() {
	// 一部抜粋
	http.HandleFunc("/newyear", drawing.DrawingHandler)

}

鏡餅の絵に見えると思うんだけど、どうかなぁ・・

良いお年を。

はじめてのwebアプリ⑤ ユニットテストを追加

この記事のソースコードこちら

ユニットテストを追加する頃合いに

文字列とファイルに対応した文字数カウント機能を作ったところで、これらのユニットテストを作る頃合いになってきた。
また、リクエストを受け取ってレスポンスに書き込むハンドラ関数のテストも欲しいところだ。

そこで、ユニットテストを追加する工程をここに記していきたい。

まず、今までmain.goに全ての処理を記述していたプログラムを、以下の構成に変更した。

kanjo
├── lib
│   └── wc
│       ├── main.go
│       └── main_test.go
├── main.go
└── main_test.go

lib/wcの配下に文字数カウントモジュールを移動し、lib/wc/main_test.goで文字数カウントのテストを、main_test.goでハンドラ関数のテストを書いていく。

文字数カウントのプログラム

アプリケーションコード

まずlib/wcに移動したプログラムは以下、

[lib/wc/main.go]

func WordCountInString(text string) (count, byte_count, invalid int) {
	b := []byte(text)

	for len(b) > 0 {
		r, size := utf8.DecodeRune(b)
		if r == utf8.RuneError {
			invalid += size
		} else {
			count++
			byte_count += size
		}
		b = b[size:]
	}
	return
}

// ファイルを読み取って文字数、バイト数、不正バイト数を数えて返す。
func WordCountInFile(rd io.Reader) (count, byte_count, invalid int) {
	in := bufio.NewReader(rd)

	for {
		r, size, err := in.ReadRune()
		if err == io.EOF {
			break
		}
		if r == utf8.RuneError {
			invalid += size
		} else {
			byte_count += size
			// 正常な文字だけカウントする。
			count++
		}
	}
	return
}

関数をふたつ定義し、それぞれ文字列とファイルを引数に取る。
(カウントの処理を更に共通化したらどうか?と思う人もいるだろうが、今回は見送り)

テストコード

テストコードは以下のスライドの記述を参考にTable Driven Testを心がけて書いてみた。

テストしやすいGoコードのデザイン

[lib/wc/main_test.go]

package wc

// テストを実行するとき
// $ go test github.com/oyakata/kanjo/lib/wc/

import (
	"strings"
	"fmt"
	"testing"
)

type T struct {
	Input                     string
	Count, ByteCount, Invalid int
}

var valid, broken, exp string

func init() {
	valid = string([]byte{
		240, 169, 184, 189, // 𩸽
		227, 129, 174, // の
		227, 129, 178, // ひ
		227, 130, 137, // ら
		227, 129, 141, // き
		240, 159, 153, 134, // face with ok gesture
		240, 159, 153, 134, // face with ok gesture
		240, 159, 153, 134, // face with ok gesture
	})

	broken = string([]byte{
		169, 184, 189, // [NG] 先頭欠損
		227, 129, 174, // の
		129, 178, // [NG] 先頭欠損
		227, 137, // [NG] 2桁目欠損
		227, 129, // [NG] 3桁目欠損
		240, 153, 134, // [NG] 2桁目欠損
		240, 159, 134, // [NG] 3桁目欠損
		240, 159, 153, // [NG] 末尾欠損
	})

	part := "𩸽のひらきを居酒屋で注文して、1時間経つがまだ来ない。𠈻な客が店員を引き止めてなじるからだ。"
	// 46 * 3 + 7 + 2 = 147文字
	// 1行: (4byte * 2文字) + (3byte * 43文字) + (1byte * 1文字) = 138byte
	// 全体: 140 * 3 + 7 + 6 = 427byte
	exp = fmt.Sprintf("%v\n\n%v\n\n\n%v\n\n以上", part, part, part)
}

func TestWordCountInString(t *testing.T) {
	cases := []T{
		{valid, 8, 28, 0},
		{broken, 1, 3, 18},
		{"", 0, 0, 0},
		{exp, 147, 427, 0},
	}

	for _, tc := range cases {
		x, y, z := WordCountInString(tc.Input)
		if x != tc.Count || y != tc.ByteCount || z != tc.Invalid {
			t.Errorf("WordCountInString=%v% v% v, want=%v% v% v",
				x, y, z,
				tc.Count, tc.ByteCount, tc.Invalid,
			)
		}
	}
}

func TestWordCountInFile(t *testing.T) {
	cases := []T{
		{valid, 8, 28, 0},
		{broken, 1, 3, 18},
		{"", 0, 0, 0},
		{exp, 147, 427, 0},
	}

	for _, tc := range cases {
		x, y, z := WordCountInFile(strings.NewReader(tc.Input))
		if x != tc.Count || y != tc.ByteCount || z != tc.Invalid {
			t.Errorf("WordCountInString=%v% v% v, want=%v% v% v",
				x, y, z,
				tc.Count, tc.ByteCount, tc.Invalid,
			)
		}
	}
}

引数が文字列とファイルで違うけれども、基本的に確かめたいテストデータの内容は同じなのでinit()に共通化してしまったのだが個人的にはどうもしっくりこない。共通化したつもりがかえって読みづらくなった感がある。が、経験不足ということでとりあえずこれで良しとした。(テストもOKを確認)

ハンドラ関数のプログラム

アプリケーションコード

とりあえず、JSONで結果を返すハンドラ関数のテストだけ書いてみた。

まず、ハンドラ関数のコードは以下。

[main.go]

package main

// import "encoding/json"
// import "github.com/oyakata/kanjo/lib/wc"
// import "net/http"

// json.Marshalは構造体の公開フィールドしか出力してくれないので注意。
// 小文字でJSONのキーを出力したければタグを指定する。
type WordCount struct {
	Text      string `json:"text"`
	Count     int    `json:"count"`
	ByteCount int    `json:"byte_count"`
}

func JSONWordCountHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")

	text := r.FormValue("text")
	count, bc, _ := wc.WordCountInString(text)

	result, err := json.Marshal(WordCount{text, count, bc})
	if err != nil {
		log.Panic(err)
	}
	w.Write(result)
}

なお、テストコードの方でUnmarshalしたいのでWordCountの構造体をトップレベルに移動した。

テストコード

問題は、ハンドラ関数をテストするときに引数に渡すhttp.ResponseWriterとhttp.Requestの作り方なのだが…
まずコードを示すと以下の通り。

[main_test.go]

package main

import (
	"fmt"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestJSONWOrdCountHandler(t *testing.T) {
	v := "今川義元1560年桶狭間"
	location := fmt.Sprintf("http://example.com/count?format=json&text=%v", v)

	w := httptest.NewRecorder()
	// httptestにはなぜかNewRequestはないと言われる(go version 1.6.2)
	// undefined: httptest.NewRequest
	r, _ := http.NewRequest("GET", location, nil)

	JSONWordCountHandler(w, r)

	if w.Code != http.StatusOK {
		t.Errorf("JSONWordCountHandler=%v, got=%v", http.StatusOK, w.Code)
	}
	result := &WordCount{}
	json.Unmarshal([]byte(w.Body.String()), result)
	expected := WordCount{v, 12, 28}
	if *result != expected {
		t.Errorf("JSONWordCountHandler=%v, got=%v", expected, result)
	}
}
net/http/httptestという便利モジュールが存在する

ハンドラ関数の第1引数、http.ResponseWriterのオブジェクトをどうやって作ったら良いか最初わからず調べるのに結構苦労した。
何と、net/http/httptestというテスト向けのモジュールがちゃんと用意されていた。
httptest.NewRecorder()がResponseWriterインターフェースを実装したオブジェクトを返してくれるのでこれを使う。

リクエストオブジェクトを作るときはnet/http.NewRequst

Goのドキュメントを読むと、httptestにNewRequestが定義されていると書いてあるのだが、自分の手元で試すとundefinedと言われるのでnet/httpのNewRequestを使って対処した。

httptest - The Go Programming Language

よって、ハンドラ関数の引数が無事に作成できた。

w := httptest.NewRecorder()
// httptestにはなぜかNewRequestはないと言われる(go version 1.6.2)
// undefined: httptest.NewRequest
r, _ := http.NewRequest("GET", location, nil)

JSONWordCountHandler(w, r)

その気になればユニットテストを書く仕組みはGoにはちゃんと用意されているものだと感心した。