Closed63

テスト駆動開発実践:ゆめみさんの模試を題材にして

tenkohtenkoh

ToDo1

まずは問題設定から読み解いたToDoを書き出してみる

  • csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
  • 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
  • 読込んだcsvのヘッダが適切か確認する(行数、項目名)
  • あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
  •  ↑ から、プレイヤーidと平均得点のマップを計算する
  • 複数のプレイヤーに拡張する
  • 平均得点順に並び変える
  • 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
  • 順位を出力する
tenkohtenkoh

Test01

csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。

テストとして書いてみる。バッファリングする都合上、この部分を別関数として切り出せないと思うので(defer file.Close()を別関数でした瞬間、読み取り結果がnilになる)、テストコードとして作った処理を後から使い回す気がする。

読み取ったデータがどのようなデータ構造になるかを知りたいのもあって、ここを一番に実施する。

ranking_test.go
package ranking

func TestReadCsv(t *testing.T) {
	path := "../testdata/input.csv"
	parsed, err := filepath.Abs(path)
	if err != nil {
		t.Error("could not parse filepath")
	}
	f, err := os.Open(parsed)
	if err != nil {
		t.Error("could not open file")
	}
	defer f.Close()
	reader := csv.NewReader(f)

	for {
		record, err := reader.Read()
		if err == io.EOF {
			return
		}
		if err != nil {
			t.Error("could not read csv line")
		}
		fmt.Println(record)
	}
}

どんな読み取り結果か確認したいので、お手軽にfmt.Printlnしている。標準出力に表示させるため-vオプションをつけてテストを実行する。

tenkohtenkoh

テスト駆動開発の書籍中で言うところの、言語仕様を把握するためのテストの位置付け。

tenkohtenkoh

なんかもっと良い書き方があるような気もするけど、ひとまずこれで前進してみる。

tenkohtenkoh
=== RUN   TestReadCsv
[create_timestamp player_id score]
[2021/01/01 12:00 player0001 12345]
[2021/01/02 13:00 player0002 10000]
[2021/01/03 12:00 player0021 100]
[2021/01/04 12:10 player0031 300]
[2021/01/05 12:00 player0041 300]
[2021/01/06 12:00 player0001 10000]
--- PASS: TestReadCsv (0.00s)

ふむふむ、全て文字列として、各行が[]stringで返ってくるっぽい。

tenkohtenkoh
  • csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
  • 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
  • 読込んだcsvのヘッダが適切か確認する(行数、項目名)
  • あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
  •  ↑ から、プレイヤーidと平均得点のマップを計算する
  • 複数のプレイヤーに拡張する
  • 平均得点順に並び変える
  • 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
  • 順位を出力する
tenkohtenkoh

ちなみにフォルダ構成は下記にするつもりで進めている。

ranking/
    |- ranking.go
    |- ranking_test.go
main.go
go.mod
go.sum
tenkohtenkoh

次のテストに進もう。

読込んだcsvのヘッダが適切か確認する(行数、項目名)

  • バリデーションしなさいとは問題に書いていないが、実際業務上として渡されたファイルフォーマットのチェックは必要だと思うので実装する。
  • 気にするのは項目名と項目の数ぐらいにしよう。
  • 本当はテストケースを構造体にして管理した方が良いのは知ってはいるけど、まずはちゃっちゃか書く。
ranking_test.go
func TestValidateHeader(t *testing.T) {
	validHeader := []string{"create_timestamp", "player_id", "score"}
	if !ValidateHeader(validHeader) {
		t.Error("validation fails")
	}

	invalidHeader := []string{"create_timestamp ", "player_id ", "score "}
	if ValidateHeader(invalidHeader) {
		t.Error("validation fails")
	}

	invalidHeader = []string{"create_timestamp", "player_id"}
	if ValidateHeader(invalidHeader) {
		t.Error("validation fails")
	}
}
tenkohtenkoh

これに対する実装を行う。

ここからはまずコンパイルを通す->エラーを消す->リファクタリングするというループを繰り返していくけど、ここに記載するのは少し端折る予定・・・。

tenkohtenkoh

まずは所定の関数がなくてコンパイルが通らないので空で宣言。

ranking.go
func ValidateHeader(header []string) bool {
	return true
}
tenkohtenkoh

コンパイルは通るけど予想通りエラーになる。

