読者です 読者をやめる 読者になる 読者になる

今川館

都内勤務の地味OLです

Goのflagパッケージを使ってサブコマンドを作る

Go初学者

GoでPythonのargparseみたいなサブコマンドを処理したい場合はflag.NewFlagSetというやつを使えば良いらしい。

Goでのやり方はこのページでドン!なんだけど。忘れないようにメモ。

Golang: Implementing subcommands for command line applications · Software adventures and thoughts

FlagSetを別々に作ってParseする

サブコマンドを作る場合はflag.NewFlagSetを呼び出すとFlagSetが返ってくるのでそれぞれ引数、オプションの解析をすれば良い。

今回は

  • help: ヘルプを表示する. -vオプションでより詳しく表示する.
  • money: 入力したふたつの通貨の為替レートを表示する.

上記2つのサブコマンドを持つコマンドを作る。

helpコマンドにはオプションを付けるので、以下のようにする。

help := flag.NewFlagSet("help", flag.ExitOnError)
verbosity := help.Int("verbosity", 0, "")
// または
// var verbosity int
// help.IntVar(&verbosity, "verbosity", 0, "")

money := flag.NewFlagSet("money", flag.ExitOnError)

必須引数はFlagSet.Args()で参照できる

moneyコマンドは通貨をふたつ入力するので、以下のようにする。

if len(money.Args()) < 2 {
	// e.g. $ <cmd> money USD
	fmt.Println("エラー: 通貨をふたつ入力してください")
	return
}

verbosityの数でメッセージの詳細度を上げたい

例えば「-v」よりも「-vv」、「-vvv」の方が詳細なメッセージを表示可能としたい。

最初、安易に以下のようにやってしまったが、

help := flag.NewFlagSet("help", flag.ExitOnError)
v1 := help.Bool("v", false, "")
v2 := help.Bool("vv", false, "")
v3 := help.Bool("vvv", false, "")

これだと、「-vvvv」などが解析エラーになってしまう。

なので、help.Args()をループで回して最大値を取るよう書き直した(以下)。

// 引数からメッセージ詳細度を算出する。算出に使わなかった引数は戻り値のスライスに返却する。
var regex = regexp.MustCompile("^-v+$")

func Verbosity(xs []string) (int, []string) {
	var v float64
	others := make([]string, 0, len(xs))
	for _, x := range xs {
		if regex.MatchString(x) {
			v = math.Max(v, float64(strings.Count(x, "v")))
		} else {
			others = append(others, x)
		}
	}
	return int(v), others
}

help := flag.NewFlagSet("help", flag.ExitOnError)
var verbosity int
help.IntVar(&verbosity, "verbosity", 0, "")

v, others := Verbosity(os.Args[2:])
help.Parse(others)
if verbosity < v {
	// e.g. <cmd> help -verbosity=0 -vvv
	help.Set("verbosity", strconv.Itoa(v))
}

サンプルコード

プログラム全体は以下のようになった。

package main

import (
	"flag"
	"fmt"
	"math"
	"os"
	"regexp"
	"strconv"
	"strings"
)

var regex = regexp.MustCompile("^-v+$")

// 引数からメッセージ詳細度を算出する。算出に使わなかった引数は戻り値のスライスに返却する。
func Verbosity(xs []string) (int, []string) {
	var v float64
	others := make([]string, 0, len(xs))
	for _, x := range xs {
		if regex.MatchString(x) {
			v = math.Max(v, float64(strings.Count(x, "v")))
		} else {
			others = append(others, x)
		}
	}
	return int(v), others
}

