20190502のGoに関する記事は10件です。

mem. golangに入門してみた時にやったこと

golangを触ってみる際に個人的にやったことを備忘録として残しておく。

主にやったこと

  • A Tour of Go を少し触る
  • MAC環境にAtomでGolangの開発環境を作る

A Tour of Go

基本中の基本?なのか、「Go 入門」などでググるとだいたいの人がこの " A Tour of Go "を読んだ後にxxしました。とゆーてるので、ひとまずここから始めてみることに。

変わっているな、と思った点だけ少しメモ。(そもそも他の言語もそんなに詳しくないので違いはあまりわからないが。。)

  • Stacking defers
    • defer へ渡した関数が複数ある場合、その呼び出しはスタック( stack )されます。 呼び出し元の関数がreturnするとき、 defer へ渡した関数は LIFO(last-in-first-out) の順番で実行されます。
    • 実行はされるが呼び出し順序が少し違うとのこと
  • Pointers
    • 懐かしのポインタが存在。ただ、Cとは違ってポインタ演算はないとのこと。
  • Arrays
    • 配列はあります。
    • var a [10]int
    • こんな感じで、配列数の後ろに型名を宣言
    • そして、配列の要素数は変えられないらしい
  • Slices are like references to arrays
    • sliceというもので配列の一部を表現
    • a[1:4]でa配列の1番目〜3番目までの要素を参照できる。
    • sliceの一部を更新すると、元の配列が更新される!!
func main() {
    names := [4]string{
        "John",
        "Paul",
        "George",
        "Ringo",
    }
    fmt.Println(names)

    a := names[0:2]
    b := names[1:3]
    fmt.Println(a, b)

    b[0] = "XXX"
    fmt.Println(a, b)
    fmt.Println(names)
}

上みたいに書くとこうなる。

[John Paul George Ringo]
[John Paul] [Paul George]
[John XXX] [XXX George]
[John XXX George Ringo]

A Tour of Goはこの後もかなり続きそう。。
性格的に、やりながら覚える派なので、一旦ここまでにして何か作りたいところ。
ということで、開発環境のセットアップを行う。

開発環境のセットアップ for MAC OS

ググるといろいろな環境、IDEなどある模様。
Visual Studio Codeをおすすめしているサイトもあったが、今回はAtomで設定をする。
ここをみると、VSCとAtomで大差はなさそうだったので、最近エディタとして使い始めたAtomになれることも視野に。

私の環境

OS: OS X Yosemite 10.10.5

Homebrew,Atomはインストール済み

やること

ここを参考にいろいろ入れていく。

  • goenvのインストール
  • Goのインストール
  • $GOHOMEの設定
  • 依存パッケージのインストール
  • Atomの関連パッケージインストール
  • 動作確認

開発環境インストール

goenvのインストール

shellで以下を実行

$ brew install goenv

.bash_profileに以下を追記

# for goenv
export PATH="$HOME/.goenv/bin:$PATH"
eval "$(goenv init -)"

Goのインストール

コマンドでインストール可能なGoのバージョンを確認

$ goenv install -l
Available versions:
  1.2.2
  1.3.0
  1.3.1
 (略)
  1.11beta2
  1.11beta3
  1.11rc1
  1.11rc2
  1.11.1
  1.11.2
  1.11.3
  1.11.4
  1.12beta1

ん。現在(2019年5月2日時点)での最新安定版は1.12.4らしいのだが、見当たらない。
ひとまず、1.11.4を入れることに。

$ goenv install 1.11.4
Downloading go1.11.4.darwin-amd64.tar.gz...
-> https://dl.google.com/go/go1.11.4.darwin-amd64.tar.gz
Installing Go Darwin 64bit 1.11.4...
Installed Go Darwin 64bit 1.11.4 to /Users/ken/.goenv/versions/1.11.4
$ goenv global 1.11.4

確認。バージョンがでればOK

$ source ~/.bash_profile
$ go version
go version go1.11.4 darwin/amd64

GOHOMEの設定

Go言語では、ワークスペースというディレクトリ下で開発作業を行ないます。
このディレクトリ下に、ソースコード、外部ライブラリー、コンパイル済み実行ファイルなど、
開発に必要なものから出力まで、全てが格納されます。

とのことなので、設定する。

$ pwd
/Users/ken/Work
$ mkdir go
$ vi ../.bash_profile
# for golang
export GOPATH=$HOME/Work/go
export PATH=$PATH:$GOPATH/bin

source ~/.bash_profileを忘れずに。

依存関係のあるパッケージをインストール

$ go get golang.org/x/tools/cmd/goimports
$ go get github.com/nsf/gocode
$ go get github.com/rogpeppe/godef
$ brew install go-delve/delve/delve

atom関連パッケージをインストール

$ apm install go-plus
$ apm install godef

動作確認

作業用のディレクトリを作る

$ mkdir $GOPATH/src/test

作ったディレクトリに以下の内容でAtomからファイルを開き、test.goを作る

package main

import "fmt"

func main() {
  fmt.Println("Test")
}

ファイルを保存をするとコンパイルが走り、以下のようなメッセージが出る。

?       test    [no test files]

Done

コマンドラインから以下を実行

$ go run test.go
Test

動いた!

以上で完了。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goでhttpサーバを立てる最小限のコード

学習履歴

■はじめに

Go は、フレームワークなしに http サーバーを立てられる様なので、概要を勉強した。

■ Code

とりあえず、コードは、以下の様になる。

main.go
package main

import (
    "fmt"
    "log"
    "net/http"
)

func requestHandler(w http.ResponseWriter, r *http.Request) {
    // /api/something text -> something text を取り出す
    s := r.URL.Path[len("/api/"):]
    fmt.Fprintf(w, "<html><head><title>%s</title></head><body><h1>%s</h1><div>%s</div></body></html>", s, s, "Good Luck!")
}

func main() {
    http.HandleFunc("/api/", requestHandler)
    // nil を指定すると 404 page not found を返してくれる
    // log.Fatal を指定するとエラーが発生したら処理を終了してくれる
    log.Fatal(http.ListenAndServe(":8001", nil))
}

これだけ。

参考

■実行例

「localhost:8001/api/Test」でアクセスする。

スクリーンショット 2019-05-02 18.43.36.png

Good Luck! の部分は、動的に変えられないが、タイトルは、指定した URL によって動的に変わる。
(実行例では、Test を指定したので、Test と表示されている)

ちなみに適当な URL を指定すると 404 page not found になる。

スクリーンショット 2019-05-02 18.46.02.png

■まとめ

普段、Django を触っているからか、短いコード量に感動を覚えた。
(あまりにも短いコードなので、解説を書く気にもならなかった)

HTML の body 部分が静的なので、DB と絡めて動的に変えられる様にしたい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

YouTube の動画にコメントする in Golang

YouTube Data API を使って Golang で動画にコメント(返信)します。

Requirements

  • Golang
    • github.com/joho/godotenv
    • google.golang.org/api/googleapi/transport
    • google.golang.org/api/youtube/v3
  • Google OAuth Client ID
    • Google Cloud Console から作成してください
    • Application typeOther

準備

1. ライブラリのインストール

$ go get github.com/joho/godotenv
$ go get google.golang.org/api/googleapi/transport
$ go get google.golang.org/api/youtube/v3

2. チャンネル作成

Google アカウントでそのまま YouTube を使用している場合は、OAuth Client ID を発行しても認証が通りません。

YouTube の設定からチャンネルを作成する必要があります。

スクリーンショット 2019-04-27 23.24.15.png
スクリーンショット 2019-04-27 23.24.18.png
スクリーンショット 2019-04-27 23.24.21.png

手順

1. コードダウンロード

$ git clone https://github.com/Doarakko/api-challenge
$ cd api-challenge/youtube-data-api

2. CLIENT_IDCLIENT_SECRET を入力

事前に Google Cloud Console 上で作成した認証情報を入れます。

$ mv .env.example .env
CLIENT_ID = ghijk.apps.googleusercontent.com
CLIENT_SECRET = lmnopqr

コードに直書きでも動きますが、間違えて GitHub にあげると面倒なので .env 使います。

3. main.go 修正

func main() {
    err := godotenv.Load("./.env")
    if err != nil {
        log.Fatal("Error loading .env file")
    }
    comment("enter video id", "おもしろい!")
}

Video ID は動画 URL の以下の箇所です。

4. ビルド

$ go build -o main

5. 実行

$ ./main

スクリーンショット 2019-05-02 18.18.07.png

解説

  • comment.go
func comment(videoID string, message string) {
    service := newYoutubeService(newOAuthClient())
    commentThread := &youtube.CommentThread{
        Snippet: &youtube.CommentThreadSnippet{
            VideoId: videoID,
            TopLevelComment: &youtube.Comment{
                Snippet: &youtube.CommentSnippet{
                    TextOriginal: message,
                },
            },
        },
    }
    call := service.CommentThreads.Insert("id,snippet", commentThread)
    _, err := call.Do()
    if err != nil {
        log.Fatalf("%v", err)
    }
    log.Printf("Comment to %v\n", videoID)
}

コメントする際に、コメントの構造体(commentThread)を渡してあげる必要があります。

入れ子になっていてわかりづらいですが、ドキュメント見ながらエディタの補完機能と定義ジャンプ使うと簡単です。

おまけ

ついでにコメントへの返信もやってみます。
コメントと返信とで使用するメソッドが異なるので注意してください。

以下ではいいねの数が 100 以上のコメントに「わろた」と返信します。

1. main.go 修正

func main() {
    err := godotenv.Load("./.env")
    if err != nil {
        log.Fatal("Error loading .env file")
    }

    for _, item := range getComments("enter video id") {
        likeCnt := item.Snippet.TopLevelComment.Snippet.LikeCount

        if likeCnt >= 100 {
            commentID := item.Snippet.TopLevelComment.Id
            reply(commentID, "わろた")
            log.Printf("Reply to %v\n", item.Snippet.TopLevelComment.Snippet.TextDisplay)
        }
    }
}

getComments で引数のビデオのコメントを取得して、いいね数が 100 以上のものに対して reply 関数を実行しています。

返信の場合は返信対象のコメント ID を指定します。

2. ビルド

$ go build -o main

3. 実行

$ ./main

スクリーンショット 2019-05-02 18.04.27.png
スクリーンショット 2019-05-02 18.04.19.png
スクリーンショット 2019-05-02 18.20.00.png

Hints

OAuth Client の作成コードは公式サンプルをそのまま使いました。

作成したトークンをローカルに保存して2回目以降はローカルを参照しています。

cacheFile := tokenCacheFile(config)
token, err := tokenFromFile(cacheFile)
if err != nil {
    token = tokenFromWeb(ctx, config)
    saveToken(cacheFile, token)
} else {
    log.Printf("Using cached token %#v from %q", token, cacheFile)
}

他にも Golang と YouTube Data API 関連で記事を書いているので参考にしてみてください。

おわりに

テストでコメントした際に手動で削除するのが面倒なので、対象の動画で自分のコメントを全削除する関数を書いておけばよかったです。

Reference

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

続・Golangを使って簡単なwebバックエンドを書いた (意訳: Prismaを使ったDBスキーマ管理とGolangにおけるセッションハンドリング)

はじめに

どうも。飛び上がり自殺をしようとしたら一般気象学 第2版補訂版(著: 小倉 義光, 2016)を持った奇妙な関西弁を話す謎の生物に止められたため、今度はドリルでモホロビチッチ不連続面まで掘削し、その過程の地熱と地圧で死ねないか考えている者です。

前回、Golangで簡単なWebバックエンドを書いたの記事では、次の2点の問題について取り上げました。

  • 認証周り (セッション管理)がちょーっと弱い。
  • データベースのマイグレーションがDjangoやRailsのように実用性のあるものではない。

今回はこの2点を頑張って対処してみます。

セッション管理の問題

この問題ついては別途、ライブラリを作りました。?gauth

このライブラリは「ガウス」とか「ゲウス」とか「ギャウス」とか読んであげてください。