tenkohtenkoh

まじめに実装する。

ranking.go
func ValidateHeader(header []string) bool {
	expected := []string{"create_timestamp", "player_id", "score"}
	if len(header) != 3 {
		return false
	}
	for i, h := range expected {
		if header[i] != h {
			return false
		}
	}
	return true
}
tenkohtenkoh

また一つ済にできた。

  • csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
  • 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
  • 読込んだcsvのヘッダが適切か確認する(行数、項目名)
  • あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
  •  ↑ から、プレイヤーidと平均得点のマップを計算する
  • 複数のプレイヤーに拡張する
  • 平均得点順に並び変える
  • 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
  • 順位を出力する
tenkohtenkoh

次に、このToDoに取り組む。

あるプレイヤーについて合計得点と出現回数をインクリメントカウントする

ToDoを書き出した時点で、頭の中で済ませてしまっているが、本当に必要な平均点を得るために、合計点数とカウント回数をまとめておきたいというもの。

tenkohtenkoh

おっと、csvを読み込んだ結果は全て文字列になっている。
合計を計算するには数値に変換する必要がある。。。これもToDoに追加して、先に済ませてしまわなければならない。

  • csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
  • 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
  • 読込んだcsvのヘッダが適切か確認する(行数、項目名)
  • csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
  • あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
  •  ↑ から、プレイヤーidと平均得点のマップを計算する
  • 複数のプレイヤーに拡張する
  • 平均得点順に並び変える
  • 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
  • 順位を出力する
tenkohtenkoh
ranking_test.go
func TestParseRecord(t *testing.T) {
	r := []string{"2021/01/01 12:00", "player0001", "12345"}
	parsed, err := ParseRecord(r)
	if err != nil {
		t.Error("could not parse a valid input")
	}
	if parsed.player != r[1] {
		t.Error("player: not expected value")
	}
	if parsed.score != 12345 {
		t.Error("score: not expected value")
	}
	// scoreは型変換が入るのでチェックする
	if fmt.Sprintf("%T", parsed.score) != "int" {
		t.Error("score type: not expected type")
	}
}
tenkohtenkoh

この時点で設計判断を下している部分がある。
csvを読み込んで得た各行を、playerscoreというフィールドを持った構造体にパースすることにしている。
なんでそうしたかと聞かれると困るが、何列目が何を表すかという思考を引きずりたくなく、この後直感的にコーディングできるように構造体にすることにしたのだと思う。

tenkohtenkoh

お察しの通りまずはコンパイルが通らない。ここに全てのステップを書くと冗長になるので、テストがパスした段階のコードだけ記そう。

ranking.go
type ParsedRecord struct {
	player string
	score  int
}

// input: []string{date, player, score}
func ParseRecord(r []string) (*ParsedRecord, error) {
	if len(r) != 3 {
		return nil, errors.New("invalid input")
	}
	score, err := strconv.Atoi(r[2])
	if err != nil {
		return nil, err
	}
	// scoreの最大値超え確認は必要?
	return &ParsedRecord{r[1], score}, nil
}

tenkohtenkoh

スコアの上限がint型の上限を超えないかモヤモヤするのでコメントだけ残しておいた。
将来的に超ハイスコアプレイヤーが登場するようになったら直してもらおう。

tenkohtenkoh

うん、また一つチェックをつけることができた。前進している。

  • csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
  • 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
  • 読込んだcsvのヘッダが適切か確認する(行数、項目名)
  • csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
  • あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
  •  ↑ から、プレイヤーidと平均得点のマップを計算する
  • 複数のプレイヤーに拡張する
  • 平均得点順に並び変える
  • 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
  • 順位を出力する
tenkohtenkoh

もともとやりたかった合計得点と投稿回数の取得に移ろう。

tenkohtenkoh
input.csv
create_timestamp,player_id,score
2021/01/01 12:00,player0001,12345
2021/01/02 13:00,player0002,10000
2021/01/03 12:00,player0021,100
2021/01/04 12:10,player0031,200
2021/01/05 12:00,player0041,300
2021/01/06 12:00,player0001,10000
ranking_test.go
func TestPlayerSum(t *testing.T) {
	path, _ := filepath.Abs("../testdata/input.csv")
	f, err := os.Open(path)
	if err != nil {
		t.Error("bad test: could not load test input")
	}
	defer f.Close()

	data := csv.NewReader(f)
	counter := NewCounter()
	counter.Count(data)

	sumCounter := counter.SumCounter
	if player1 := sumCounter["player0001"]; player1.sum != 22345 {
		t.Errorf("expected 22345: got %d\n", player1.sum)
	}
	if player1 := sumCounter["player0002"]; player1.sum != 10000 {
		t.Errorf("expected 10000: got %d\n", player1.sum)
	}
}
tenkohtenkoh