func main() {
	help := flag.NewFlagSet(	"help", flag.ExitOnError)
	var verbosity int
	help.IntVar(&verbosity, "verbosity", 0, "")

	money := flag.NewFlagSet("money", flag.ExitOnError)
	if len(os.Args) == 1 {
		fmt.Println("usage: argparse <command> [<args>]")
		fmt.Println("\thelp: ヘルプをプリントします")
		fmt.Println("\tmoney: 為替レートを調べます")
		return
	}

	switch os.Args[1] {
	case "help":
		v, others := Verbosity(os.Args[2:])
		help.Parse(others)
		if verbosity < v {
			// e.g. <cmd> help -verbosity=0 -vvv
			help.Set("verbosity", strconv.Itoa(v))
		}
	case "money":
		money.Parse(os.Args[2:])
	default:
		fmt.Printf("%q is not valid command.\n", os.Args[1])
		os.Exit(2)
	}

	if help.Parsed() {
		message := "Help me."
		if verbosity == 1 {
			message = "Help me!"
		}
		if verbosity == 2 {
			message = "Help me!!"
		}
		if verbosity >= 3 {
			message = "HELP ME!!"
		}
		fmt.Println(message)
	}

	if money.Parsed() {
		if len(money.Args()) < 2 {
			// e.g. $ <cmd> money USD
			fmt.Println("エラー: 通貨をふたつ入力してください")
			return
		}

		from, to := money.Arg(0), money.Arg(1)
		if from == "" || to == "" {
			// e.g. $ <cmd> money USD ''
			fmt.Println("エラー: 通貨をふたつ入力してください")
			return
		}
		fmt.Printf("%v/%v: 100円/$\n", from, to) // 適当
	}
}

動かすとこのような結果が出る。

$ go run foo.go help -verbosity=0 -vvv
# => HELP ME!!

$ go run foo.go help -vv
# => Help me!!

$ go run foo.go money USD JPY
# => USD/JPY: 100円/$

ANSIエスケープコードを使ってコンソールアニメーション

Go初学者

プログラミング言語Go』のスピナー

予約語「go」を使うとメインgoroutineと別のgoroutineで動かせる

プログラミング言語Go』の「ゴルーチンとチャネル」という章を読んでいて、メインルーチンの処理中にスピナーを回すサンプルコードが出てきたので自分でも試していた。

この、スピナーのコードが以下の通り、1文字出力してはキャリッジリターン「\r」でカーソル位置を戻して上書きを繰り返すものなのだが、これに予約語「go」を付けると別ルーチンで動いてくれる。

func spinner(delay time.Duration) {
	for {
		for _, r := range `-\|/` {
			fmt.Printf("\r%c", r)
			time.Sleep(delay)
		}
	}
}

func main() {
	go spinner(100 * time.Millisecond)
	// 中略: 変数accに処理時間を累積していく
	fmt.Printf("%v かかりました\n", acc)
}

ところが、スピナーが出力する文字列を以下のように変えてしまったら問題が起きた。

func spinner(message string, delay time.Duration) {
	for {
		for _, r := range `-\|/` {
			fmt.Printf("\r%c%v", r, message)
			time.Sleep(delay)
		}
	}
}

func main() {
	go spinner("コンピュータ思考中...", 140*time.Millisecond)
	// 中略: 変数accに処理時間を累積していく
	fmt.Printf("\r%v かかりました\n", acc)
}

スピナーで使った文字列が残ってしまう

これだと、処理が終わるまでは「-コンピュータ思考中...」というメッセージの先頭文字が切り替わり続けているが、終了時に

2.5s かかりました中...

このように、何ともマヌケな表示になってしまう。

これを、

2.5s かかりました

このように改めるにはどうした良いのかわからなかった。
キャリッジリターンを出力するとある程度文字を書き換えられるのだから、同じような特殊文字を出力すれば良いだろうとは思っていたのだが。

ANSIエスケープコード

調べてみると、stackoverflowの記事がヒットし、「ANSIエスケープコード」というものを出力すれば良いらしい。

How can I clear the terminal screen in Go? - Stack Overflow

You could do it with ANSI escape codes:

print("\033[H\033[2J")