gauthはベースとなる技術として、JWTを使っています。JWTについては調べていただくことにして、このライブラリによって、

  • セッション管理
  • ログインが必須なリソースの保護

ができるようになりました。めでたしめでたし

DBの問題

GolangでORMを行う場合、Gormの利用が筆頭に上がるかと思います。が、このライブラリの自動マイグレーションはフィールドの追加とテーブルの追加以外の事ができません。 例えば、gormで次の事をした場合、マイグレーションを自分で書き上げなければなりません。

  • NULL許容だったフィールドをNULL非許容にした
  • フィールドやテーブルをリネームした
  • フィールドを消した
  • すでにあるフィールドにインデックス属性をつけた

Djangoはここら辺が非常によくできていて、相当に特殊な場合を除いて、python manage.py makemigrationsを実行するだけで、これらの問題に対処するマイグレーションファイルをいい感じに書いてくれます。が、Golangではそういったいい感じのDBマイグレーションの生成支援ツールがない。

・・・ よし、作るか

等と普段の僕なら考えるのですが、巷ではSpec-Drivenな開発手法が流行っているようです。Zノーテーション万歳!と言いたいところですが、そこまで高度な形式仕様の記述は必要としていません。欲しいのは何かしらの言語で記述されたデータモデルの仕様をDBのスキーマに落としてくれるツールだ!!しかも実用的なマイグレーションつきで!!

というわけで探したら・・・ ありました!!Prismaが!!

Prismaとは

公式サイトより引用すると、

Prisma replaces traditional ORMs

との事です。つまり、一般的なORMを置き換えることを目的としたツールという事になります。

Prismaの機能

Prismaがもつ機能として、次の機能があります:

  • DBとの調停
  • GraphQLベースのDSLによってモデルの仕様を書くことができる
  • 実用的なマイグレーション (なお2.0ではマイグレーションのバージョニングが追加されるという。)
  • DSLによって記述されたモデルデータをGolangなりTypescriptなりのクライアントとして落とし込める
  • Adminパネル

・・・Djangoが使えない(というか巷ではNodeJSやGolangなどでWebバックエンドを開発することが多いそうですが)場合は間違いなくPrismaはモデル管理ツールの筆頭候補になりそうですね。素晴らしい。

使ってみる

と、いうわけでPrismaを使ってみます。

1. Prismaクライアントのインストール

クライアント自体はNPMに置かれています。と、いうわけで、おもむろにコンソールを開いて次のコマンドでクライアントをインストールします。

npm i -g prisma

2. サーバーのインストール

PrismaのサーバーはDocker化されています。つまり、docker-compose.yml あたりで開発に必要な構成を定義して開発を行うとようにしても良いのですが、今回は単純なコードサンプルだけなので、PostgresPrismaをdockerコンテナとして動かします:

docker-compose.yml
version: '3'

services:
  db:
    image: postgres:alpine
    restart: always
    environment:
      POSTGRES_PASSWORD: go-sample
      POSTGRES_USER: go-sample
  prisma:
    image: prismagraphql/prisma:1.31
    restart: always
    depends_on:
      - db
    ports:
      - "4466:4466"
    environment:
      PRISMA_CONFIG: |
        port: 4466
        databases:
          default:
            connector: postgres
            host: db
            port: 5432
            user: go-sample
            password: go-sample

今回、使用するサーバーのバージョンは1.31ですが、これより新しいバージョンのアプリがリリースされた場合はそれを使用するようにしてください。

Prismaのサーバーを終了するとSIGKILLが発生する件について

頑張ってPrismaPostgresdocker-compose.ymlを書き、さあやっと遊べますよヤッター⭐と思ったのですが、なんとdocker-composeを終了する時にPrismaが終了コードコード137を返すではありませんか!!

go-gql-sample_prisma_1 exited with code 137 <-- アッー!!
db_1       | 2019-04-28 03:24:13.906 UTC [1] LOG:  received smart shutdown request
db_1       | 2019-04-28 03:24:13.907 UTC [1] LOG:  background worker "logical replication launcher" (PID 24) exited with exit code 1
db_1       | 2019-04-28 03:24:13.907 UTC [19] LOG:  shutting down
db_1       | 2019-04-28 03:24:13.915 UTC [1] LOG:  database system is shut down
go-gql-sample_db_1 exited with code 0

この問題、まさに以前Qiitaで記事にしたことのある問題ドンピシャなのです:

と、いうわけでこの問題に対処します。 まず、原因箇所を特定するため、docker-compose psを実行してPrismaの中でどういったプロセスが実行されているのか調べます:

          Name                         Command              State            Ports         
-------------------------------------------------------------------------------------------
go-gql-sample_db_1          docker-entrypoint.sh postgres   Up       5432/tcp              
go-gql-sample_prisma_1      /bin/sh -c /app/start.sh        Up       0.0.0.0:4466->4466/tcp

この出力では、go-gql-sample_prisma_1Prismaのコンテナになります。そして、このコンテナが実行しているコマンドはどうやらシェルで書かれているようですね。というわけでその中身を見てみましょう。

/app/start.sh
#!/bin/bash
set -e
/app/prerun_hook.sh
/app/bin/prisma-local  # <-- これ!

はいビンゴ!つまり/app/bin/prisma-localは新しく作成されたプロセス内で実行されます。(i.e. SIGINTが伝わってこない) そして同様に/app/bin/prisma-localについてもここに書きたいところですが、当該のスクリプトをここに書くのは長過ぎるので、結論のみを書くと、/app/bin/prisma-localはちゃんとexecを使ってサーバーを起動しておりました。

というわけで、/app/start.shの内容を次のように書き換え、新しくDockerイメージを作成します:

/app/start.sh
#!/bin/sh -e
# -*- coding: utf-8 -*-

/app/prerun_hook.sh
exec /app/bin/prisma-local  # execビルトインコマンドはプロセスを置き換える
prismasvr.dockerfile
FROM prismagraphql/prisma:1.31
COPY ./prisma-patch.sh /app/start.sh
CMD [ "/app/start.sh" ]
docker-compose.yml
version: '3'

services:
  db:
    image: postgres:alpine
    restart: always
    environment:
      POSTGRES_PASSWORD: go-sample
      POSTGRES_USER: go-sample
  prisma:
    build:
      context: ./
      dockerfile: prismasvr.dockerfile
    restart: always
    depends_on:
      - db
    ports:
      - "4466:4466"
    environment:
      PRISMA_CONFIG: |
        port: 4466
        databases:
          default:
            connector: postgres
            host: db
            port: 5432
            user: go-sample
            password: go-sample

上記の変更を行った後、docker-compose updocker-compose stopを行って様子を見てみましょう。

go-gql-sample_prisma_1 exited with code 143
db_1         | 2019-04-30 06:42:17.810 UTC [1] LOG:  received smart shutdown request
db_1         | 2019-04-30 06:42:17.813 UTC [1] LOG:  background worker "logical replication launcher" (PID 24) exited with exit code 1
db_1         | 2019-04-30 06:42:17.813 UTC [19] LOG:  shutting down
db_1         | 2019-04-30 06:42:17.835 UTC [1] LOG:  database system is shut down
go-gql-sample_db_1 exited with code 0

と、このように、Prismaを正常に終了させるようにする事ができました。

尚、これはバグなのでGithubにプルリクを出しています。マージされるかどうかは不明。

3. prisma.ymlの作成

Prismaには環境設定ファイルが存在し、これを以下のコマンドで簡単に作成しておきます:

prisma init --endpoint http://localhost:4466

このコマンドを実行すると、prisma.ymldatamodel.prismaの2つのファイルが作成されます。前者が環境設定ファイル、後者がデータモデル定義ファイルとなります。また、データモデルの定義には複数のファイルを指定することができ、そのようにする場合はprisma.ymlを次のように書き換える必要があります。

prisma.yml
endpoint: http://prisma:4466
datamodel:
  - models/user.prisma
  - models/payment.prisma
  - models/etc.prisma

しかし、今回の場合、記述するべきモデルは一つだけなので、デフォルトの設定でも問題ありません。

4. データモデルの定義

さて、Prismaのバグに簡易パッチを当て、ようやくデータモデルの定義に移ることができます。先にも述べたように、Prismaのモデル定義のDSLはGraphQLtypeに各種ディレクティブを付け加えたものになっています。

そして、今回の簡単な認証バックエンドに必要なモデルは一つ。ユーザー名とパスワードのハッシュを格納するUserモデルのみです。これを定義します:

datamodel.prisma
type User {
  id: ID! @unique @id
  username: String! @unique
  password: String!
}

上記コードで独特な点は、@unique@idで、次の効果があります。

  • @unique はSQLで言うところのUNIQUE制約、つまり、「保存されているデータの当該カラムの情報はユニークでなければならない」という制約です。
  • @id はIDで型付けされたフィールドのみ有効な語句で、これが指定される事によって当該のフィールドの値はPrismaによって自動生成され、ユーザがAdminパネルで編集することはできません。

これらのディレクティブに加え、Date型には@createdAt等のディレクティブなども用意されています。詳細についてはヘルプをご覧頂くとして、取り敢えず上記コードでUserモデルを定義することができました。

5. モデルの展開

次に、モデルをPrismaに展開させます。とはいえ、やることは単純に次のコマンドを実行するだけです:

prisma deploy

6. クライアントの作成

次に、バックエンドからPrismaを操作するためのクライアントを作成します。今回はGraphQLベースのバックエンドなので、作成すべきコードは次の2つです:

これらのコードを作成するには、prisma.ymlに少し変更を加えます:

prisma.yml
endpoint: http://prisma:4466
datamodel:
  - models/user.prisma
generate: # ??この項目を追加する
  - generator: go-client
    output: ./backend/prisma
  - generator: graphql-schema
    output: ./backend/schemata/generated.graphql
hooks:
  post-deploy:
    - prisma generate # ? prisma deployした時に自動的にクライアントも更新させる

上記変更を行った後、prisma deploy あるいは、prisma generateを実行すると、./backend/prismaにGo用クライアントが、./backend/schemata/generated.graphqlGraphQL スキーマが生成されます。

7. ゴリゴリ書く

あとはゴリゴリとバックエンドを書くだけです:star:
また、書いたコードはここにあります。ありがとうございました。

終わりに

とりあえず、Prismaの使い方をざっくり書いてみましたが、コードを修正してプルリク送るよりもQiitaに記事をアウトプットするほうが労力がかかるように思えました。独創的な死に方で自殺する前に考えた事をコードに落とし込むAIと考えたことをブログ記事にするAIはよ!!

では。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Golang:Ginkgo】BDDがちょうどいいかもしれない。(実践編その1)

お題

前回の続きで実践編。
ただ、ここでは、本来の「BDD(振る舞い駆動開発)」に準ずることを目的とはしていない。
ある解決したい課題があり、「Ginkgo」というツールがもたらす体裁(BDDテストフレームワーク)が、その解決に使えるのでは?という直感のもと使おうとしているだけなので。

使い方の感覚としては、どちらかというと、「ユースケース駆動開発」と言えるかもしれない。
(ただ、「ユースケース」という言葉とこの記事が提示している説明や具体例にも乖離があるかもしれない。(実際に「ユースケース駆動開発」の経験がないため))
「ユースケース駆動開発」のようにロバストネス分析を行ってといったアプローチも取らない。

【解決したい課題】
機能を実装するスピードが最優先という状況下での品質担保。

<想定するプロジェクトの性質>
・ システム内部のプログラムから外部への接続(クラウドや外部システムのAPI)が多い。
・ システムの構成としてバックエンドはWebAPIの形式をとる。

<課題解決のためのアプローチ>
上記を想定した上で、以下のようなアプローチをとる。
・ 設計を省くことはできない。テストファースト=設計ファーストとして、テストケースは機能実装前に書く。
・ ただし、1テストケースの粒度はTDDで言うそれとは大きく異なり、1ユースケースというくらいのものとする。
(例:「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」)
・ 個別項目に対する境界値チェックやフォーマットのチェックなどの細かいケースは後回しとする。
(その手の仕様は実装中でさえ、要件レベルでころころ変わりうる、ないし、認識・確認漏れが発生するので、まずはブレない(にくい)部分からテストコードにおこす。)