このテストを書く間に、次のような設計判断をしている。

  • プレイヤーごとに合計得点と加算回数を整理したいので、プレイヤー名をキーとするマップを作ることにした
  • 上記マップの値には、合計得点と加算回数をフィールドとする構造体(SumCounter)を使う
  • ゆくゆくは平均点も計算する必要がある。そこでCounterという構造体を作り、その中に上記のマップと今後計算するだろう平均点を収めるようにしよう。
tenkohtenkoh

最終的な実装結果がこちら

ranking.go
type PlayerSum struct {
	sum   int
	count int
}

type Counter struct {
	SumCounter map[string]PlayerSum
}

func NewCounter() *Counter {
	counter := new(Counter)
	counter.SumCounter = make(map[string]PlayerSum)
	return counter
}

func (c *Counter) Count(r *csv.Reader) {
	if r == nil {
		return
	}

	header, err := r.Read()
	if err != nil {
		return
	}
	if !ValidateHeader(header) {
		return
	}

	for {
		record, err := r.Read()
		if err == io.EOF {
			break
		}
		// ある行が無効だったら飛ばして処理を継続する
		if err != nil {
			continue
		}
		parsed, err := ParseRecord(record)
		if err != nil {
			continue
		}
		playerSum, exist := c.SumCounter[parsed.player]
		if !exist {
			c.SumCounter[parsed.player] = PlayerSum{parsed.score, 1}
		} else {
			c.SumCounter[parsed.player] = PlayerSum{
				playerSum.sum + parsed.score,
				playerSum.count + 1,
			}
		}
	}
}
tenkohtenkoh

よしよしパスした

  • csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
  • 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
  • 読込んだcsvのヘッダが適切か確認する(行数、項目名)
  • csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
  • あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
  •  ↑ から、プレイヤーidと平均得点のマップを計算する
  • 複数のプレイヤーに拡張する
  • 平均得点順に並び変える
  • 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
  • 順位を出力する
tenkohtenkoh
  • モヤっとしていた各種命名を修正した。 (どうでも良い話だが、合計得点を表示するboardのようなメタファーに基づいてみた)
  • 処理が長くなっていたところを切り出した。

テストも通っている。よしよし。

ranking_test.go
func TestSumCounter(t *testing.T) {
	path, _ := filepath.Abs("../testdata/input.csv")
	f, err := os.Open(path)
	if err != nil {
		t.Error("bad test: could not load test input")
	}
	defer f.Close()

	data := csv.NewReader(f)
	rank := NewRanking()
	rank.Aggregate(data)

	sumBoard := rank.sumBoard
	tests := map[string]int{
		"player0001": 22345,
		"player0002": 10000,
	}
	for player, sum := range tests {
		if sumCounter := sumBoard[player]; sumCounter.sum != sum {
			t.Errorf("expected %d: got %d\n", sum, sumCounter.sum)
		}
	}
}
ranking.go
type SumCounter struct {
	sum   int
	count int
}

type Ranking struct {
	sumBoard map[string]SumCounter
}

func NewRanking() *Ranking {
	rank := new(Ranking)
	rank.sumBoard = make(map[string]SumCounter)
	return rank
}

func (r *Ranking) Aggregate(records *csv.Reader) {
	if records == nil {
		return
	}

	header, err := records.Read()
	if err != nil {
		return
	}
	if !ValidateHeader(header) {
		return
	}

	for {
		record, err := records.Read()
		if err == io.EOF {
			break
		}
		// ある行が無効だったら飛ばして処理を継続する
		if err != nil {
			continue
		}
		r.updateSum(record)
	}
}

func (r *Ranking) updateSum(record []string) {
	parsed, err := ParseRecord(record)
	if err != nil {
		return
	}
	current, exist := r.sumBoard[parsed.player]
	if !exist {
		r.sumBoard[parsed.player] = SumCounter{parsed.score, 1}
	} else {
		r.sumBoard[parsed.player] = SumCounter{
			current.sum + parsed.score,
			current.count + 1,
		}
	}
}
tenkohtenkoh