そういえば.vimrcや.bashrcを書き換えて、プロンプトやlsで表示される文字色を変えたときに「\033[チョメチョメ」という表記を見かけたなぁと思った。

カーソル移動と文字消去

ANSIエスケープコード - コンソール制御 - 碧色工房

このページに非常に詳しく書いてあるのだが、カーソル移動や行や文字を消すコードがちゃんと設けられている。

ESC[nG
カーソルを現在の横位置に関係なく左端からnの場所に移動させる。 (左端を1とする。nには整数が入る、省略すると1)

ESC[nK
行消去、nを省略、もしくは0を指定した場合、カーソルより後ろを消去、 1を指定するとカーソルより前を消去、2を指定すると行全体を消去となる。

最初のESCは表示文字でないので便宜上このように表示しているが、(中略)
どちらも同じものを表現しているが、8進数を利用した\033で書かれる場合が多いようだ。

上記から、行を消し、その後カーソルを行頭に移動するには以下の出力をしてあげれば良い。

fmt.Printf("\033[2K\033[G")

文字に色をつける

更に、出力する文字の色を変えることも可能らしい。
そういえばAnsibleやgrepコマンドの出力結果には適宜色がついていたなぁと思い出す。

そこで、以下の通りプログラムを書き換えて、終了メッセージの先頭に「[ok] 」というプレフィクスを緑色で表示してみることにした。

fmt.Printf("\033[32m[ok]\033[39m %v かかりました\n", acc)

すると、このように、ちゃんと色がついた。

f:id:imagawa_yakata:20161211171023p:plain

Goの乱数&time.Durationの算術演算

Go初学者

ランダムに数百ミリ秒スリープしたい

Goで乱数とスリープを組み合わせた処理を書こうとして苦しんだ。

やりたいことは以下なのだが、

  • 100〜900ミリ秒スリープする処理を5回繰り返す
  • 但し、同じ秒数の待機は一度しか許さない(一回100ミリ秒待ったら、もはや100ミリ秒待ってはいけない)
  • 5回繰り返した累積の待機秒数を最後に標準出力に出す。

これは思ったより難しかった。

サンプルコード

まず最初にサンプルコードを示して、ハマりポイントを解説したい。
紆余曲折を経て、以下のコードで期待通りの動きをしてくれた。

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	var acc, delay time.Duration
	rand.Seed(time.Now().UnixNano()) // ハマりポイント1
	choices := rand.Perm(9)

	for _, x := range choices[:5] {
		// ハマりポイント2
		delay = time.Duration((x+1)*100) * time.Millisecond
		acc += delay
		time.Sleep(delay)
	}
	fmt.Printf("合計: %v", acc)
}

ハマりポイント1: ランダムにならない(乱数シードの初期化忘れ)

このプログラムを取り敢えず動く状態にして何度か実行していたら、すべて結果が同じ秒数になってしまうことに気づいた。
これは、乱数のシードを適切に初期化しなかったことが原因らしい(以下の記事を参照)。

Random Seed って言葉を初めて知った! | ちょまど帳

こちらはコメント欄が重要
Goで乱数 - Qiita

乱数シードはナノ秒の方が良さそうです。
(1秒以内に実行されたら、同じ結果になる)

サンプルコードの通り、rand.Seed(time.Now().UnixNano())したらちゃんと結果がばらけてくれた。

ハマりポイント2: time.Durationをうまく扱えなくて苦戦

既に他の記事に書いたことだが、time.Sleepは引数にtime.Durationを取る。

これの算術演算に苦戦した。

delay = time.Duration(100 * (x+1) * time.Millisecond)

これができないので、以下の通り、先にtime.Duration(100 * x)してからtime.Millisecondと乗算する。

time.Duration((x+1)*100) * time.Millisecond

どうも、数値のリテラルと掛け算する場合は暗黙にtime.Duration同士に型を推測してくれるようだが、リテラルではなく変数だとダメみたいだ。

当然、変数accもtime.Durationで宣言しておかないとダメ。

var acc int
var delay time.Duration

(中略)
acc += delay
// これはコンパイルエラー
// invalid operation: acc += delay (mismatched types int and time.Duration)

両方ともtime.Durationで宣言しておくこと。
もしaccを整数として扱いたい場合は、型をキャストして使う。以下はOK。

fmt.Printf("合計: %v, 整数-> %v", acc, int(acc)+1000) // 合計: 2.8s, 整数-> 2800001000

Goでスリープしようとしてハマった

Go初学者

Pythonでスリープするときは

import time

time.sleep(1)  # 1秒待つ
time.sleep(0.5)  # 0.5秒待つ

こうすれば良いが、同じ感覚でGoでスリープしようとしたらうまくいかなかった。

import "time"

time.Sleep(1) // 1秒待つ(?)

どうなるかというと、やけに早く戻ってきてしまうのである。

ちなみに、こっちをやるとコンパイルエラーになってしまう。

time.Sleep(0.5)
// constant 0.5 truncated to integer

Goのtime.Sleepの引数はDurationという型

https://golang.org/pkg/time/#Sleep

golangのドキュメントを見ると、time.Sleepの引数にはtime.Durationという型を受け付ける。

そして、time.Durationはint64の別名型らしい。

type Duration int64

A Duration represents the elapsed time between two instants as an int64 nanosecond count.

しかもナノ秒が基礎なので、time.Sleep(1)とやってしまうとたった1ナノ秒しか待たないことになる。
どうりで思ったより早く戻ってくるわけだ。

正式なN秒スリープの仕方

N秒スリープするのに正式も自己流も無いと思うが、以下の書き方が安定的である。

time.Sleep(1 * time.Second) // 1秒待つ

先ほど書いた通り、0.5秒待つときにこれはできない。

time.Sleep(0.5 * time.Second)  // これはエラー(floatと掛け算できない) constant 0.5 truncated to integer

こうすれば動く。

time.Sleep(500 * time.Millisecond) // 0.5秒待つときはMillisecondと乗算する

ちなみにこれも動く。

var s time.Duration
s = 5
time.Sleep(100 * s * time.Millisecond)

しかし、こっちは動かない。int64が掛け算の式に混入するのがダメらしい。

var s int64 // この他、float64もintも試したがダメだった
s = 5
time.Sleep(100 * s * time.Millisecond)
// invalid operation: 100 * s * time.Millisecond (mismatched types int64 and time.Duration)

どうしてもintと掛け算したい場合はこのように一度整数からDurationを作ってから掛け算する。

var t int
t = 5
time.Sleep(time.Duration(t*100) * time.Millisecond)
fmt.Println("Hello, world.")

なぜ、こんなことを調べ始めたかというと、「ランダムに数百ミリ秒待つ」という処理を書こうとしてハマったからである。

(続く)

Visual Studio Codeを使ってGoのコードをデバッグする

Go初学者

Goのデバッガ

Goでデバッガを使いたい場合、gdb, delveといったツールは初心者には操作が難しい。
(コマンドラインからbとかlとかpとか入力して、ブレークポイントの設定からやらなければならないので、わたしには厳しかった)

しかし、Visual Studio CodeのGo拡張機能が無料で利用可能なので、これを使うと簡単にデバッグできる。

インストールが必要なもの

以下3つインストールが必要。

  1. Visual Studio Code
  2. Visual Studio CodeのGo拡張機能
  3. delve https://github.com/derekparker/delve

Visuao Studio CodeのGoの拡張機能が裏でdelveを操作することでGUIデバッグ機能を提供しているので、delveも入れる必要があることに注意。

前提条件

  • 今回はUbuntu16.04の64ビットデスクトップ版で実施
  • GOPATHは$HOME/_go とした (わたしのパソコンでは/home/echizen/_go)
  • $HOME/_go/src/github.com/{ユーザー名}/ 配下に自分のリポジトリをcloneする

インストール手順 (Ubuntu16.04 64ビットデスクトップ版で実施)

Visual Studio Codeをダウンロードします。

$ firefox https://code.visualstudio.com/download

f:id:imagawa_yakata:20161209132833p:plain

「.deb」のリンクを押します。
(32ビットのパソコンを使っている人は「32bit versions」のページでやって下さい)

そうすると「code_チョメチョメ.deb」のダウンロードの許可を求めるポップアップウインドウが開きます。

f:id:imagawa_yakata:20161209132836p:plain

ここで「ファイルを保存する」を選びます。
上記debファイルが所定の場所にダウンロードされます。
(わたしの場合は「ダウンロード」配下にファイルが保存されました)

ダウンロードしたdebファイルをインストールします。

まず、debファイルを保存した場所へ移動します。

$ cd ~/ダウンロード

インストールコマンドを実行します。

$ sudo dpkg -i code_1.7.2-1479766213_amd64.deb

f:id:imagawa_yakata:20161209132838p:plain

Visual Studio Codeが起動できることを確かめます。

$ code

f:id:imagawa_yakata:20161209132841p:plain

こんな画面が開いたらokです。

Goの拡張機能をインストールします。
これが無いと画面からブレークポイントを設定し、デバッグできません。

Visual Studio Codeの画面左側最下の「拡張機能」のアイコンを押します。

検索ボックスに「Go」と入力すると「Go/Rich Go language support for Visual Studio Code」という候補が出てくるので、それの「インストール」を押します。

f:id:imagawa_yakata:20161209132850p:plain

Goのプロジェクトを用意します。

$ export GOPATH=/home/echizen/_go

$ mkdir -p /home/echizen/_go/src/github.com/oyakata/
$ cd /home/echizen/_go/src/github.com/oyakata/
$ git clone git@github.com:oyakata/tokyo.git
$ cd tokyo

delveをインストールします。

https://github.com/derekparker/delve/blob/master/Documentation/installation/linux/install.md

このページの説明に従って、作業を行います。
(今回はgo getでやりました)

$ go get github.com/derekparker/delve/cmd/dlv

(プロンプトが返ってくるまで少し時間がかかります)

ここまで成功すればVisual Studio Codeの画面からデバッグ可能です。

実際にデバッグをやってみます。

Visual Studio Codeの画面を開きます。
(既に動作確認で開いた画面は一度閉じて、開き直しておいた方が良いでしょう)

$ code

画面左最上のファイルのアイコン「エクスプローラー」を押して、「フォルダーを開く」ボタンを押して、プロジェクトディレクトリを開きます。

f:id:imagawa_yakata:20161209132844p:plain

先ほど用意したプロジェクトを選択し、「OK」を押します。

f:id:imagawa_yakata:20161209132847p:plain

すると、Visual Studio Codeの画面の左側にプロジェクトの構成が読み込まれるので、デバッグしたいファイルを開いて行番号の隣の赤い●を押すとブレークポイントを設定できます。

f:id:imagawa_yakata:20161209132852p:plain

画面左の下から2番め「デバッグ」アイコンを押すと、デバッグ実行できます。

初期状態だと「構成がありません」と表示されるので、赤い●が付いている歯車アイコンを押して「Go」を選択して下さい。

f:id:imagawa_yakata:20161209132854p:plain

f:id:imagawa_yakata:20161209132856p:plain

あとは、デバッグの右にある▶を押せばデバッグ機能が使えます。

f:id:imagawa_yakata:20161209132859p:plain

以上

Goでユニットテストの書き方② 実践編

Go初学者

ユニットテストで守るべきルール

ふつうのユニットテストのための7つのルール - ブログなんだよもん

ここに良くまとまっているのだが、ユニットテストは特定の条件に依存せず、何度やっても同じ結果になるよう作ることが望ましい。

その上でよく問題になることとして、

  • ファイルの読み書きを伴うプログラムのテスト
  • ログが標準出力に出てしまう
  • 例外を出すことをテスト
  • 設定ファイルの設定値に依存するテスト
  • モックやスタブを使ったテスト
  • テストデータの投入/破棄

自分の経験としては上記の対処に頭を悩ませてきた。

今回はそういう事柄の解決をGoのユニットテストでどうやったら良いか考えてみる。

ファイルを読み取るプログラムのテスト

ファイルを読み取る場合は文字列のファイルライクオブジェクトを使って対処。
Goの場合、strings.NewReaderを使えば良い。(バイナリファイルの場合bytes.NewReader)

[unittest/fileread.go]

package unittest

import (
	"encoding/csv"
	"io"
)

type Entry map[string]string

// ファイルを処理するプログラム
func CSVToMaps(in io.Reader) (xs []Entry, err error) {
	var r *csv.Reader
	r = csv.NewReader(in)

	var names []string
	var entry Entry
	for {
		record, err := r.Read()
		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, err
		}

		if names == nil {
			// 最初の行
			names = record
		} else {
			entry = Entry{}
			for i := 0; i < len(names); i++ {
				entry[names[i]] = record[i]
			}
			xs = append(xs, entry)
		}
	}
	return
}

[unittest/fileread_test.go]

package unittest

import (
	"reflect"
	"strings"
	"testing"
)

// ファイルを入力に受け付けるプログラムのテスト: strings.NewReader/bytes.NewReaderで対処
func TestCSVToMaps(t *testing.T) {
	fp := strings.NewReader("" +
		"id,name,age\n" +
		"1,foo,18\n" +
		"2,bar,\n" +
		"3,,5")

	xs, err := CSVToMaps(fp)
	if err != nil {
		t.Errorf("ファイルの読み込みに失敗しました: %v", err)
	}

	expected := []Entry{
		{"id": "1", "name": "foo", "age": "18"},
		{"id": "2", "name": "bar", "age": ""},
		{"id": "3", "name": "", "age": "5"},
	}

	if !reflect.DeepEqual(xs, expected) {
		t.Errorf("結果が期待値と一致しません: %v != %v", xs, expected)
	}
}

ファイルを書き出すプログラムのテスト

ファイルを書き出す場合はテンポラリファイルを使って対処。Goの場合、ioutil.TempFileを使えば良い。
テンポラリファイルは最後に消すので、deferを使ってos.Remove(tmp.Name())を仕掛けておく。
なお、この方法はローカルのファイルを書き出す場合に有効である。S3とか、外部の出力先に書き出す場合は別の方法を採る必要がある。

[unittest/filewrite.go]

package unittest

import (
	"encoding/csv"
	"os"
)

// ローカルのファイルを出力するプログラム (S3とかに出力する場合はこの方法ではダメ)
func WriteCSV(names []string, xs []Entry, filepath string) {
	out, _ := os.OpenFile(filepath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
	defer out.Close()
	w := csv.NewWriter(out)

	defer w.Flush()
	w.Write(names)

	values := make([]string, len(names))
	for _, x := range xs {
		for i := 0; i < len(names); i++ {
			values[i] = x[names[i]]
		}
		w.Write(values)
	}
}

[unittest/filewrite_test.go]

package unittest

import (
	"io/ioutil"
	"os"
	"testing"
)

// ファイルを出力するプログラムのテスト: ioutil.TempFileで対処
func TestWriteCSV(t *testing.T) {
	tmp, _ := ioutil.TempFile("", "_")
	defer func() {
		os.Remove(tmp.Name())
	}()

	data := []Entry{
		{"id": "1", "name": "foo", "age": "18"},
		{"id": "2", "name": "bar", "age": ""},
		{"id": "3", "name": "", "age": "5"},
	}
	names := []string{"id", "name", "age"}
	WriteCSV(names, data, tmp.Name())

	in, _ := os.Open(tmp.Name())
	defer in.Close()
	bs, err := ioutil.ReadAll(in)
	if err != nil {
		t.Errorf("処理に失敗しました: %v", err)
	}

	result := string(bs)

	expected := ("" +
		"id,name,age\n" +
		"1,foo,18\n" +
		"2,bar,\n" +
		"3,,5\n")
	if result != expected {
		t.Errorf("結果が期待通りではありませんでした: %v != %v", result, expected)
	}
}

ログを出力するプログラムのテスト

これには悩んだ。そもそもGoは敢えて貧弱な標準ロギングライブラリを設けているらしい。

loggingについて話そう - Qiita

ログを出力するプログラムのテストは事前にロギングを無効化することにした。
方法としては、ioutil.Discardが/dev/nullに向いた出力先として用意されているので、log.SetOutput(ioutil.Discard)した。

同じ方法は以下の記事でも紹介されていた。

Using The Log Package In Go

[unittest/logging.go]

package unittest

import (
	"log"
)

// ログを出力するプログラム
func DoSomethingWithLog(x int) (ok bool) {
	if x%2 == 0 {
		log.Printf("偶数は受け付けません: %vが入力されました", x)
		return false
	}
	return true
}

[unittest/logging_test.go]

package unittest

import (
	"io/ioutil"
	"log"
	"os"
	"testing"
)

// ログを出力するプログラムのテスト: logモジュールで出力先を潰してしまう(!)
func TestDoSomethingWithLog(t *testing.T) {
	setUp := func() {
		log.SetOutput(ioutil.Discard) // /dev/nullに出力
	}
	tearDown := func() {
		log.SetOutput(os.Stdout) // 出力先を戻す
	}

	func() {
		setUp()
		defer tearDown()

		var ok bool

		ok = DoSomethingWithLog(3)
		if !ok {
			t.Errorf("結果が期待通りではありませんでした: %v != true", ok)
		}

		ok = DoSomethingWithLog(2)
		if ok {
			t.Errorf("結果が期待通りではありませんでした: %v != false", ok)
		}
	}()

	// DoSomethingWithLog(2) // 当然、無名関数の外で呼び出したら標準出力にログが出力される
}

とりあえずここまで。他のテーマは随時更新で。

Goでユニットテストの書き方① 基礎編

Go初学者

testing.Tのポインタを引数に取る関数を定義する

ベンチマークテストのことを先に調べてからユニットテストを調べているのもアレだが、Goでユニットテストを書くときはtesting.Tのポインタを引数に取る関数を定義すれば良いらしい。

assertなんちゃら といったメソッドは無い: t.Error(), t.Errorf()を使う

PythonJavaユニットテストライブラリに慣れた身としては、まずアサーションの実行には「Error」や「Errorf」という名前のメソッドを使うルールに混乱した。

ちゃんと「Fail」というメソッドもあるので、最初こっちを使うのだろうと思ったが、どうやらこれはPythonのunittest.TestCase.fail()に相当する(=とにかくテストを失敗させるメソッド、アサーション無し)もののようだ。

簡単な例を示すとこんな感じ。

[foo/main_test.go]

package foo

import "testing"

func TestFoo(t *testing.T){
	var result int
	var err error
	result, err = Foo("1")
	if result, error = Foo("1"); err != nil {
		t.Errorf("処理に失敗しました: %v", err)
	}

	if result != 10 {
		t.Errorf("結果が不正です: %v != 10", result)
	}
}

非公開モジュールのテストをするためにアプリケーションコードと同じパッケージに所属させる

小文字で名前が始まるモジュールはパッケージの外からアクセスできないので、それらのテストを書く場合はテスト対象と同じパッケージを宣言する。

ひとつのテストケースの中で複数のアサーションをやっても別々に出力してくれる

感心したのが、testing.Tはt.Errorfを同じ関数(=テストケース)の中で複数出力しても処理は続行される点だ。

Pythonならば、assertなんちゃらメソッドを呼んで期待値と結果が異なれば、即座にそのテストケースは打ち切りになる。

import unittest

class FooTest(unittest.TestCase):
    def test(self):
        self.assertEqual(1, 0)  # ここでテスト失敗、処理は打ち切られる。
        self.assertEqual(2, 2)  # ここは通らない

しかし、Goの場合はt.Error, t.Errorfが標準出力にテストの失敗を出してくれるだけで、処理は続行される。

import "testing"

func TestFoo(t *testing.T){
	if 1 != 0 {
		t.Error("結果が不正です: 1 != 0") // ここで処理は止まらない
	}

	if 2 != 1 {
		t.Error("結果が不正です: 2 != 1") // こっちもテストしてくれる
	}
}

[処理結果]

--- FAIL: TestFoo (0.00s)
	sample_test.go:7: 結果が不正です: 1 != 0
	sample_test.go:11: 結果が不正です: 2 != 1
FAIL
FAIL	github.com/oyakata/tokyo/err	0.007s

このように、ちゃんとTestFooの中で別々の検証と判るよう整形してくれることに感銘を受けた。