<テストケース作成単位>
先述した通り、1ユースケース相当。
正直なところ、案件の内容やプロジェクトの性質、及び機能間でも粒度にブレが出る恐れは十分ある。
イメージとしては、クリーン・アーキテクチャで言う「Use Cases(Application Business Rules)」に当たり、DDDで言う「アプリケーション・サービス」に当たる。

開発環境

# OS

$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="18.04.2 LTS (Bionic Beaver)"

# Golang

$ go version
go version go1.11.4 linux/amd64

# Ginkgo/Gomega

$ cat go.mod 
module gobdd

require (
    github.com/onsi/ginkgo v1.8.0
    github.com/onsi/gomega v1.5.0
)

実践

前段

ginkgo」の導入とひな型作成は以下を参照。
https://qiita.com/sky0621/items/a0af8f292b516160c8cd#導入

今回対象とするプロジェクトの全ソースについては下記参照。
https://github.com/sky0621/gobdd

1ユースケースの設計・実装

想定するユースケース

想定するユースケースは「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」とする。
まずは、このユースケースをシンプルに表現するテストコードの実装を目指す。

Step.01: Ginkgoでの事前準備

Ginkgoに備わっているテストコードのひな型を作成するコマンドによって『お知らせ』に関するユースケースを表現するためのテストファイルを自動生成。

$ ginkgo generate notice
Generating ginkgo test for Notice in:
  notice_test.go

[notice_test.go]
package gobdd_test

import (
    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"

    . "github.com/sky0621/gobdd"
)

var _ = Describe("Notice", func() {

})

Step.02: ユースケースの説明

まずは、何に関するテストケースなのかを説明するコードを追加。
ここでは、記事冒頭で提示したユースケース例である「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」を適用。

[notice_test.go]
package gobdd_test

import (
    . "github.com/onsi/ginkgo"
)

var _ = Describe("Notice", func() {
    Describe("システム管理者は、表示期間を指定して『お知らせ』を登録することができる。", func() {})
})

では、テスト再実行。

$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556554048
Will run 0 of 0 specs


Ran 0 of 0 Specs in 0.000 seconds
SUCCESS! -- 0 Passed | 0 Failed | 0 Pending | 0 Skipped
PASS

Ginkgo ran 1 suite in 932.41912ms
Test Suite Passed

特にエラーなくテストスイートがPassすることは変わりなし。ただ、テストケース自体カウントされていない。
実際、notice_test.go単体をテストしても、no tests to runとなる。
つまり、テストケースで説明文だけを記載しても、テストケースとしてはカウントされないということか。

$ go test -v notice_test.go 
testing: warning: no tests to run
PASS
ok      command-line-arguments  0.003s [no tests to run]

Step.03: ユースケースのコンテキスト

以下のように説明のみ記述していたテストケースを

[notice_test.go]
var _ = Describe("Notice", func() {
    Describe("システム管理者は、表示期間を指定して『お知らせ』を登録することができる。", func() {})
})

どういう条件でどうなるべきかを明示できるよう修正。

[notice_test.go]
var _ = Describe("Notice", func() {
    Describe("『お知らせ』の登録", func() {
        Context("主体が「システム管理者」である場合", func() {
            It("表示期間を指定して『お知らせ』を登録できる。", func() {
                // FIXME: ここに具体的な検証ロジックを簡潔に記載する。
            })
        })
    })
})

Step.04: コンパイルエラーケースから実装

TDDの流儀にならうなら、まずはビルドすら通らないテストケースを実装する。

実装内容(テストコードのみ)

[notice_test.go]
package gobdd_test

import (
    "gobdd/usecase"
    usecasemodel "gobdd/usecase/model"

    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"
)

var _ = Describe("Notice", func() {
    Describe("『お知らせ』の登録", func() {
        Context("主体が「システム管理者」である場合", func() {
            var (
                // 登録対象の『お知らせ』情報
                noticeForCreateParam *usecasemodel.Notice
            )
            It("表示期間を指定して『お知らせ』を登録できる。", func() {
                id, err := usecase.NewNotice().Create(noticeForCreateParam)
                Expect(id).To(Equal("DUMMY"))
                Expect(err).To(BeNil())
            })
        })
    })
})

Expectという関数がGomegaというテスト用ライブラリのアサーション用途として提供されているので利用。
Expect関数の引数に対してテスト対象関数ないしメソッドの実行結果を渡し、Toという関数内で期待値とのマッチングを行う。

さて、この(コンパイルが通らない)テストコードでは何をしているのか?
usecase.NewNotice().Create(noticeForCreateParam)により、ユースケースパッケージの『お知らせ』ユースケースを担う構造体(usecase.Notice)を用いて『お知らせ』情報を登録する。
パラメータは、usecasemodel.Notice(ユースケースモデルパッケージのNotice構造体)とする。
構造体の定義を持つことで、同パッケージにて『お知らせ』情報の詳細は隠蔽する。
また、Createメソッドは永続化した情報に付与されたID(情報をユニークに特定するもの)とエラー有無を返却するため、その返却値と事前に定義した期待値をマッチング。

テスト結果

テスト対象ソースが未実装なので、当然、テストは失敗する。

$ ginkgo
Failed to compile gobdd:

# gobdd
notice_test.go:4:2: unknown import path "gobdd/usecase": cannot find module providing package gobdd/usecase
FAIL    gobdd [setup failed]

Ginkgo ran 1 suite in 670.28508ms
Test Suite Failed

考察

テスト対象ソースを実装する前にテストはコードを実装、そして、ユースケースを練ることが重要。
大概は、テストファーストと言っても、テストコードを書く時点で、ある程度の実装の算段はついている。
たとえば、以下のようなイメージ。(このへんの想定は人によって当然異なる。その人の参画プロジェクトないし技術背景に大きく依存するので。)

  • 使うWebフレームワークは「Echo
  • O/Rマッパーは「Gorm
  • Logユーティリティは「Zap
  • クラウド(GCP)上にデプロイ
  • デプロイ先サービスは「Google App Engine
  • 永続化は「Cloud SQL」を利用
  • バッチ処理で生成したCSVファイル等は「Cloud Storage」に格納

上記の事例と大きく異なろうとも、ある程度の想定技術、環境、ミドルウェアの選定は済んでいたりする。
そうなると、永続化はRDBを用いる、そして使うサービスはこれと決まった時点で、テストコードを書こうにも、そうした選定技術の詳細に依存した作りになる。
”ユースケース”をベースとした”テストファースト”な設計・実装を目指すはずが、以下のような、ユースケースと直接関係のない事柄から実装を始めてしまうことになりがち。
『RDBなのでリクエスト受ける前にコネクション張って、ある程度プールしておく。』
『プールしたコネクション情報は各HTTPリクエストで使いまわせるよう、HTTPリクエスト受信時に利用できる形で保持しておく。』
『Echoフレームワークを使うので、Echoコンテキストに情報をいろいろ積めば、各HTTPリクエスト処理で効果的に使える。』
『ログをGCPのStackdriverLoggingにログレベル別に表示させるためにZapをラップしたロガーを作っておこう。』
etc...

こうした技術詳細に囚われないようにするために、まずは、純粋に要件をユースケースという形に落とし込む。
それをテストコードという形で表現する。ただし、この過程でテスト対象コードのことも当然考える必要に迫られる。
それに対し、この時点では、極力抽象化した構造での実装を試みる。
たとえば、「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」というユースケースに対し、(なまじ開発者としての経験があるゆえ、また、既にテストコードを書き始めているがゆえに)以下のように、すぐに実装詳細の検討に入ろうとしてしまう。
『登録するのだからユニークに特定するIDが必要だ。』
『表示期間は年月日のフォーマットか? タイムゾーン考慮は必要か? 面倒だからUnixタイムスタンプで持つか。』
『システム管理者というのは”役割(ロール)”と捉えるべきか。他にはロールはあるか。任意で増減はあるか。ロールIDというのを定義して持たせればよいか。』

これを、ぐっと押しとどめる。とにかく、ただユースケースのことだけを考える。
『そもそもこのユースケースは妥当か?』
『お知らせを登録する頻度はどれくらいあるのか?』
『他の機能と比べて、優先度の高い、ないし、必須の機能なのか?』
『システム管理者のみとする要件に変更が入る可能性はどれくらいあるか?』
『お知らせ1件1件をユニークに特定できる必要はあるか?(単純に、存在するお知らせが新しいものから順に出ていればいいだけとは言えないか)』
『高度なフィルタリングが必要になり得るか?』
『お知らせ毎に見せる相手を分ける必要はあるか?』

こうした掘り下げを行った結果として、以下のような要件に変わるかもしれない。
「お知らせは、権限のある運用担当者がCSV形式で最新化し、Cloud Storageにアップロードする。プログラム側ではCSVの更新を検知したら、画面の表示を最新化する。」
RDBに永続化する方式とどちらが楽(現在、そして未来)かは一概には言い切れない。
ただ、最初からRDBありきで、他の機能にも高度な検索フィルタ機能が付いているがゆえに、顧客から「他の画面と同じように保存してるなら、使うかわからないけど『お知らせ』にも付けといて。」と言われるのは避けたい。
”こういう理由で必要だから”実装するようにしたい。(ないしは、”確実とは言えないが、こういう仮説で、求められている機能だと考えられるから”実装するようにしたい。)

Step.05: コンパイルを通す

ユースケースについては検討し、いったん下記のままで進めることとする。
「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」

実装内容(プロダクトコード)

テストコードで登場させ、未実装だったプロダクトコードを実装する。
まず、下記の通り、 usecaseパッケージ、及び、usecase/model => usecasemodelパッケージを実装する。

$ tree
.
├── gobdd_suite_test.go
├── go.mod
├── go.sum
├── notice_test.go
└── usecase
    ├── model
    │   └── notice.go   <- ★これ
    └── notice.go       <- ★これ

新規追加したプロダクトコードについてはコンパイル可能な最低限の実装を行っておく。

[usecase/notice.go]
package usecase

import (
    usecasemodel "gobdd/usecase/model"
)

func NewNotice() Notice {
    return &notice{}
}

type Notice interface {
    Create(noticeModel *usecasemodel.Notice) (string, error)
}

type notice struct {
}

func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
    // FIXME: ユースケースに合わせて実装
    return "", nil
}
[usecase/model/notice.go]
package usecasemodel

// 入力値用『お知らせ』定義
type Notice struct {
    Title       string // お知らせのタイトル
    Text        string // お知らせの文章(現時点はテキストのみサポート)
    PublishFrom int    // お知らせの掲載開始日時
    PublishTo   int    // お知らせの掲載終了日時
}

テスト結果

この状態でテスト実行すると、こうなる。テスト失敗という点は変わりないが、テスト対象のテストケースが存在する状態での初実行となる。

$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556771790
Will run 1 of 1 specs

• Failure [0.000 seconds]
Notice
/home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:11
  『お知らせ』の登録
  /home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:12
    主体が「システム管理者」である場合
    /home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:13
      表示期間を指定して『お知らせ』を登録できる。 [It]
      /home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:18

      Expected
          <string>: 
      to equal
          <string>: DUMMY

      /home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:20
------------------------------


Summarizing 1 Failure:

[Fail] Notice 『お知らせ』の登録 主体が「システム管理者」である場合 [It] 表示期間を指定して『お知らせ』を登録できる。 
/home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:20

Ran 1 of 1 Specs in 0.001 seconds
FAIL! -- 0 Passed | 1 Failed | 0 Pending | 0 Skipped
--- FAIL: TestGobdd (0.00s)
FAIL

Ginkgo ran 1 suite in 881.789325ms
Test Suite Failed

現状はCreateメソッドが常に空文字とnilを返す実装になっており、期待値として現状設定している内容と異なるためにテストが失敗する。

Step.06: ユースケースを練る