↑ から、プレイヤーidと平均得点のマップを計算する

いよいよ本丸に取り掛かろう。

tenkohtenkoh

Ranking.Aggregateというメソッドは平均点の取得までを見据えているので、この中にテストを追加する。
(本当はテストを分けてあげた方が良い気がするが、ファイルの読み込み部分など重複してしまうので追記する形にした)

ranking_test.go
func TestAggregate(t *testing.T) {
	path, _ := filepath.Abs("../testdata/input.csv")
	f, err := os.Open(path)
	if err != nil {
		t.Error("bad test: could not load test input")
	}
	defer f.Close()

	data := csv.NewReader(f)
	rank := NewRanking()
	rank.Aggregate(data)

	sumBoard := rank.sumBoard
	tests := map[string]int{
		"player0001": 22345,
		"player0002": 10000,
	}
	for player, sum := range tests {
		if sumCounter := sumBoard[player]; sumCounter.sum != sum {
			t.Errorf("expected %d: got %d\n", sum, sumCounter.sum)
		}
	}

+	meanBoard := rank.meanBoard
+	meanTests := map[string]float32{
+		"player0001": 22345.0 / 2,
+		"player0002": 10000,
+	}
+	for player, mean := range meanTests {
+		if m := meanBoard[player]; m != mean {
+			t.Errorf("expected %f, got %f\n", mean, m)
+		}
+	}
}
tenkohtenkoh

このまとめを作っている間に気が付きましたが、平均点は四捨五入で整数にする指定がありました。
公開した最終ソースコードは修正済みですが、本記事の中ではこのままでいきます。

tenkohtenkoh

いざ実装に移ろうとしたところで躓いてしまった。

検索の便利さからmap[player]meanという返り値を想定したテストを書いた。
しかしmapでは並び順が保証されないので、せっかくこのmapを作っても、後から平均得点順に並び替える時に別にスライスを作り直す必要が出てくるだろうことがわかってしまった。

ここは一度テストを見直そうと思う。
プレイヤー名と平均点をフィールドとする構造体を作り、そのスライスを作ることにしよう。

tenkohtenkoh
ranking_test.go
func TestAggregate(t *testing.T) {
	path, _ := filepath.Abs("../testdata/input.csv")
	f, err := os.Open(path)
	if err != nil {
		t.Error("bad test: could not load test input")
	}
	defer f.Close()

	data := csv.NewReader(f)
	rank := NewRanking()
	rank.Aggregate(data)

	sumBoard := rank.sumBoard
	tests := map[string]int{
		"player0001": 22345,
		"player0002": 10000,
	}
	for player, sum := range tests {
		if sumCounter := sumBoard[player]; sumCounter.sum != sum {
			t.Errorf("expected %d: got %d\n", sum, sumCounter.sum)
		}
	}

	meanBoard := rank.meanBoard
+	meanTests := []PlayerMean{
+		{"player0001", 22345.0 / 2},
+		{"player0002", 10000},
	}
+	hits := 0
+	for _, expected := range meanTests {
+		for _, calculated := range meanBoard {
+			if expected.player == calculated.player {
+				hits++
+				if expected.mean != calculated.mean {
+					t.Errorf("expected %f, got %f\n", expected.mean, calculated.mean)
+				}
+			}
+		}
+	}
+	if hits != len(meanTests) {
+		t.Errorf("player num is insufficient")
	}
}

テストの成功条件は複雑になってしまったが、この後のことを考えてテスト自体をリファインできた。

tenkohtenkoh

この時点ではmeanBoardの中身は点数順にソートされていることを期待していないので、確認方法が煩雑な感じ。

tenkohtenkoh

いざ実装

ranking.go
+type PlayerMean struct {
+	player string
+	mean   float32
+}

type Ranking struct {
	sumBoard  map[string]SumCounter
+	meanBoard []*PlayerMean
}

func NewRanking() *Ranking {
+	cap_player := 1000
	rank := new(Ranking)
	rank.sumBoard = make(map[string]SumCounter)
+	rank.meanBoard = make([]*PlayerMean, 0, cap_player)
	return rank
}

