はじめに
オブジェクト指向的な言語をはじめとして、大抵の言語は抽象化を行うための方法を有すると思います。
Go言語では、オブジェクト指向的な言語ではありませんが、interfaceという型により抽象化を行います。
例えば、package ioが定義しているReader インターフェースについて見てみましょう。
type Reader interface {
Read(p []byte) (n int, err error)
}
このコードは、Readという名前のメソッドを持ち、かつ引数として[]byteを取り、かつ戻り値として(int, error)を持つ型は、Reader interfaceにより抽象化できるということを主張しています。
(ちなみに、error
はビルトインで定義されているinterfaceです。)
具体例を見てみましょう。
osパッケージにて定義されている型File
は、以下のメソッドを持ちます。
func (f *File) Read(b []byte) (n int, err error)
io.Reader
と同じメソッドを持っていますね。
よって、os.File
は、io.Reader
インターフェースにより抽象化できます。
言い換えると、io.Reader
は、os.File
により実装されているわけです。
抽象化すると何が嬉しいのか
あるポートで動作する、ウェブサーバーを実装することを考えてみます。
とりあえず、ウェブサーバーの設定を記述したconfig.json
を用意してから、以下のようなコードを書いてみました。
package config
import (
"encoding/json"
"os"
)
type ServerConfig struct {
Port string `json:"port"`
}
func GetConfigFile() (*os.File, error) {
return os.Open("config.json")
}
func GetConfig(file *os.File) (*ServerConfig, error) {
var dec = json.NewDecoder(file)
var conf ServerConfig
if err := dec.Decode(&conf); err != nil {
return nil, err
}
return &conf, nil
}
package main
import (
"fmt"
"net/http"
"qiita/config"
)
func main() {
var file, err = config.GetConfigFile()
if err != nil {
panic(err)
}
conf, err := config.GetConfig(file)
if err != nil {
panic(err)
}
http.HandleFunc("/qiita", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "hello, world!")
})
http.ListenAndServe(":"+conf.Port, nil)
}
ごく普通のコードですが、このコードには以下の欠点があります。
-
config.json
を実際に用意しないと、main()
や、config.GetConfig()
を単体テストできない - サーバーの設定を、ファイルではなく、別の手段で与えるよう変更するとき、コードの修正量が多くなる可能性がある
特に1点目は、ちょっと深刻な問題です。
例えば、サーバーに与える設定の値を変えて単体テストしたくなったとき、この場合だとconfig.json
に記述する値を変更せざるを得ません。変更したあと、元に戻すのが面倒ですし、変更したままリモートリポジトリにプッシュしてしまったりするかもしれません。
このように、main.go
とconfig.go
は、単体テストがしづらいコードとなっています。
この問題を解決するため、以下のようにコードをリファクタリングしてみましょう。
package main
import (
"fmt"
"net/http"
"qiita/config"
)
type Server struct {
conf Config
}
type Config interface {
GetPort() string
}
func main() {
var file, err = config.GetConfigFile()
if err != nil {
panic(err)
}
conf, err := config.GetConfig(file)
if err != nil {
panic(err)
}
var server = newServer(conf)
server.serve()
}
func newServer(conf Config) *Server {
return &Server{conf: conf}
}
func (s *Server) serve() {
http.HandleFunc("/qiita", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "hello, world!")
})
http.ListenAndServe(":"+s.conf.GetPort(), nil)
}
Config
インターフェースを定義し、サーバーが動作するポート番号を返す存在として抽象化してみました。
こうしてConfig
を抽象化することで、main
パッケージはConfig
の実体を知る必要がなくなりました。
また、構造体Server
を定義し、Server
がConfig
を持つようにしてみました。
Server
を生成し、そのポインタを返すnewServer()
にも注意してください。
引数をinterfaceとすることで、GetPort() string
を持つ型なら何でも引数に渡せるようにしてあります。
これは"accept interface, return struct"と呼ばれるGoのパターンの1つです。
config.go
を見てみましょう。
package config
import (
"encoding/json"
"io"
"os"
)
type ServerConfig struct {
Port string `json:"port"`
}
func (sc *ServerConfig) GetPort() string {
return sc.Port
}
func GetConfigFile() (*os.File, error) {
return os.Open("config.json")
}
func GetConfig(reader io.Reader) (*ServerConfig, error) {
var dec = json.NewDecoder(reader)
var conf ServerConfig
if err := dec.Decode(&conf); err != nil {
return nil, err
}
return &conf, nil
}
GetConfig()
の引数として、ファイルの実体であるos.File
でなく、それを抽象化したインターフェースであるio.Reader
を渡すことで、GetConfig()
の単体テストを楽にできるようになりました。
package config_test
import (
"io"
"qiita/config"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestGetConfig(t *testing.T) {
type args struct {
reader io.Reader
}
tests := []struct {
name string
args args
want *config.ServerConfig
wantErr bool
}{
{
name: "successful",
args: args{
reader: strings.NewReader(`{"port":"8080"}`),
},
want: &config.ServerConfig{
Port: "8080",
},
wantErr: false,
},
{
name: "fail case (wrong json syntax)",
args: args{
reader: strings.NewReader(`{port:8080}`),
},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := config.GetConfig(tt.args.reader)
if (err != nil) != tt.wantErr {
t.Errorf("GetConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(got, tt.want); diff != "" {
t.Errorf("GetConfig() = %v, want %v", got, tt.want)
}
})
}
}
config_test.go
では、以下をテストしています。
- 正しい文法のjsonを渡すと、デコードが行われ、デコードの結果と期待した構造体の値が等しいこと
- 文法に誤りを含むjsonを渡すと、デコードが行われず、エラーを返すこと
リファクタリング前のコードだと、テストの度にconfig.jsonを手作業で修正するしかありませんでした。
それに対し、リファクタリング後は、コマンド1つで複数のテストケースをテストできるようになりました。
終わりに
テストしやすいコードを書くと、コードの保守性や、変更への柔軟性が向上すると思います。
今回のコードを置いておくので、よければご参照ください。