テストコードにて明確にパラメータへセットする値を定義する。
そして、期待値としても求める形を明確に定義する。

上記にて明確に(とは言え、『お知らせ』が持つ属性としても現時点で顧客からヒアリングできている情報のみを使った形となる)定義したユースケースに合致するように実装する。
ただし、今の時点では、明確に決めたユースケースに必ず合致するように実装するだけ。
やはり、永続化のことなどは考えない。

実装内容(テストコード)

[notice_test.go]
package gobdd_test

import (
    "gobdd/usecase"
    usecasemodel "gobdd/usecase/model"

    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"
)

var _ = Describe("Notice", func() {
    Describe("『お知らせ』の登録", func() {
        Context("主体が「システム管理者」である場合", func() {
            var (
                // 登録対象の『お知らせ』情報
                noticeForCreateParam *usecasemodel.Notice
            )
+           BeforeEach(func() {
+               noticeForCreateParam = &usecasemodel.Notice{
+                   Title:       "お知らせ1",
+                   Text:        "これはお知らせ1です。",
+                   PublishFrom: 1556636400,
+                   PublishTo:   1557327599,
+               }
+           })
            It("表示期間を指定して『お知らせ』を登録できる。", func() {
                id, err := usecase.NewNotice().Create(noticeForCreateParam)
                Expect(id).To(Equal("ef5198df-5c04-42ba-9fbe-2beb2794468a"))
                Expect(err).To(BeNil())
            })
        })
    })
})

テスト対象のCreateメソッドに渡すパラメータに具体的な値をセット。

実装内容(プロダクトコード)

次は、ユースケース実行ロジックに手を入れる。
ただし、あくまでテストケースを成功させるためだけの修正のため、テストコードが期待する値を返すだけの実装にする。

[usecase/notice.go]
package usecase

 〜〜 省略 〜〜

func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
*   // FIXME: 【テストコードを通すための実装】ここでは渡された『お知らせ』情報と固定のIDを返却する。
*   return "ef5198df-5c04-42ba-9fbe-2beb2794468a", nil
}

テスト結果

$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556771998
Will run 1 of 1 specs

•
Ran 1 of 1 Specs in 0.000 seconds
SUCCESS! -- 1 Passed | 0 Failed | 0 Pending | 0 Skipped
PASS

Ginkgo ran 1 suite in 929.463711ms
Test Suite Passed

ちゃんとテストケース1件が認識されて、1件成功している。
実装自体はもちろん永続化もされていないし、IDも固定値を返しているので完成とは到底言えないけど、ひとまずユースケースを満たすコードを(当然修正が必要なので「FIXMEコメント」付きで)書いてテストをPASSしたということで、まずは一安心。

考察

「コンパイルエラー」→「テスト失敗(RED)」→「テスト成功(GREEN)」の変遷を辿ることで設計・実装・テストのリズムが出来るというのがTDDのウリのひとつだと思う。
通常の感覚であれば「固定のIDと渡された構造体を返すだけでテストOKなんて、ただのバグだろう!」と思うところを、ぐっと飲み込む必要がある。
現時点では、永続化など考えていないということもあり、このテストコード並びにプロダクトコードは、(基本的に)いつ、どの環境で動かしても、Goの同じVersionのランタイムさえあれば実行できるはず。
ただ、こういういかにもテストしやすいコードに関しては、正直なところあれこれ検討することもなく、さらっとテストコード書けてしまうもの。
問題は、ここから。

今回のユースケースに関して、検討の結果、登録リクエストされた『お知らせ』情報はRDBで永続化することにした。
実サービス上ではGCPの「Cloud SQL」を想定し、タイプはMySQLなので、例えばGCPへのデプロイ前にローカル環境で動作確認をする場合はローカル環境上にMySQLをインストール(最近はDockerコンテナ使う方が多いのかな)してデータベースを構築し、プログラムからはそこに接続して永続化を行うようにすると思われる。
さて、永続化を考え、ミドルウェアを使うことを考えた時点で、一気に技術の詳細を意識せざるを得なくなった。
この時点で、(永続化を行うミドルウェアに接続するプログラムを完成後に)いつでもどの環境でもテストコードが流せるというのは無理がある。

この事態に対して取るアプローチとして経験があるのは以下の2つ。
①ローカル環境だろうとCI環境だろうと、本番と(極力)同じミドルウェアを適宜インストールしてテストコードを流す。
②アプリ外と接続する部分は全てスタブ化してテストコードを流す。
当然のことながら両者それぞれにメリット・デメリットはあり、どちらを採用するかは現場の方針によって決まっていた印象。
(テストに関する大ベテランがいた現場ではないため、どの現場も両者どちらにすべきか、割と揺れ動いていた記憶あり。(まあ、主に自分が・・・。))
思いっきり粗くメリ・デメを書く。

No 内容 メリット デメリット
環境毎にミドルウェアを用意 テストと本番とで結果差異が少ない(*1) べき等性担保が条件付きになる(*2)
ミドルウェア接続をスタブ化 純粋にロジックのテストが可能 テストOKでも本番NGケースが発生する(*3)

*1: 本番DBだけに存在するレコードの影響やミドルウェアのマイナーバージョンの違いで結果に差異が生じる可能性は0ではない。
*2: ミドルウェアとの接続は通信を介するため、成功テストケースが必ず成功するとは限らない。
*3: テストケースは、あくまで「こういう結果をミドルウェアは返すはず」という想定でスタブ化するため、本番ミドルウェアの挙動が想定外となってエラーが起きる可能性はある。

TDDを扱いはじめの頃は、ユニットテストなので外部との(通信を介する)接続部分は全てスタブ化して、純粋に単体機能の挙動をテストすべきと考えていた。
ただ、昨今のクラウドに載せる前提のアプリの場合、単体機能といえど、重要なロジックのIN/OUT、及び、その結果に応じたロジックに至るまで、クラウドサービスとの通信の結果(そして、それは各サービスの技術特性が大きく関わる)が大きく作用するようになっており、そこをスタブ化するとロジックの大部分がテストの意味を持たなくなる機能まで出てくる。
こうなってくると、テストコードにおいて、頑なに”ユニット”であること、また、べき等であることにこだわるよりも、極力本番と同じサービス(の別インスタンス)に(テストコードからも)接続する方が意味のあるテストになるのではないか。
そのような考えから、
スタブは用いずテストコードからも外部サービスとの接続前提としたことがあった。
その結果、外部サービスがRDBぐらいであればおそらく問題なかったが、キャッシュはRedis、ファイルのアップロード・ダウンロードにストレージサービス(AWSで言うS3、GCPで言うCloud Storage)、機能によっては別のWebAPIサービスから情報を取得といったように”外部”が増えてくると、テストコードの実行が必要な環境毎にそれぞれの外部サービスの(テスト用)インスタンスを用意する必要に迫られ、そのあたりの情報の管理コストが(メリットを上回った?と思えるほど)肥大化した。

結局、今の考えとしては、以下に落ち着いている。
あまり細かい(技術詳細に依存する)部分までテストコードにおこすのではなく、業務用件の本質部分というかエッセンスというか、今回題材としている”ユースケース”といった部分を仕様としてテストケースにおこす。
あとは、実装詳細が”外部サービス”とのやりとりに関する部分であれば、基本的なパターンのみスタブを設け、それ以外のケース(特に”外部サービス”固有の癖のある部分)は結合テスト(※できれば、その前段として”単一機能内の外部サービスとの結合テスト”という名目のフェーズを設けたい。)に委ねる。

Step.07: 永続化ロジックの実装(インターフェース呼び出し部分)

登録リクエストを受けた『お知らせ』情報をRDB(Cloud SQL)に永続化するコードを書く。
具体的には下記を”実際に永続化する”実装に変える。

[usecase/notice.go]
func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
    // FIXME: 【テストコードを通すための実装】ここでは渡された『お知らせ』情報と固定のIDを返却する。
    return "ef5198df-5c04-42ba-9fbe-2beb2794468a", nil
}

事前検討

Step.06 の考察で”ミドルウェアとの接続ロジックはスタブで基本的なパターンをテストする”とした。
スタブを実装するには”スタブでない実装”も当然必要になるわけで、必然的に「お知らせ情報を永続化する」機能に関して、呼び出し状況に応じて詳細な実装を切り替えられる仕組みを実装することになる。
Goにはインターフェースがあるので、まずはインターフェースを定義し、その後、本来のプロダクトコード(実際にRDBに接続するロジック)とテストコード(スタブ)のそれぞれを実装する。

ソース追加後ツリービュー

$ tree
.
+ ├── domain
+ │   ├── model
+ │   │   └── notice.go
+ │   └── notice.go
  ├── gobdd_suite_test.go
  ├── go.mod
  ├── go.sum
* ├── notice_test.go
  └── usecase
      ├── model
      │   └── notice.go
*     └── notice.go

これまでのユースケース層のパッケージから実装の詳細を呼び出すに際し、上述の通り、まずはインターフェースを定義する。
(パッケージは「domain」とする。)
そして、ユースケース層からそのインターフェースを呼び出すコードを書く。

実装内容(ドメイン層のインターフェース)

[domain/notice.go]
package domain

import (
    domainmodel "gobdd/domain/model"
)

type Notice interface {
    Create(noticeModel *domainmodel.Notice) (string, error)
}
[domain/model/notice.go]
package domainmodel

type Notice struct {
    ID          string
    Title       string
    Text        string
    PublishFrom int
    PublishTo   int
    CreatedAt   int
    UpdatedAt   int
}

実装内容(ユースケース層)

[usecase/notice.go]
package usecase

import (
    "gobdd/domain"
    domainmodel "gobdd/domain/model"
    usecasemodel "gobdd/usecase/model"

    "github.com/google/uuid"
)

* func NewNotice(noticeDomain domain.Notice) Notice {
*   return &notice{noticeDomain: noticeDomain}
}

type Notice interface {
    Create(noticeModel *usecasemodel.Notice) (string, error)
}

type notice struct {
*   noticeDomain domain.Notice
}

func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
*   // ドメインモデルへの変換(ユースケース層独自の構造からドメイン層独自の構造への変換(例:日時の持ち方や「姓」と「名」別持ちから「姓名」等))
+   domainModel := &domainmodel.Notice{
+       ID:          uuid.New().String(),
+       Title:       noticeModel.Title,
+       Text:        noticeModel.Text,
+       PublishFrom: noticeModel.PublishFrom,
+       PublishTo:   noticeModel.PublishTo,
+   }
*   return n.noticeDomain.Create(domainModel)
}

「お知らせ情報を永続化する」機能の具体的な処理はドメイン層に委譲させた。
ドメイン層のロジックを呼び出すために、NewNotice関数を改良して、具体的なドメイン層実装ロジックを受け取れるようにしておく。
こうすることで、実際にどこに接続(ローカルなのかクラウドなのか外部APIなのか)するかを意識せずにユースケースをテストすることができる。
外部に依存する部分はNewNotice関数に渡すドメインロジックの構造体が担っているので、スタブ用の構造体を渡すことでどのようにでも制御できる。
(正直なところ、これだけでは呼び出すドメイン層のロジックがロジックの全てになるので、「じゃあ、テストする意味ないのでは?」と思われてしまう。前段にバリデーションや(複数のドメインロジックを呼び出す想定で)トランザクション制御などが入ると有用性が感じられると思うが、いったんこのままで。)

ツリー構造ふたたび

ドメイン層にはインターフェースしかないので、当然具体的な実装ロジックが必要。
それらは、実プロダクト用としてはもちろん、テスト時のスタブ用、または、ローカル環境で動かす時専用だったりと(同じインターフェースを持つ)さまざまな具象ロジックが想定される。
以下の通り、adapterというパッケージを作り、今回で言うと”永続化”をどう実装するかのパターンに応じてサブパッケージを作ることにした。