func (r *Ranking) Aggregate(records *csv.Reader) {
	if records == nil {
		return
	}

	header, err := records.Read()
	if err != nil {
		return
	}
	if !ValidateHeader(header) {
		return
	}

	for {
		record, err := records.Read()
		if err == io.EOF {
			break
		}
		// ある行が無効だったら飛ばして処理を継続する
		if err != nil {
			continue
		}
		r.updateSum(record)
	}
+	r.updateMean()
}

+func (r *Ranking) updateMean() {
+	for player, sumCounter := range r.sumBoard {
+		cnt := sumCounter.count
+		if cnt == 0 {
+			continue
+		}
+		mean := float32(sumCounter.sum) / float32(cnt)
+		r.meanBoard = append(r.meanBoard, &PlayerMean{player, mean})
+	}
+}
tenkohtenkoh

[]*PlayerMeanのキャパシティーは正直適当。問題文に一万人以上にはならないと書かれていたので、適当に1/10にしておいた。あんまりグローバルに定義してもなんのことかわからないし、コンストラクタの中だけに隠しておこう…。

tenkohtenkoh

よしパス!

  • csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
  • 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
  • 読込んだcsvのヘッダが適切か確認する(行数、項目名)
  • csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
  • あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
  •  ↑ から、プレイヤーidと平均得点のマップを計算する
  • 複数のプレイヤーに拡張する
  • 平均得点順に並び変える
  • 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
  • 順位を出力する
tenkohtenkoh

最初から複数プレイヤーに拡張してしまってあったのでテストをクローズした

tenkohtenkoh

次は平均得点による並び替え。いよいよゴールまで後少しという感じ。

tenkohtenkoh
ranking_test.go
func TestSortMeanBoard(t *testing.T) {
	test := []*PlayerMean{
		{"player2", 100},
		{"player1", 100},
		{"player3", 200},
		{"player4", 50},
	}
	// 要求にないので同点の時にプレイヤー名の五十音で並び替えるなどは実装していない
	expected := []*PlayerMean{
		{"player3", 200},
		{"player2", 100},
		{"player1", 100},
		{"player4", 50},
	}
	// this method override order
	sortMeanBoard(test)
	for i, player := range expected {
		if test[i].player != player.player {
			t.Errorf("unexpected order at index of %d", i)
		}
	}
}
tenkohtenkoh

仕様になかったので、点数が同点だった時にどう並び替えるかは実装していない。

tenkohtenkoh

この並び替えの実装は正直悩んだ…。sortパッケージのgodocを見たらそのまんまの例があったので使わせてもらった。

ranking.go
func sortMeanBoard(board []*PlayerMean) {
	// 平均点で並び替え
	sort.Slice(board, func(i, j int) bool {
		return board[i].mean > board[j].mean
	})
}
tenkohtenkoh
  • csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
  • 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
  • 読込んだcsvのヘッダが適切か確認する(行数、項目名)
  • csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
  • あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
  •  ↑ から、プレイヤーidと平均得点のマップを計算する
  • 複数のプレイヤーに拡張する
  • 平均得点順に並び変える
  • 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
  • 順位を出力する
tenkohtenkoh

ここまでで平均得点降順に並んだプレイヤー一覧が準備できている。あとやることは順位を付与して、それを出力してあげるだけだ。

…ところがどっこい、この順位付与が意外とめんどくさい。同点の時の処置として、1位の人が二人いたら、1, 1, 3のような順位付けをしないといけない。今の所の最高得点を覚えておいて、もし同じだったら次点の人につける順位を考えておいてあげないとならない。

tenkohtenkoh

テストがどんどん長くなっていくな…という自分の心の声を聞いた時、あ、どこかでロジックを分離できるんじゃないか?と思いついた。
そこから思考を巡らせ、次のように方針転換を行うことにした。

  • プレイヤーの得点の集計と並び替えまでをおこなうRecorder構造体を設ける
  • ↑ を受け取り、順位付けと発表までを行うJudege構造体を設ける

得点記録係と、最終的な順位を決める審判のようなメタファーに基づくロジックである。

tenkohtenkoh

この思考に基づくと、ここまで実装してきた内容はRecorderのメソッドと、その他一般的な関数となる。
順位付けの責務を負うJudgeとそのメソッドはここから作るものだ。

ボリュームが膨らんできたこともあり、実装側のコードはファイルを分割する必要もありそうだ。