このあたりのパッケージの切り方、Goファイル名の付け方には、アプリの規模やサービスの特性等さまざまな要因から最適(でなくても、よりベター)な切り口を見つける必要がある。
どんなアプリかにより、ある程度のパターンやプラクティスは見つけられると思うが、すべてに通用するものを見つけるのは難しいと思われる。(今ベストと思われるものも、時の洗礼を受けた結果どうなるかはわからない。)

$ tree
.
+ ├── adapter
+ │   └── gateway
+ │       ├── gcp
+ │       │   └── notice.go
+ │       ├── local
+ │       │   └── notice.go
+ │       └── test
+ │           └── notice.go
  ├── domain
  │   ├── model
  │   │   └── notice.go
  │   └── notice.go
 〜〜 省略 〜〜

実装内容(domainパッケージのインターフェースを実装するテスト用のスタブ)

実際に永続化を行うのではなく、テストがしやすいように期待値をあらかじめセットすることができる作りにする。

[adapter/gateway/test/notice.go]
package testgateway

import domainmodel "gobdd/domain/model"

type NoticeImpl struct {
    ExpectID    string
    ExpectError error
}

func (n *NoticeImpl) Create(noticeModel *domainmodel.Notice) (string, error) {
    // 事前にセットされた期待値を返すだけ
    return n.ExpectID, n.ExpectError
}

実装内容(テストコード)

[notice_test.go]
package gobdd_test

import (
    testgateway "gobdd/adapter/gateway/test"
    "gobdd/domain"
    "gobdd/usecase"
    usecasemodel "gobdd/usecase/model"

    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"
)

var _ = Describe("Notice", func() {
    Describe("『お知らせ』の登録", func() {
        Context("主体が「システム管理者」である場合", func() {
+           const (
+               ExpectID = "ef5198df-5c04-42ba-9fbe-2beb2794468a"
+           )
            var (
+               // 『お知らせ』情報を登録するロジック
+               noticeDomain domain.Notice
                // 登録対象の『お知らせ』情報
                noticeForCreateParam *usecasemodel.Notice
            )
            BeforeEach(func() {
+               // 期待値をセットできるテスト用のスタブドメインロジックを使うことで、外部サービス接続ロジックを回避したテストが可能
+               noticeDomain = &testgateway.NoticeImpl{
+                   ExpectID:    ExpectID,
+                   ExpectError: nil,
+               }
                noticeForCreateParam = &usecasemodel.Notice{
                    Title:       "お知らせ1",
                    Text:        "これはお知らせ1です。",
                    PublishFrom: 1556636400,
                    PublishTo:   1557327599,
                }
            })
            It("表示期間を指定して『お知らせ』を登録できる。", func() {
                id, err := usecase.NewNotice(noticeDomain).Create(noticeForCreateParam)
*               Expect(id).To(Equal(ExpectID))
                Expect(err).To(BeNil())
            })
        })
    })
})

テスト結果

期待値通りにセットしたドメイン層のロジックを使っているのだから当然ではあるが、テストは成功する。

$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556778608
Will run 1 of 1 specs

•
Ran 1 of 1 Specs in 0.000 seconds
SUCCESS! -- 1 Passed | 0 Failed | 0 Pending | 0 Skipped
PASS

Ginkgo ran 1 suite in 1.000235799s
Test Suite Passed

考察

レイヤーの切り方やパッケージ名についてはクリーン・アーキテクチャヘキサゴナルアーキテクチャを意識している。

今回は、domainパッケージにインターフェースを定義して、その具体的な実装をadapter/gateway/xxxパッケージに持たせた。
今回くらいのアプリの規模感であればこの切り方でも良いと思うが、もう少し規模が大きい(ないし大きくなる)ことが想定される場合は、domainパッケージ下をもう1段階掘り下げた方がいい。
戦術的な要素だけ取り入れるのはよくないとは言われているものの、DDDを参考にするなら以下のようなイメージ。
以下の中の「repository」部分が外部サービスとの接続ロジックに相当するので、そこをインターフェースにして切り替えるイメージ。
※その他のパッケージについてはDDDの説明になるので説明は省略。以下を参照されたし。
https://codezine.jp/article/detail/9546

  └── domain
      ├── aggregate
      │   └── notice.go
      ├── entity
      │   └── notice.go
      ├── factory
      │   └── notice.go
      ├── repository
      │   └── notice.go
      ├── service
      │   └── notice.go
      └── valueobject
          └── xxxx.go

まとめ

Ginkgoをツールとして使ったテストファースト開発の試行について、ここまでで、外部依存するケースのインターフェースを用いたロジック切り替えを実現。いったんの区切りまでは到達したのと、だいぶ長くなってきたので、今回はここまで。
ただし、実際に外部依存するロジックを実装する側については未実装なので、次回はそこを実装。
このあたりは、いわゆるDI(Dependency Injection)が必要になる世界。
以下で書いたGoogleのwireを利用してみようと思う。
go1.11+google/wireによるDependency Injection

今時点のソース全量は↓
https://github.com/sky0621/gobdd/tree/19b34328101f6ba70190f8dde506efe258fa4e5f

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Golang:Ginkgo】BDDがちょうどいいかもしれない。(実践編)

お題

前回の続きで実践編。
ただ、ここでは、本来の「BDD(振る舞い駆動開発)」に準ずることを目的とはしていない。
ある解決したい課題があり、「Ginkgo」というツールがもたらす体裁(BDDテストフレームワーク)が、その解決に使えるのでは?という直感のもと使おうとしているだけなので。

使い方の感覚としては、どちらかというと、「ユースケース駆動開発」と言えるかもしれない。
(ただ、「ユースケース」という言葉とこの記事が提示している説明や具体例にも乖離があるかもしれない。(実際に「ユースケース駆動開発」の経験がないため))
「ユースケース駆動開発」のようにロバストネス分析を行ってといったアプローチも取らない。

【解決したい課題】
機能を実装するスピードが最優先という状況下での品質担保。

<想定するプロジェクトの性質>
・ システム内部のプログラムから外部への接続(クラウドや外部システムのAPI)が多い。
・ システムの構成としてバックエンドはWebAPIの形式をとる。

<課題解決のためのアプローチ>
上記を想定した上で、以下のようなアプローチをとる。
・ 設計を省くことはできない。テストファースト=設計ファーストとして、テストケースは機能実装前に書く。
・ ただし、1テストケースの粒度はTDDで言うそれとは大きく異なり、1ユースケースというくらいのものとする。
(例:「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」)
・ 個別項目に対する境界値チェックやフォーマットのチェックなどの細かいケースは後回しとする。
(その手の仕様は実装中でさえ、要件レベルでころころ変わりうる、ないし、認識・確認漏れが発生するので、まずはブレない(にくい)部分からテストコードにおこす。)

<テストケース作成単位>
先述した通り、1ユースケース相当。
正直なところ、案件の内容やプロジェクトの性質、及び機能間でも粒度にブレが出る恐れは十分ある。
イメージとしては、クリーン・アーキテクチャで言う「Use Cases(Application Business Rules)」に当たり、DDDで言う「アプリケーション・サービス」に当たる。

開発環境

# OS

$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="18.04.2 LTS (Bionic Beaver)"

# Golang

$ go version
go version go1.11.4 linux/amd64

# Ginkgo/Gomega

$ cat go.mod 
module gobdd

require (
    github.com/onsi/ginkgo v1.8.0
    github.com/onsi/gomega v1.5.0
)

実践

前段

ginkgo」の導入とひな型作成は以下を参照。
https://qiita.com/sky0621/items/a0af8f292b516160c8cd#導入

今回対象とするプロジェクトの全ソースについては下記参照。
https://github.com/sky0621/gobdd/tree/19b34328101f6ba70190f8dde506efe258fa4e5f

1ユースケースの設計・実装

想定するユースケース

想定するユースケースは「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」とする。
まずは、このユースケースをシンプルに表現するテストコードの実装を目指す。

Step.01: Ginkgoでの事前準備

Ginkgoに備わっているテストコードのひな型を作成するコマンドによって『お知らせ』に関するユースケースを表現するためのテストファイルを自動生成。

$ ginkgo generate notice
Generating ginkgo test for Notice in:
  notice_test.go

[notice_test.go]
package gobdd_test

import (
    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"

    . "github.com/sky0621/gobdd"
)

var _ = Describe("Notice", func() {

})

Step.02: ユースケースの説明

まずは、何に関するテストケースなのかを説明するコードを追加。
ここでは、記事冒頭で提示したユースケース例である「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」を適用。

[notice_test.go]
package gobdd_test

import (
    . "github.com/onsi/ginkgo"
)

var _ = Describe("Notice", func() {
    Describe("システム管理者は、表示期間を指定して『お知らせ』を登録することができる。", func() {})
})

では、テスト再実行。

$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556554048
Will run 0 of 0 specs


Ran 0 of 0 Specs in 0.000 seconds
SUCCESS! -- 0 Passed | 0 Failed | 0 Pending | 0 Skipped
PASS

Ginkgo ran 1 suite in 932.41912ms
Test Suite Passed

特にエラーなくテストスイートがPassすることは変わりなし。ただ、テストケース自体カウントされていない。
実際、notice_test.go単体をテストしても、no tests to runとなる。
つまり、テストケースで説明文だけを記載しても、テストケースとしてはカウントされないということか。

$ go test -v notice_test.go 
testing: warning: no tests to run
PASS
ok      command-line-arguments  0.003s [no tests to run]

Step.03: ユースケースのコンテキスト

以下のように説明のみ記述していたテストケースを

[notice_test.go]
var _ = Describe("Notice", func() {
    Describe("システム管理者は、表示期間を指定して『お知らせ』を登録することができる。", func() {})
})

どういう条件でどうなるべきかを明示できるよう修正。

[notice_test.go]
var _ = Describe("Notice", func() {
    Describe("『お知らせ』の登録", func() {
        Context("主体が「システム管理者」である場合", func() {
            It("表示期間を指定して『お知らせ』を登録できる。", func() {
                // FIXME: ここに具体的な検証ロジックを簡潔に記載する。
            })
        })
    })
})

Step.04: コンパイルエラーケースから実装

TDDの流儀にならうなら、まずはビルドすら通らないテストケースを実装する。

実装内容(テストコードのみ)

[notice_test.go]
package gobdd_test

import (
    "gobdd/usecase"
    usecasemodel "gobdd/usecase/model"

    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"
)

var _ = Describe("Notice", func() {
    Describe("『お知らせ』の登録", func() {
        Context("主体が「システム管理者」である場合", func() {
            var (
                // 登録対象の『お知らせ』情報
                noticeForCreateParam *usecasemodel.Notice
            )
            It("表示期間を指定して『お知らせ』を登録できる。", func() {
                id, err := usecase.NewNotice().Create(noticeForCreateParam)
                Expect(id).To(Equal("DUMMY"))
                Expect(err).To(BeNil())
            })
        })
    })
})

Expectという関数がGomegaというテスト用ライブラリのアサーション用途として提供されているので利用。
Expect関数の引数に対してテスト対象関数ないしメソッドの実行結果を渡し、Toという関数内で期待値とのマッチングを行う。

さて、この(コンパイルが通らない)テストコードでは何をしているのか?
usecase.NewNotice().Create(noticeForCreateParam)により、ユースケースパッケージの『お知らせ』ユースケースを担う構造体(usecase.Notice)を用いて『お知らせ』情報を登録する。
パラメータは、usecasemodel.Notice(ユースケースモデルパッケージのNotice構造体)とする。
構造体の定義を持つことで、同パッケージにて『お知らせ』情報の詳細は隠蔽する。
また、Createメソッドは永続化した情報に付与されたID(情報をユニークに特定するもの)とエラー有無を返却するため、その返却値と事前に定義した期待値をマッチング。

テスト結果

テスト対象ソースが未実装なので、当然、テストは失敗する。

$ ginkgo
Failed to compile gobdd:

# gobdd
notice_test.go:4:2: unknown import path "gobdd/usecase": cannot find module providing package gobdd/usecase
FAIL    gobdd [setup failed]

Ginkgo ran 1 suite in 670.28508ms
Test Suite Failed

考察

テスト対象ソースを実装する前にテストはコードを実装、そして、ユースケースを練ることが重要。
大概は、テストファーストと言っても、テストコードを書く時点で、ある程度の実装の算段はついている。
たとえば、以下のようなイメージ。(このへんの想定は人によって当然異なる。その人の参画プロジェクトないし技術背景に大きく依存するので。)

  • 使うWebフレームワークは「Echo
  • O/Rマッパーは「Gorm
  • Logユーティリティは「Zap
  • クラウド(GCP)上にデプロイ
  • デプロイ先サービスは「Google App Engine
  • 永続化は「Cloud SQL」を利用
  • バッチ処理で生成したCSVファイル等は「Cloud Storage」に格納

上記の事例と大きく異なろうとも、ある程度の想定技術、環境、ミドルウェアの選定は済んでいたりする。
そうなると、永続化はRDBを用いる、そして使うサービスはこれと決まった時点で、テストコードを書こうにも、そうした選定技術の詳細に依存した作りになる。
”ユースケース”をベースとした”テストファースト”な設計・実装を目指すはずが、以下のような、ユースケースと直接関係のない事柄から実装を始めてしまうことになりがち。
『RDBなのでリクエスト受ける前にコネクション張って、ある程度プールしておく。』
『プールしたコネクション情報は各HTTPリクエストで使いまわせるよう、HTTPリクエスト受信時に利用できる形で保持しておく。』
『Echoフレームワークを使うので、Echoコンテキストに情報をいろいろ積めば、各HTTPリクエスト処理で効果的に使える。』
『ログをGCPのStackdriverLoggingにログレベル別に表示させるためにZapをラップしたロガーを作っておこう。』
etc...

こうした技術詳細に囚われないようにするために、まずは、純粋に要件をユースケースという形に落とし込む。
それをテストコードという形で表現する。ただし、この過程でテスト対象コードのことも当然考える必要に迫られる。
それに対し、この時点では、極力抽象化した構造での実装を試みる。
たとえば、「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」というユースケースに対し、(なまじ開発者としての経験があるゆえ、また、既にテストコードを書き始めているがゆえに)以下のように、すぐに実装詳細の検討に入ろうとしてしまう。
『登録するのだからユニークに特定するIDが必要だ。』
『表示期間は年月日のフォーマットか? タイムゾーン考慮は必要か? 面倒だからUnixタイムスタンプで持つか。』
『システム管理者というのは”役割(ロール)”と捉えるべきか。他にはロールはあるか。任意で増減はあるか。ロールIDというのを定義して持たせればよいか。』

これを、ぐっと押しとどめる。とにかく、ただユースケースのことだけを考える。
『そもそもこのユースケースは妥当か?』
『お知らせを登録する頻度はどれくらいあるのか?』
『他の機能と比べて、優先度の高い、ないし、必須の機能なのか?』
『システム管理者のみとする要件に変更が入る可能性はどれくらいあるか?』
『お知らせ1件1件をユニークに特定できる必要はあるか?(単純に、存在するお知らせが新しいものから順に出ていればいいだけとは言えないか)』
『高度なフィルタリングが必要になり得るか?』
『お知らせ毎に見せる相手を分ける必要はあるか?』

こうした掘り下げを行った結果として、以下のような要件に変わるかもしれない。
「お知らせは、権限のある運用担当者がCSV形式で最新化し、Cloud Storageにアップロードする。プログラム側ではCSVの更新を検知したら、画面の表示を最新化する。」
RDBに永続化する方式とどちらが楽(現在、そして未来)かは一概には言い切れない。
ただ、最初からRDBありきで、他の機能にも高度な検索フィルタ機能が付いているがゆえに、顧客から「他の画面と同じように保存してるなら、使うかわからないけど『お知らせ』にも付けといて。」と言われるのは避けたい。
”こういう理由で必要だから”実装するようにしたい。(ないしは、”確実とは言えないが、こういう仮説で、求められている機能だと考えられるから”実装するようにしたい。)

Step.05: コンパイルを通す

ユースケースについては検討し、いったん下記のままで進めることとする。
「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」

実装内容(プロダクトコード)

テストコードで登場させ、未実装だったプロダクトコードを実装する。
まず、下記の通り、 usecaseパッケージ、及び、usecase/model => usecasemodelパッケージを実装する。

$ tree
.
├── gobdd_suite_test.go
├── go.mod
├── go.sum
├── notice_test.go
└── usecase
    ├── model
    │   └── notice.go   <- ★これ
    └── notice.go       <- ★これ

新規追加したプロダクトコードについてはコンパイル可能な最低限の実装を行っておく。

[usecase/notice.go]
package usecase

import (
    usecasemodel "gobdd/usecase/model"
)

func NewNotice() Notice {
    return &notice{}
}

type Notice interface {
    Create(noticeModel *usecasemodel.Notice) (string, error)
}

type notice struct {
}

func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
    // FIXME: ユースケースに合わせて実装
    return "", nil
}
[usecase/model/notice.go]
package usecasemodel

// 入力値用『お知らせ』定義
type Notice struct {
    Title       string // お知らせのタイトル
    Text        string // お知らせの文章(現時点はテキストのみサポート)
    PublishFrom int    // お知らせの掲載開始日時
    PublishTo   int    // お知らせの掲載終了日時
}

テスト結果

この状態でテスト実行すると、こうなる。テスト失敗という点は変わりないが、テスト対象のテストケースが存在する状態での初実行となる。

$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556771790
Will run 1 of 1 specs

• Failure [0.000 seconds]
Notice
/home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:11
  『お知らせ』の登録
  /home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:12
    主体が「システム管理者」である場合
    /home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:13
      表示期間を指定して『お知らせ』を登録できる。 [It]
      /home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:18

      Expected
          <string>: 
      to equal
          <string>: DUMMY

      /home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:20
------------------------------


Summarizing 1 Failure:

[Fail] Notice 『お知らせ』の登録 主体が「システム管理者」である場合 [It] 表示期間を指定して『お知らせ』を登録できる。 
/home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:20

Ran 1 of 1 Specs in 0.001 seconds
FAIL! -- 0 Passed | 1 Failed | 0 Pending | 0 Skipped
--- FAIL: TestGobdd (0.00s)
FAIL

Ginkgo ran 1 suite in 881.789325ms
Test Suite Failed

現状はCreateメソッドが常に空文字とnilを返す実装になっており、期待値として現状設定している内容と異なるためにテストが失敗する。

Step.06: ユースケースを練る

テストコードにて明確にパラメータへセットする値を定義する。
そして、期待値としても求める形を明確に定義する。

上記にて明確に(とは言え、『お知らせ』が持つ属性としても現時点で顧客からヒアリングできている情報のみを使った形となる)定義したユースケースに合致するように実装する。
ただし、今の時点では、明確に決めたユースケースに必ず合致するように実装するだけ。
やはり、永続化のことなどは考えない。

実装内容(テストコード)

[notice_test.go]
package gobdd_test

import (
    "gobdd/usecase"
    usecasemodel "gobdd/usecase/model"

    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"
)

var _ = Describe("Notice", func() {
    Describe("『お知らせ』の登録", func() {
        Context("主体が「システム管理者」である場合", func() {
            var (
                // 登録対象の『お知らせ』情報
                noticeForCreateParam *usecasemodel.Notice
            )
+           BeforeEach(func() {
+               noticeForCreateParam = &usecasemodel.Notice{
+                   Title:       "お知らせ1",
+                   Text:        "これはお知らせ1です。",
+                   PublishFrom: 1556636400,
+                   PublishTo:   1557327599,
+               }
+           })
            It("表示期間を指定して『お知らせ』を登録できる。", func() {
                id, err := usecase.NewNotice().Create(noticeForCreateParam)
                Expect(id).To(Equal("ef5198df-5c04-42ba-9fbe-2beb2794468a"))
                Expect(err).To(BeNil())
            })
        })
    })
})

テスト対象のCreateメソッドに渡すパラメータに具体的な値をセット。

実装内容(プロダクトコード)

次は、ユースケース実行ロジックに手を入れる。
ただし、あくまでテストケースを成功させるためだけの修正のため、テストコードが期待する値を返すだけの実装にする。

[usecase/notice.go]
package usecase

 〜〜 省略 〜〜

func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
*   // FIXME: 【テストコードを通すための実装】ここでは渡された『お知らせ』情報と固定のIDを返却する。
*   return "ef5198df-5c04-42ba-9fbe-2beb2794468a", nil
}

テスト結果

$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556771998
Will run 1 of 1 specs

•
Ran 1 of 1 Specs in 0.000 seconds
SUCCESS! -- 1 Passed | 0 Failed | 0 Pending | 0 Skipped
PASS

Ginkgo ran 1 suite in 929.463711ms
Test Suite Passed

ちゃんとテストケース1件が認識されて、1件成功している。
実装自体はもちろん永続化もされていないし、IDも固定値を返しているので完成とは到底言えないけど、ひとまずユースケースを満たすコードを(当然修正が必要なので「FIXMEコメント」付きで)書いてテストをPASSしたということで、まずは一安心。

考察

「コンパイルエラー」→「テスト失敗(RED)」→「テスト成功(GREEN)」の変遷を辿ることで設計・実装・テストのリズムが出来るというのがTDDのウリのひとつだと思う。
通常の感覚であれば「固定のIDと渡された構造体を返すだけでテストOKなんて、ただのバグだろう!」と思うところを、ぐっと飲み込む必要がある。
現時点では、永続化など考えていないということもあり、このテストコード並びにプロダクトコードは、(基本的に)いつ、どの環境で動かしても、Goの同じVersionのランタイムさえあれば実行できるはず。
ただ、こういういかにもテストしやすいコードに関しては、正直なところあれこれ検討することもなく、さらっとテストコード書けてしまうもの。
問題は、ここから。

今回のユースケースに関して、検討の結果、登録リクエストされた『お知らせ』情報はRDBで永続化することにした。
実サービス上ではGCPの「Cloud SQL」を想定し、タイプはMySQLなので、例えばGCPへのデプロイ前にローカル環境で動作確認をする場合はローカル環境上にMySQLをインストール(最近はDockerコンテナ使う方が多いのかな)してデータベースを構築し、プログラムからはそこに接続して永続化を行うようにすると思われる。
さて、永続化を考え、ミドルウェアを使うことを考えた時点で、一気に技術の詳細を意識せざるを得なくなった。
この時点で、(永続化を行うミドルウェアに接続するプログラムを完成後に)いつでもどの環境でもテストコードが流せるというのは無理がある。

この事態に対して取るアプローチとして経験があるのは以下の2つ。
①ローカル環境だろうとCI環境だろうと、本番と(極力)同じミドルウェアを適宜インストールしてテストコードを流す。
②アプリ外と接続する部分は全てスタブ化してテストコードを流す。
当然のことながら両者それぞれにメリット・デメリットはあり、どちらを採用するかは現場の方針によって決まっていた印象。
(テストに関する大ベテランがいた現場ではないため、どの現場も両者どちらにすべきか、割と揺れ動いていた記憶あり。(まあ、主に自分が・・・。))
思いっきり粗くメリ・デメを書く。

No 内容 メリット デメリット
環境毎にミドルウェアを用意 テストと本番とで結果差異が少ない(*1) べき等性担保が条件付きになる(*2)
ミドルウェア接続をスタブ化 純粋にロジックのテストが可能 テストOKでも本番NGケースが発生する(*3)

*1: 本番DBだけに存在するレコードの影響やミドルウェアのマイナーバージョンの違いで結果に差異が生じる可能性は0ではない。
*2: ミドルウェアとの接続は通信を介するため、成功テストケースが必ず成功するとは限らない。
*3: テストケースは、あくまで「こういう結果をミドルウェアは返すはず」という想定でスタブ化するため、本番ミドルウェアの挙動が想定外となってエラーが起きる可能性はある。