かなり大掛かりなリファクタリングになるが、こちらには足場となるテストがある。テストと実装を慎重にいじっていけば怖いことはないだろう。

tenkohtenkoh

リファクタリング適用後

ranking_test.go
func TestValidateHeader(t *testing.T) {
	validHeader := []string{"create_timestamp", "player_id", "score"}
	if !ValidateHeader(validHeader) {
		t.Error("validation fails")
	}

	invalidHeader := []string{"create_timestamp ", "player_id ", "score "}
	if ValidateHeader(invalidHeader) {
		t.Error("validation fails")
	}

	invalidHeader = []string{"create_timestamp", "player_id"}
	if ValidateHeader(invalidHeader) {
		t.Error("validation fails")
	}
}

func TestParseRecord(t *testing.T) {
	r := []string{"2021/01/01 12:00", "player0001", "12345"}
	parsed, err := ParseRecord(r)
	if err != nil {
		t.Error("could not parse a valid input")
	}
	if parsed.player != r[1] {
		t.Error("player: not expected value")
	}
	if parsed.score != 12345 {
		t.Error("score: not expected value")
	}
	// scoreは型変換が入るのでチェックする
	if fmt.Sprintf("%T", parsed.score) != "int" {
		t.Error("score type: not expected type")
	}
}

func TestAggregate(t *testing.T) {
	path, _ := filepath.Abs("../testdata/input.csv")
	f, err := os.Open(path)
	if err != nil {
		t.Error("bad test: could not load test input")
	}
	defer f.Close()

	data := csv.NewReader(f)
	recorder := NewRecorder()
	recorder.Aggregate(data)

	sumBoard := recorder.sumBoard
	tests := map[string]int{
		"player0001": 22345,
		"player0002": 10000,
	}
	for player, sum := range tests {
		if sumCounter := sumBoard[player]; sumCounter.sum != sum {
			t.Errorf("expected %d: got %d\n", sum, sumCounter.sum)
		}
	}

	meanBoard := recorder.meanBoard
	meanTests := []PlayerMean{
		{"player0001", 22345.0 / 2},
		{"player0002", 10000},
	}
	hits := 0
	for _, expected := range meanTests {
		for _, calculated := range meanBoard {
			if expected.player == calculated.player {
				hits++
				if expected.mean != calculated.mean {
					t.Errorf("expected %f, got %f\n", expected.mean, calculated.mean)
				}
			}
		}
	}
	if hits != len(meanTests) {
		t.Errorf("player num is insufficient")
	}
}

func TestSortMeanBoard(t *testing.T) {
	test := []*PlayerMean{
		{"player2", 100},
		{"player1", 100},
		{"player3", 200},
		{"player4", 50},
	}
	// 要求にないので同点の時にプレイヤー名の五十音で並び替えるなどは実装していない
	expected := []*PlayerMean{
		{"player3", 200},
		{"player2", 100},
		{"player1", 100},
		{"player4", 50},
	}
	// this method override order
	sortMeanBoard(test)
	for i, player := range expected {
		if test[i].player != player.player {
			t.Errorf("unexpected order at index of %d", i)
		}
	}
}

util.go
package ranking

import (
	"errors"
	"sort"
	"strconv"
)

type ParsedRecord struct {
	player string
	score  int
}

func ValidateHeader(header []string) bool {
	expected := []string{"create_timestamp", "player_id", "score"}
	if len(header) != 3 {
		return false
	}
	for i, h := range expected {
		if header[i] != h {
			return false
		}
	}
	return true
}

// input: []string{date, player, score}
func ParseRecord(r []string) (*ParsedRecord, error) {
	if len(r) != 3 {
		return nil, errors.New("invalid input")
	}
	score, err := strconv.Atoi(r[2])
	if err != nil {
		return nil, err
	}
	// scoreの最大値超え確認は必要?
	return &ParsedRecord{r[1], score}, nil
}

func sortMeanBoard(board []*PlayerMean) {
	// 平均点で並び替え
	sort.Slice(board, func(i, j int) bool {
		return board[i].mean > board[j].mean
	})
}

recorder.go
package ranking

import (
	"encoding/csv"
	"io"
)

type SumCounter struct {
	sum   int
	count int
}

type PlayerMean struct {
	player string
	mean   float32
}