TDDを扱いはじめの頃は、ユニットテストなので外部との(通信を介する)接続部分は全てスタブ化して、純粋に単体機能の挙動をテストすべきと考えていた。
ただ、昨今のクラウドに載せる前提のアプリの場合、単体機能といえど、重要なロジックのIN/OUT、及び、その結果に応じたロジックに至るまで、クラウドサービスとの通信の結果(そして、それは各サービスの技術特性が大きく関わる)が大きく作用するようになっており、そこをスタブ化するとロジックの大部分がテストの意味を持たなくなる機能まで出てくる。
こうなってくると、テストコードにおいて、頑なに”ユニット”であること、また、べき等であることにこだわるよりも、極力本番と同じサービス(の別インスタンス)に(テストコードからも)接続する方が意味のあるテストになるのではないか。
そのような考えから、
スタブは用いずテストコードからも外部サービスとの接続前提としたことがあった。
その結果、外部サービスがRDBぐらいであればおそらく問題なかったが、キャッシュはRedis、ファイルのアップロード・ダウンロードにストレージサービス(AWSで言うS3、GCPで言うCloud Storage)、機能によっては別のWebAPIサービスから情報を取得といったように”外部”が増えてくると、テストコードの実行が必要な環境毎にそれぞれの外部サービスの(テスト用)インスタンスを用意する必要に迫られ、そのあたりの情報の管理コストが(メリットを上回った?と思えるほど)肥大化した。

結局、今の考えとしては、以下に落ち着いている。
あまり細かい(技術詳細に依存する)部分までテストコードにおこすのではなく、業務用件の本質部分というかエッセンスというか、今回題材としている”ユースケース”といった部分を仕様としてテストケースにおこす。
あとは、実装詳細が”外部サービス”とのやりとりに関する部分であれば、基本的なパターンのみスタブを設け、それ以外のケース(特に”外部サービス”固有の癖のある部分)は結合テスト(※できれば、その前段として”単一機能内の外部サービスとの結合テスト”という名目のフェーズを設けたい。)に委ねる。

Step.07: 永続化ロジックの実装(インターフェース呼び出し部分)

登録リクエストを受けた『お知らせ』情報をRDB(Cloud SQL)に永続化するコードを書く。
具体的には下記を”実際に永続化する”実装に変える。

[usecase/notice.go]
func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
    // FIXME: 【テストコードを通すための実装】ここでは渡された『お知らせ』情報と固定のIDを返却する。
    return "ef5198df-5c04-42ba-9fbe-2beb2794468a", nil
}

事前検討

Step.06 の考察で”ミドルウェアとの接続ロジックはスタブで基本的なパターンをテストする”とした。
スタブを実装するには”スタブでない実装”も当然必要になるわけで、必然的に「お知らせ情報を永続化する」機能に関して、呼び出し状況に応じて詳細な実装を切り替えられる仕組みを実装することになる。
Goにはインターフェースがあるので、まずはインターフェースを定義し、その後、本来のプロダクトコード(実際にRDBに接続するロジック)とテストコード(スタブ)のそれぞれを実装する。

ソース追加後ツリービュー

$ tree
.
+ ├── domain
+ │   ├── model
+ │   │   └── notice.go
+ │   └── notice.go
  ├── gobdd_suite_test.go
  ├── go.mod
  ├── go.sum
* ├── notice_test.go
  └── usecase
      ├── model
      │   └── notice.go
*     └── notice.go

これまでのユースケース層のパッケージから実装の詳細を呼び出すに際し、上述の通り、まずはインターフェースを定義する。
(パッケージは「domain」とする。)
そして、ユースケース層からそのインターフェースを呼び出すコードを書く。

実装内容(ドメイン層のインターフェース)

[domain/notice.go]
package domain

import (
    domainmodel "gobdd/domain/model"
)

type Notice interface {
    Create(noticeModel *domainmodel.Notice) (string, error)
}
[domain/model/notice.go]
package domainmodel

type Notice struct {
    ID          string
    Title       string
    Text        string
    PublishFrom int
    PublishTo   int
    CreatedAt   int
    UpdatedAt   int
}

実装内容(ユースケース層)

[usecase/notice.go]
package usecase

import (
    "gobdd/domain"
    domainmodel "gobdd/domain/model"
    usecasemodel "gobdd/usecase/model"

    "github.com/google/uuid"
)

* func NewNotice(noticeDomain domain.Notice) Notice {
*   return &notice{noticeDomain: noticeDomain}
}

type Notice interface {
    Create(noticeModel *usecasemodel.Notice) (string, error)
}

type notice struct {
*   noticeDomain domain.Notice
}

func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
*   // ドメインモデルへの変換(ユースケース層独自の構造からドメイン層独自の構造への変換(例:日時の持ち方や「姓」と「名」別持ちから「姓名」等))
+   domainModel := &domainmodel.Notice{
+       ID:          uuid.New().String(),
+       Title:       noticeModel.Title,
+       Text:        noticeModel.Text,
+       PublishFrom: noticeModel.PublishFrom,
+       PublishTo:   noticeModel.PublishTo,
+   }
*   return n.noticeDomain.Create(domainModel)
}

「お知らせ情報を永続化する」機能の具体的な処理はドメイン層に委譲させた。
ドメイン層のロジックを呼び出すために、NewNotice関数を改良して、具体的なドメイン層実装ロジックを受け取れるようにしておく。
こうすることで、実際にどこに接続(ローカルなのかクラウドなのか外部APIなのか)するかを意識せずにユースケースをテストすることができる。
外部に依存する部分はNewNotice関数に渡すドメインロジックの構造体が担っているので、スタブ用の構造体を渡すことでどのようにでも制御できる。
(正直なところ、これだけでは呼び出すドメイン層のロジックがロジックの全てになるので、「じゃあ、テストする意味ないのでは?」と思われてしまう。前段にバリデーションや(複数のドメインロジックを呼び出す想定で)トランザクション制御などが入ると有用性が感じられると思うが、いったんこのままで。)

ツリー構造ふたたび

ドメイン層にはインターフェースしかないので、当然具体的な実装ロジックが必要。
それらは、実プロダクト用としてはもちろん、テスト時のスタブ用、または、ローカル環境で動かす時専用だったりと(同じインターフェースを持つ)さまざまな具象ロジックが想定される。
以下の通り、adapterというパッケージを作り、今回で言うと”永続化”をどう実装するかのパターンに応じてサブパッケージを作ることにした。

このあたりのパッケージの切り方、Goファイル名の付け方には、アプリの規模やサービスの特性等さまざまな要因から最適(でなくても、よりベター)な切り口を見つける必要がある。
どんなアプリかにより、ある程度のパターンやプラクティスは見つけられると思うが、すべてに通用するものを見つけるのは難しいと思われる。(今ベストと思われるものも、時の洗礼を受けた結果どうなるかはわからない。)

$ tree
.
+ ├── adapter
+ │   └── gateway
+ │       ├── gcp
+ │       │   └── notice.go
+ │       ├── local
+ │       │   └── notice.go
+ │       └── test
+ │           └── notice.go
  ├── domain
  │   ├── model
  │   │   └── notice.go
  │   └── notice.go
 〜〜 省略 〜〜

実装内容(domainパッケージのインターフェースを実装するテスト用のスタブ)

実際に永続化を行うのではなく、テストがしやすいように期待値をあらかじめセットすることができる作りにする。

[adapter/gateway/test/notice.go]
package testgateway

import domainmodel "gobdd/domain/model"

type NoticeImpl struct {
    ExpectID    string
    ExpectError error
}

func (n *NoticeImpl) Create(noticeModel *domainmodel.Notice) (string, error) {
    // 事前にセットされた期待値を返すだけ
    return n.ExpectID, n.ExpectError
}

実装内容(テストコード)

[notice_test.go]
package gobdd_test

import (
    testgateway "gobdd/adapter/gateway/test"
    "gobdd/domain"
    "gobdd/usecase"
    usecasemodel "gobdd/usecase/model"

    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"
)

var _ = Describe("Notice", func() {
    Describe("『お知らせ』の登録", func() {
        Context("主体が「システム管理者」である場合", func() {
+           const (
+               ExpectID = "ef5198df-5c04-42ba-9fbe-2beb2794468a"
+           )
            var (
+               // 『お知らせ』情報を登録するロジック
+               noticeDomain domain.Notice
                // 登録対象の『お知らせ』情報
                noticeForCreateParam *usecasemodel.Notice
            )
            BeforeEach(func() {
+               // 期待値をセットできるテスト用のスタブドメインロジックを使うことで、外部サービス接続ロジックを回避したテストが可能
+               noticeDomain = &testgateway.NoticeImpl{
+                   ExpectID:    ExpectID,
+                   ExpectError: nil,
+               }
                noticeForCreateParam = &usecasemodel.Notice{
                    Title:       "お知らせ1",
                    Text:        "これはお知らせ1です。",
                    PublishFrom: 1556636400,
                    PublishTo:   1557327599,
                }
            })
            It("表示期間を指定して『お知らせ』を登録できる。", func() {
                id, err := usecase.NewNotice(noticeDomain).Create(noticeForCreateParam)
*               Expect(id).To(Equal(ExpectID))
                Expect(err).To(BeNil())
            })
        })
    })
})

テスト結果

期待値通りにセットしたドメイン層のロジックを使っているのだから当然ではあるが、テストは成功する。

$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556778608
Will run 1 of 1 specs

•
Ran 1 of 1 Specs in 0.000 seconds
SUCCESS! -- 1 Passed | 0 Failed | 0 Pending | 0 Skipped
PASS

Ginkgo ran 1 suite in 1.000235799s
Test Suite Passed

考察

レイヤーの切り方やパッケージ名についてはクリーン・アーキテクチャヘキサゴナルアーキテクチャを意識している。

今回は、domainパッケージにインターフェースを定義して、その具体的な実装をadapter/gateway/xxxパッケージに持たせた。
今回くらいのアプリの規模感であればこの切り方でも良いと思うが、もう少し規模が大きい(ないし大きくなる)ことが想定される場合は、domainパッケージ下をもう1段階掘り下げた方がいい。
戦術的な要素だけ取り入れるのはよくないとは言われているものの、DDDを参考にするなら以下のようなイメージ。
以下の中の「repository」部分が外部サービスとの接続ロジックに相当するので、そこをインターフェースにして切り替えるイメージ。
※その他のパッケージについてはDDDの説明になるので説明は省略。以下を参照されたし。
https://codezine.jp/article/detail/9546

  └── domain
      ├── aggregate
      │   └── notice.go
      ├── entity
      │   └── notice.go
      ├── factory
      │   └── notice.go
      ├── repository
      │   └── notice.go
      ├── service
      │   └── notice.go
      └── valueobject
          └── xxxx.go

まとめ

Ginkgoをツールとして使ったテストファースト開発の試行について、ここまでで、外部依存するケースのインターフェースを用いたロジック切り替えを実現。いったんの区切りまでは到達したのと、だいぶ長くなってきたので、今回はここまで。
ただし、実際に外部依存するロジックを実装する側については未実装なので、次回はそこを実装。
このあたりは、いわゆるDI(Dependency Injection)が必要になる世界。
以下で書いたGoogleのwireを利用してみようと思う。
go1.11+google/wireによるDependency Injection

また、そもそもユースケースにあった「システム管理者は」の部分と「表示期間を指定して」の部分に関するテストケースが設けられていないので、そこも次回以降で。

1ユースケースについて練り上げていく過程を出しただけでは、当然のことながら実運用で適用できるものとは言い切れない。
このあたりは、ユースケースをなるべく業務レベルで起こり得そうな規模まで増やしてみることが必要だけど、出来うるなら、実業務でトライする機会が欲しいところ。

今時点のソース全量は↓
https://github.com/sky0621/gobdd/tree/19b34328101f6ba70190f8dde506efe258fa4e5f

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

何をすればいいのかわからない!GO言語の第一歩。

直近でGo言語の知識が必要になりそう・・・。
とりあえず何から初めていいのかわからないので「Progate」で触りから学んでみた。

Googleが開発したプログラム言語「Go言語」の特徴

文法がシンプルで学びやすくチーム開発にもおすすめ、実行速度が早いのも特徴。

基本的な記述

ファイル名は「.go」、JavaScriptのようにfunction(){}で関数を定義して実行するみたい。
ただし記述の仕方は若干短くてちょっと嬉しい。

func main() {
  println("Hello, Go")
}

Go言語ファイルの構成

  • パッケージ定義部分
  • 関数定義部分

上記の2つで構成されている。

※コメントの記述方法はJavaScriptと一緒。
※文字列はダブルクォーテションで囲む

// ▼パッケージの定義
package main

// ▼関数の定義
func main() {
  println("Hello, Go")
}

変数

変数は「var 変数名 データ型」で定義します。

var number int
var name string

変数の型が数字であれば「int」省略することができます。

// 型の定義は省略することができる
// ※整数である場合は「int」を記述しなくても数字型として扱われる
var number = 100

// :=(コロンとイコール)で記述することでも「int」で定義するのと同じ意味になります
// varも省略できる
number := 100

変数に関する注意点

基本的には他の言語と大きく大差はない気がしますが、一応。

  1. 同じ変数を定義するとエラーになる
  2. 定義する前には利用できない
  3. 使ってない変数があるとエラがでる

それぞれエラーが出た時にコンソールでは下記のようなエラー文が出力されます。

// 1 「:= の左側に新しい変数がない」
no new variables on left side of :=

func main() {
  a := 100
  a := 100 エラー発生
  println(a)
}

// 2 「変数nが定義されていない」
undefinded:n

func main() {
  println(n)エラー発生
  n := 100
  println(n)ここでは使える
}

// 3 「変数bが使用されていない」
b declared and not used
func main() {
  a := 100
  b := 200
  println(a)エラー発生
}

if文

func main() {
  x := 5

  if x > 20 {
    println("xは20より大きいです") 
 } else if x == 20 {
    println("xは20です") 
  } else {
    println("xは20より小さいです")
  }
}

// 条件式を複数の条件とマッチさせたい場合は「&&(かつ)」や「||(または)」を利用する

switch文

func main() {
  n := 3
  switch n {
    // caseを追加し、nが0の場合、"凶です"と出力してください
    case 0 :
      println("nは0です")

    // caseを追加し、nが1または2の場合、"吉です"と出力してください
    case 1, 2 :
      println("nは1または2です")

    // caseを追加し、nが3または4の場合、"中吉です"と出力してください
    case 3, 4 :
      println("nは3または4です")

    // caseを追加し、nが5の場合、"大吉です"と出力してください
    case 5 :
      println("nは5です")  
  }
}

まとめ

さわりだけやってみましたが、印象としてはJavaScriptとよく似ている気がします。
少しプログラミング言語をさわったことがある人にとってはとっつきやすい言語なんじゃないかと思います。
これからもう少し触ってみてもう少し複雑なことができるようになっていきたいです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vim/Neovim の terminal mode の中で Vim/Neovim を実行するときに便利そうなやつを作った

つくった


画像は https://ghlinkcard.com/ から生成
参考: みんなにOSSを見てもらいたい人の為に、GitHubリポジトリのOGP的画像を自動生成してくれるサービスを作った

どういうやつ?

Vim/Neovim の terminal mode で Vim/Neovim を実行すると Vim/Neovim が入れ子に起動してしまう
asciicast

これを回避するには Neovim の場合は mhinz/neovim-remote を使うとよい
asciicast

Vim8 の場合でも clientserver 機能や terminal-api を利用すればよい
参考(clientserver): Vim の :terminal の中から外の Vim を操る方法
参考(clientserver): :h clientserver
参考(terminal-api): :terminal に関する小さい Tips
参考(terminal-api): :h terminal-api

しかしいずれにせよ、vim/nvim の中と外で実行するコマンドを切り替えねばならんというのは面倒である
なので、自身が vim/nvim の中なのか外なのかをよしなに判断して上記の回避策を実行してくれるツールを作成した
先行研究もあったが、Vim8 のみ対応のようだったので Neovim にも対応する形で実装してみた
参考: Vim in Vim しない :terminal

どうやってつかう?

こんな感じ
asciicast

あとは alias vim=vimalter みたいな感じで shell の alias 登録すれば、どこでもとりあえず vim って実行すればいい感じに vim/nvim が開く

なにをしている?

vimalter コマンドを実行しているのは vim なのか nvim なのか shell なのか

vim/nvim は terminal mode のとき、いくつか環境変数を追加してくれているので、それを利用した
環境変数 VIMRUNTIME に runtimepath を入れてくれているようなので(ex. VIMRUNTIME=/usr/share/nvim/runtime)一つ上のディレクトリ(ex. /usr/share/nvim) の部分が nvim なのか vim なのかで判定した

もしかすると自前で vim/nvim を build してたりするとここに vim or nvim が入っておらず、上手く動かないかもしれない

neovim の場合

単純に引数を nvr コマンドに食わせて実行すればよい

vim の場合

vim は少し複雑で :term する前に call remote_startserver('hogehoge') を叩いているかどうかで挙動を変える必要がある
remote_startserver()+clientserver オプションを付けて build された vim でしか実行できない関数で、引数に指定された名前(例では hogehoge)で vim サーバを起動するコマンドである

これが実行されていれば terminal mode では 環境変数 VIM_SERVERNAMEhogehoge と入っているので vim --servername $VIM_SERVERNAME --remote file_name というコマンドを実行すれば親の vim で file_name を開いてくれる

call remote_startserver('hogehoge') されていない場合は環境変数 VIM_SERVERNAME は空なので他の方法を考える必要がある
今回は terminal-api を利用した

terminal-api の詳細は先に挙げた参考資料に任せるとして、とにかく echo -e \x1b]51;[\"drop\",\"file_name\"]\x07 と terminal mode で実行すれば親の vim で file_name に指定したファイルを開いてくれる

vim の実行ファイルを探索する

/usr/bin/vim が vim だといつから錯覚していた?

はい、debian 系(というか update-alternatives を使用している場合などは大抵) /usr/bin/vim とはただのシンボリックリンクである
実態はどこにいるかは辿ってみないと分からないので雑に vim に引数を食わせるわけにはいかない
ちゃんと現在 terminal mode で実行中の vim の実 path を辿ってやる必要がある
方法は愚直に PPID を辿って vim または vim.basic(debian で apt で入れた vim の実行ファイル名はこれになる) という文字列が実行ファイル名になっているものを探す
apt でインストールされた vim の実行ファイル名は他にも vim.tiny とかもあるが、vim.tiny は terminal mode が使用できないので除外した
他にも実行ファイル名のパターンがあれば Issue か PR をください...!
この実装をする際 shirou/gopsutil が非常に便利だった

-tab option

Vim の --remote オプションは親でファイルを開く際、ファイルをウィンドウ分割して開く、という挙動となっており、個人的には --remote-tab の挙動のほうが好みだったので -tab オプションで制御できるようにした
弊害として、vim 側のオプションを渡す際は -- を付ける必要があるが、まあ大抵こういうコマンドはそうなってるのでいいかな、と(適当)

vim --remote-tab だと、既存に空ファイルのウィンドウがあればそのウィンドウでファイルを開き、それ以外の場合は新規に tab を作成してそこでファイルを開く、という挙動をする

このとき困るのは terminal-api での実装で、現状 drop コマンド(つまり挙動としては --remote と同じ)しか雑にファイルを開くときには使えないので --remote-tab を真似ることはできない
call コマンドというユーザ定義関数を呼ぶコマンドもあるにはあるが、Tapi_ で始まるユーザ定義関数しか呼べない、という制限があるため現状は未実装ということにしている

今後の予定として Tapi_ 関数群を提供する Vim plugin を書いて、この挙動を実装する方針である

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSのヘルスケアデータをCSVに変換する

iOS(iPhone)やApple Watchで溜めているヘルスケアデータを、
再利用するためにデータ出力をしたが、xml形式で使いにくかったのでCSVに変換してみようというお話。

githubリポジトリ

当方環境

  • iPhone7 iOS 12.2
  • Apple Watch gen3(だった気がする)

やり方

  • iPhoneのHealthアプリケーションから、Export Health Dartaを選択し、zipファイルをエクスポートさせる。

IMG_1219.jpg

⇨しばらくするとファイルをどうするか聞かれるので、任意の方法でPCに送る
当方はmacでiCloud連携を使用してデスクトップにおきました。

  • zipファイルを解凍する

    • ZIPファイルを解凍すると以下のようなディレクトリ構成になっています。
        .
         ├── README.md
         ├── apple_health_export
         │   ├── export.xml
         │   └── export_cda.xml
    
    • export_cda.xmlは中身がよくわからなかったので無視。
  • 以下、編集するためのプログラムです。

package main

import (
    "encoding/xml"
    "io/ioutil"
    "log"
    "os"

    "github.com/gocarina/gocsv"
)

type HealthData struct {
    XMLName xml.Name `xml:"HealthData"`
    Record  []Record
}

type Record struct {
    Type          string `xml:"type,attr" csv:"type"`
    SourceName    string `xml:"sourceName,attr" csv:"-"`
    SourceVersion string `xml:"sourceVersion,attr" csv:"-"`
    Unit          string `xml:"unit,attr" csv:"unit"`
    CreationDate  string `xml:"creationDate,attr" csv:"-"`
    StartDate     string `xml:"startDate,attr" csv:"startDate"`
    EndDate       string `xml:"endDate,attr" csv:"endDate"`
    Value         string `xml:"value,attr" csv:"value"`
}

func main() {

    // open xml file.
    xmlFile, err := os.Open("./apple_health_export/export.xml")
    if err != nil {
        log.Fatal(err)
    }
    defer xmlFile.Close()

    // read xml data.
    xmlData, err := ioutil.ReadAll(xmlFile)
    if err != nil {
        log.Fatal(err)
    }

    // parse xml and export to csv.
    csvFile, err := os.Create("./export.csv")
    if err != nil {
        log.Fatal(err)
    }
    defer csvFile.Close()

    var healthData HealthData
    xml.Unmarshal(xmlData, &healthData)
    records := healthData.Record
    gocsv.MarshalFile(&records, csvFile)

}

ポイント

HealthData内にRecordとして複数データが含まれているデータ構造だったので、
HealthData構造体とRecord構造体を用意しました。

Record構造体から抜き出す項目は csv:"xxx"として記述し、
抽出不要項目はcsv:"-"とすることでparserが良い感じに処理してくれます。

改善ポイント

  • メモリ消費が非常に大きい
    • ファイルサイズが大きいとそれに比例してメモリ使用量が増える

最後に

走り書きですが、とりあえず使えそうなデータが取れたので、
CSVを使って何かできないか試してみたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UbuntuでGoの開発環境をインストールしてHelloWorldしよう!

はじめに

この記事では、

1. UbuntuにGo言語の開発環境をインストールする
2. HelloWorldする

の2つについて書いていきまーす

前提環境

  • Ubuntu 18.04.2 LTS

1. UbuntuにGo言語の開発環境をインストールする

ctrl+alt+t キーを押すことでターミナルを開きます。

Ubuntuでターミナルを開いて以下のコマンドを入力します。


$ sudo apt install golang

これでgoのインストールが完了するはずです!

1-1.png

次は、ちゃんとgoの開発環境が入ってるか確認します。

以下のコマンドを実行することで、現在入っているgoのバージョンを確認できます。

$ go version
go version go1.10.4 linux/amd64

1-2.png

2. HelloWorldする

それでは無事goの環境構築ができたところで、HelloWorldをしましょう!

以下のコードを入力してください。

hello.go
package main

import "fmt"

func main() {
  fmt.Printf("Hello World!\n")
}

そして、ターミナルでファイルを保存したディレクトリを開き、以下のコマンドを実行するとHello World完了です!

$ go run hello.go
Hello World!

2-1.png

まとめ

この記事では、Go言語の開発環境を構築し、Hello Worldをしました!

今後もGoに関連する記事を書いていくのでよろしくおねがいします!

参考記事

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む