type Recorder struct {
	sumBoard  map[string]SumCounter
	meanBoard []*PlayerMean
}

func NewRecorder() *Recorder {
	cap_player := 1000
	recorder := new(Recorder)
	recorder.sumBoard = make(map[string]SumCounter)
	recorder.meanBoard = make([]*PlayerMean, 0, cap_player)
	return recorder
}

func (r *Recorder) Aggregate(records *csv.Reader) {
	if records == nil {
		return
	}

	header, err := records.Read()
	if err != nil {
		return
	}
	if !ValidateHeader(header) {
		return
	}

	for {
		record, err := records.Read()
		if err == io.EOF {
			break
		}
		// ある行が無効だったら飛ばして処理を継続する
		if err != nil {
			continue
		}
		r.updateSum(record)
	}
	r.updateMean()
}

func (r *Recorder) updateSum(record []string) {
	parsed, err := ParseRecord(record)
	if err != nil {
		return
	}
	current, exist := r.sumBoard[parsed.player]
	if !exist {
		r.sumBoard[parsed.player] = SumCounter{parsed.score, 1}
	} else {
		r.sumBoard[parsed.player] = SumCounter{
			current.sum + parsed.score,
			current.count + 1,
		}
	}
}

func (r *Recorder) updateMean() {
	for player, sumCounter := range r.sumBoard {
		cnt := sumCounter.count
		if cnt == 0 {
			continue
		}
		mean := float32(sumCounter.sum) / float32(cnt)
		r.meanBoard = append(r.meanBoard, &PlayerMean{player, mean})
	}
}
tenkohtenkoh

いよいよ順位の読み上げである。
先ほどのように責務を検討したので、順位付けと出力(読み上げ)は同時に行う想定になっている。

はて、最終的には標準出力に出力するが、それではテストができないではないか。
とするとJudgeは出力先を切り替える能力が必要そうだ。
それをテストを通じて表現してみよう。

ranking_test.go
func TestJudgeOutput(t *testing.T) {
	path, _ := filepath.Abs("../testdata/input.csv")
	f, err := os.Open(path)
	if err != nil {
		t.Error("bad test: could not load test input")
	}
	defer f.Close()

	data := csv.NewReader(f)
	recorder := NewRecorder()
	recorder.Aggregate(data)

	judge := NewJudge(recorder)
	writer := new(bytes.Buffer)

	judge.SetWriter(writer)
	judge.Output()
	expectedSlice := []string{
		"1,player0001,11172.50",
		"2,player0002,10000.00",
		"3,player0031,300.00",
		"3,player0041,300.00",
		"5,player0021,100.00",
	}

	s := writer.String()
	s = strings.TrimRight(s, "\n") // trim last newline
	ss := strings.Split(s, "\n")

	for _, expected := range expectedSlice {
		if !isin(expected, ss) {
			t.Errorf("could not find %s\n", expected)
		}
	}
}

func isin(target string, words []string) bool {
	for _, w := range words {
		if target == w {
			return true
		}
	}
	return false
}
tenkohtenkoh

おや、このテストは最終的なコードの統合の姿を示しているようだ。このテストをパスしてしまえば、ほぼ統合テストまで完了したようなものだろう。

tenkohtenkoh

なお、このテストを書いている時に躓いた点があった。

合計得点が同じ時、プレイヤー名の並び順は一意に決まらないのだ。
これは合計得点を取得する時にmapを使っているので、その時点で元のファイル中の並び順が失われるため。

tenkohtenkoh

そのため面倒だが、期待値一覧を操作して、全てが返り値の中に見つかるかどうかを調べる仕様にした。

tenkohtenkoh

よし実装だ。

judge.go
package ranking

import (
	"fmt"
	"io"
	"os"
)

const DISPLAY_MAX_RANK = 10

type Judge struct {
	last   float32
	rank   int
	skip   int
	record *Recorder
	writer io.Writer
}

func NewJudge(r *Recorder) *Judge {
	judge := new(Judge)
	judge.last = 0.0
	judge.rank = 0
	judge.skip = 0
	judge.record = r
	return judge
}

func (j *Judge) SetWriter(w io.Writer) {
	j.writer = w
}

func (j *Judge) Output() {
	w := j.writer
	if j.writer == nil {
		w = os.Stdout
	}
	for _, playerMean := range j.record.meanBoard {

		// 同点を考慮して例えば 1, 1, 3 のような順位を表示するための処理
		if j.last != playerMean.mean {
			j.last = playerMean.mean
			j.rank = j.rank + j.skip + 1
			j.skip = 0
		} else {
			j.skip++
		}

		if j.rank > DISPLAY_MAX_RANK {
			break
		}
		fmt.Fprintf(w, "%d,%s,%.2f\n", j.rank, playerMean.player, playerMean.mean)
	}
}
tenkohtenkoh

ちょっとごちゃごちゃしているが、重複があるわけでもなく、テストもパスするのでこれでOKだ。

tenkohtenkoh
  • csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
  • 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
  • 読込んだcsvのヘッダが適切か確認する(行数、項目名)
  • csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
  • あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
  •  ↑ から、プレイヤーidと平均得点のマップを計算する
  • 複数のプレイヤーに拡張する
  • 平均得点順に並び変える
  • 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
  • 順位を出力する
tenkohtenkoh

最後にmain.goの中から使いやすいように適当に統合した関数を作っておく。

ranking_test.go
func TestIntegration(t *testing.T) {
	es := []string{
		"1,player0001,11563.33",
		"2,player0002,10000.00",
		"3,player001,1000.00",
		"3,player002,1000.00",
		"5,player0031,300.00",
		"5,player0041,300.00",
		"7,player0021,100.00",
		"7,player01,100.00",
		"7,player02,100.00",
		"10,player031,30.00",
		"10,player041,30.00",
	}

	filename := "../testdata/large.csv"
	writer := new(bytes.Buffer)
	err := GetRank(filename, writer)
	if err != nil {
		t.Errorf("Got error: %s\n", err.Error())
	}

	// note: return values' order could not be guaranteed
	s := writer.String()
	s = strings.TrimRight(s, "\n") // trim last newline
	ss := strings.Split(s, "\n")

	for _, expected := range es {
		if !isin(expected, ss) {
			t.Errorf("could not find %s\n", expected)
		}
	}
}

これでOK

main.go
func GetRank(filename string, w io.Writer) error {
	path, err := filepath.Abs(filename)
	if err != nil {
		return err
	}
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()

	data := csv.NewReader(f)
	recorder := NewRecorder()
	recorder.Aggregate(data)

	judge := NewJudge(recorder)
	judge.SetWriter(w)
	judge.Output()
	return nil
}
tenkohtenkoh
large.csv
create_timestamp,player_id,score
2021/01/01 12:00,player0001,12345
2021/01/02 13:00,player0002,10000
2021/01/03 12:00,player0021,100
2021/01/04 12:10,player0031,300
2021/01/05 12:00,player0041,300
2021/01/06 12:00,player0001,10000
2021/01/01 12:00,player0001,12345
2021/01/02 13:00,player0002,10000
2021/01/03 12:00,player0021,100
2021/01/04 12:10,player0031,300
2021/01/05 12:00,player0041,300
2021/01/06 12:00,player001,1000
2021/01/02 13:00,player002,1000
2021/01/03 12:00,player021,10
2021/01/04 12:10,player031,30
2021/01/05 12:00,player041,30
2021/01/06 12:00,player001,1000
2021/01/02 13:00,player002,1000
2021/01/03 12:00,player021,10
2021/01/04 12:10,player031,30
2021/01/05 12:00,player041,30
2021/01/06 12:00,player01,100
2021/01/05 12:00,player41,3
2021/01/06 12:00,player01,100
2021/01/02 13:00,player02,100
2021/01/03 12:00,player21,1
2021/01/04 12:10,player31,3
2021/01/05 12:00,player41,3
2021/01/06 12:00,player01,100
tenkohtenkoh

最後に実行プログラムを書く。ここまでのテストが通っているので、これは処理が実行できたかどうかだけで良し悪しがわかる。

tenkohtenkoh
main.go
package main

import (
	"flag"
	"log"
	"os"

	"github.com/tenkoh/yumemi-moshi/ranking"
)

func main() {
	flag.Parse()
	args := flag.Args()
	if len(args) != 1 {
		log.Fatal("invalid command.")
	}
	filepath := args[0]
	w := os.Stdout
	err := ranking.GetRank(filepath, w)
	if err != nil {
		log.Fatal("operation failed")
	}
}
このスクラップは2023/04/03にクローズされました