背景・目的
以前、Swagger UIをGitHub Actionsで公開しました。その際に利用した設定ファイルを使用してコードを自動生成し、
今回は、oapi-codegen
について、特徴を整理し、動作を確認してみます。
また、uber-go/mock
を使用してモックを生成して単体テストも試します。
まとめ
下記に、特徴を整理します。
特徴 | 説明 |
---|---|
oapi-codegenとは | OpenAPI 仕様を Go コードに変換するためのコマンドライン ツールおよびライブラリ |
oapi-codegenのメリット | OpenAPI 3.0に基づくサービスの作成や統合に必要な定型文を削減できる。 これにより、ビジネス ロジックの作成と、組織にとっての真の付加価値の創出に注力できる |
oapi-codegenのサポート | ・サーバーサイドの定型文の生成 ・クライアント API 定型文の生成 ・型の生成 ・大規模な OpenAPI 仕様を複数のパッケージに分割する |
uber-go/mockとは | Go プログラミング言語用のモックフレームワーク Go の組み込みテスト パッケージとうまく統合されているが、他のコンテキストでも使用できる |
uber-go/mockの背景 | Google の golang/mock リポジトリから生まれたが、Google はこのプロジェクトを保守しなくなった。 Uber 内で gomock プロジェクトが頻繁に使用されていることを考慮して、Uber では今後これをフォークして保守することになった |
概要
oapi-codegen
下記のページを基に整理します。
https://github.com/oapi-codegen/oapi-codegen
oapi-codegen is a command-line tool and library to convert OpenAPI specifications to Go code, be it server-side implementations, API clients, or simply HTTP models.
- OpenAPI 仕様を Go コードに変換するためのコマンドライン ツールおよびライブラリ
- サーバー側の実装
- API クライアント
- 単純な HTTP モデルなど
Using oapi-codegen allows you to reduce the boilerplate required to create or integrate with services based on OpenAPI 3.0, and instead focus on writing your business logic, and working on the real value-add for your organisation.
- OpenAPI 3.0に基づくサービスの作成や統合に必要な定型文を削減できる。これにより、ビジネス ロジックの作成と、組織にとっての真の付加価値の創出に注力できる
Features
At a high level, oapi-codegen supports:
- Generating server-side boilerplate for a number of servers (docs)
- Generating client API boilerplate (docs)
- Generating the types (docs)
- Splitting large OpenAPI specs across multiple packages(docs)
- This is also known as "Import Mapping" or "external references" across our documentation / discussion in GitHub issues
- oapi-codegenは下記をサポートしている
- サーバーサイドの定型文の生成
- クライアント API 定型文の生成
- 型の生成
- 大規模な OpenAPI 仕様を複数のパッケージに分割する
uber-go/mock
下記を基に整理します。
gomock is a mocking framework for the Go programming language. It integrates well with Go's built-in testing package, but can be used in other contexts too.
- gomock は、Go プログラミング言語用のモック フレームワーク
- Go の組み込みテスト パッケージとうまく統合されているが、他のコンテキストでも使用できる
This project originates from Google's golang/mock repo. Unfortunately, Google no longer maintains this project, and given the heavy usage of gomock project within Uber, we've decided to fork and maintain this going forward at Uber.
- このプロジェクトは、Google の golang/mock リポジトリから生まれた
- Google はこのプロジェクトを保守しなくなった
- Uber 内で gomock プロジェクトが頻繁に使用されていることを考慮して、Uber では今後これをフォークして保守することになった
実践
前提
- Goがインストールされていること
oapi-codegenを使用した実装
プロジェクトの作成
-
VSCodeのワークスペース用に、ディレクトリを作成します
% mkdir oapi-codegen-test % cd oapi-codegen-test
-
VSCodeでフォルダを追加します
oapi-codegenのインストール
- oapi-codegenをインストールします
% go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest go: downloading github.com/oapi-codegen/oapi-codegen v1.16.3 go: downloading github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 go: downloading github.com/getkin/kin-openapi v0.127.0 go: downloading golang.org/x/text v0.18.0 go: downloading golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d go: downloading github.com/speakeasy-api/openapi-overlay v0.9.0 go: downloading gopkg.in/yaml.v3 v3.0.1 go: downloading github.com/vmware-labs/yaml-jsonpath v0.3.2 go: downloading github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 go: downloading github.com/go-openapi/jsonpointer v0.21.0 go: downloading github.com/invopop/yaml v0.3.1 go: downloading github.com/perimeterx/marshmallow v1.1.5 go: downloading github.com/go-openapi/swag v0.23.0 go: downloading github.com/mailru/easyjson v0.7.7 go: downloading github.com/josharian/intern v1.0.0 go: downloading github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 %
- インストールを確認します。(バージョンを確認します)
% oapi-codegen -version github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen v2.4.1 %
クライアントコードを生成
-
専用のディレクトリを作成します
% mkdir client
-
githubリポジトリのコードを指定して、クライアントコードを生成します
% oapi-codegen -generate types,client -o ./client/api_client.go -package client https://raw.githubusercontent.com/XXXXX/swagger-test/refs/heads/main/swagger-tutorial.yml
-
コードが生成されました。内容を確認します
oapi-codegen-test % ls client/api_client.go client/api_client.go oapi-codegen-test % cat client/api_client.go // Package client provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package client import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "github.com/oapi-codegen/runtime" ) const ( BasicAuthScopes = "BasicAuth.Scopes" ) // GetArtistListParams defines parameters for GetArtistList. type GetArtistListParams struct { // Limit Limits the number of items on a page Limit *int `form:"limit,omitempty" json:"limit,omitempty"` // Offset Specifies the page number of the artists to be displayed Offset *int `form:"offset,omitempty" json:"offset,omitempty"` } // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error // Doer performs HTTP requests. // // The standard http.Client implements this interface. type HttpRequestDoer interface { Do(req *http.Request) (*http.Response, error) } // Client which conforms to the OpenAPI3 specification for this service. type Client struct { // The endpoint of the server conforming to this interface, with scheme, // https://api.deepmap.com for example. This can contain a path relative // to the server, such as https://api.deepmap.com/dev-test, and all the // paths in the swagger spec will be appended to the server. Server string // Doer for performing requests, typically a *http.Client with any // customized settings, such as certificate chains. Client HttpRequestDoer // A list of callbacks for modifying requests which are generated before sending over // the network. RequestEditors []RequestEditorFn } // ClientOption allows setting custom parameters during construction type ClientOption func(*Client) error // Creates a new Client, with reasonable defaults func NewClient(server string, opts ...ClientOption) (*Client, error) { // create a client with sane default values client := Client{ Server: server, } // mutate client and add all optional params for _, o := range opts { if err := o(&client); err != nil { return nil, err } } // ensure the server URL always has a trailing slash if !strings.HasSuffix(client.Server, "/") { client.Server += "/" } // create httpClient, if not already present if client.Client == nil { client.Client = &http.Client{} } return &client, nil } // WithHTTPClient allows overriding the default Doer, which is // automatically created using http.Client. This is useful for tests. func WithHTTPClient(doer HttpRequestDoer) ClientOption { return func(c *Client) error { c.Client = doer return nil } } // WithRequestEditorFn allows setting up a callback function, which will be // called right before sending the request. This can be used to mutate the request. func WithRequestEditorFn(fn RequestEditorFn) ClientOption { return func(c *Client) error { c.RequestEditors = append(c.RequestEditors, fn) return nil } } // The interface specification for the client above. type ClientInterface interface { // GetArtistList request GetArtistList(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) GetArtistList(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetArtistListRequest(c.Server, params) if err != nil { return nil, err } req = req.WithContext(ctx) if err := c.applyEditors(ctx, req, reqEditors); err != nil { return nil, err } return c.Client.Do(req) } // NewGetArtistListRequest generates requests for GetArtistList func NewGetArtistListRequest(server string, params *GetArtistListParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) if err != nil { return nil, err } operationPath := fmt.Sprintf("/artists") if operationPath[0] == '/' { operationPath = "." + operationPath } queryURL, err := serverURL.Parse(operationPath) if err != nil { return nil, err } if params != nil { queryValues := queryURL.Query() if params.Limit != nil { if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err } else { for k, v := range parsed { for _, v2 := range v { queryValues.Add(k, v2) } } } } if params.Offset != nil { if queryFrag, err := runtime.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err } else { for k, v := range parsed { for _, v2 := range v { queryValues.Add(k, v2) } } } } queryURL.RawQuery = queryValues.Encode() } req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err } return req, nil } func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { return err } } for _, r := range additionalEditors { if err := r(ctx, req); err != nil { return err } } return nil } // ClientWithResponses builds on ClientInterface to offer response payloads type ClientWithResponses struct { ClientInterface } // NewClientWithResponses creates a new ClientWithResponses, which wraps // Client with return type handling func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { client, err := NewClient(server, opts...) if err != nil { return nil, err } return &ClientWithResponses{client}, nil } // WithBaseURL overrides the baseURL. func WithBaseURL(baseURL string) ClientOption { return func(c *Client) error { newBaseURL, err := url.Parse(baseURL) if err != nil { return err } c.Server = newBaseURL.String() return nil } } // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { // GetArtistListWithResponse request GetArtistListWithResponse(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*GetArtistListResponse, error) } type GetArtistListResponse struct { Body []byte HTTPResponse *http.Response JSON200 *[]struct { Albums *int `json:"albums,omitempty"` ArtistGenre *string `json:"artist_genre,omitempty"` ArtistName *string `json:"artist_name,omitempty"` Username string `json:"username"` } JSON400 *struct { Message *string `json:"message,omitempty"` } } // Status returns HTTPResponse.Status func (r GetArtistListResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } return http.StatusText(0) } // StatusCode returns HTTPResponse.StatusCode func (r GetArtistListResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } // GetArtistListWithResponse request returning *GetArtistListResponse func (c *ClientWithResponses) GetArtistListWithResponse(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*GetArtistListResponse, error) { rsp, err := c.GetArtistList(ctx, params, reqEditors...) if err != nil { return nil, err } return ParseGetArtistListResponse(rsp) } // ParseGetArtistListResponse parses an HTTP response from a GetArtistListWithResponse call func ParseGetArtistListResponse(rsp *http.Response) (*GetArtistListResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } response := &GetArtistListResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: var dest []struct { Albums *int `json:"albums,omitempty"` ArtistGenre *string `json:"artist_genre,omitempty"` ArtistName *string `json:"artist_name,omitempty"` Username string `json:"username"` } if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest struct { Message *string `json:"message,omitempty"` } if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest } return response, nil }
生成されたクライアントを使用する
- main.goファイルを作成します
- 下記のコードを実装します
- 生成されたクライアントコードは、apiというパッケージ名で使えるようになります。
package main import ( "context" "log" "net/http" // APIクライアントのパッケージをインポート // このパッケージはoapi-codegenで生成されたもの // モジュール+パッケージ名で指定 "oapi-codegen-test/client" ) func main() { // クライアントを作成 appClient, err := client.NewClientWithResponses("https://api.example.com", client.WithHTTPClient(&http.Client{})) // クエリパラメータの設定 params := &client.GetArtistListParams{ Limit: intPointer(10), Offset: intPointer(0), } // APIリクエストの送信 response, err := appClient.GetArtistListWithResponse(context.Background(), params) if err != nil { log.Fatalf("Error calling API: %v", err) } log.Printf("Response: %v", response.JSON200) // 成功時のレスポンスを表示 } // intをポインタで渡すためのヘルパー関数 func intPointer(i int) *int { return &i }
ビルド
-
go mod initでモジュールを初期化する
oapi-codegen-test % go mod init oapi-codegen-test go: creating new go.mod: module oapi-codegen-test go: to add module requirements and sums: go mod tidy oapi-codegen-test %
-
中身を確認します
oapi-codegen-test % cat go.mod module oapi-codegen-test go 1.23.2 oapi-codegen-test %
-
ビルドします
go build -o myapi-client
uber-go/mockを使用した単体テスト
テストを行うにあたり、下記のフォルダ構成とします
oapi-codegen-test/
├── client
│ ├── api_client.go # oapi-codegenで生成されたAPIクライアントコード
│ ├── model.go # oapi-codegenで生成されたデータモデル(構造体)定義
│ └── tests
│ └── client_test.go # クライアントコードの単体テスト。モックを使って検証
├── e2e
│ └── main_test.go # エンドツーエンドテスト。モッククライアントでmain.goの動作を確認
├── go.mod # Goモジュールの依存関係ファイル
├── go.sum # go.modの依存関係のチェックサム
├── interfaces
│ ├── client_interface.go # クライアントのインターフェース定義。テスト用モック生成に使用
│ └── client_mock.go # モックジェネレーター(uber-go/mock)で生成されたモック
├── main.go # アプリケーションのエントリーポイント。実行ロジックを含む
└── myapi-client # ビルドされた実行ファイル(例)
gomockパッケージのインストール
-
gomock
をインストールしますoapi-codegen-test % go get go.uber.org/mock/gomock go: downloading github.com/stretchr/testify v1.9.0 go: added go.uber.org/mock v0.5.0 oapi-codegen-test %
openapi仕様書の修正
- 下記のOpenAPIをGitHubにPushしておきます
openapi: 3.0.0 info: version: 1.0.0 title: Simple Artist API description: A simple API to illustrate OpenAPI concepts servers: - url: https://example.io/v1 # Basic authentication components: securitySchemes: BasicAuth: type: http scheme: basic security: - BasicAuth: [] paths: /artists: get: description: Returns a list of artists operationId: getArtistList parameters: - name: limit in: query description: Limits the number of items on a page schema: type: integer required: false - name: offset in: query description: Specifies the page number of the artists to be displayed schema: type: integer required: false responses: '200': description: Successfully returend a list of artists content: application/json: schema: type: array items: type: object required: - username properties: artist_name: type: string artist_genre: type: string albums: type: integer username: type: string '400': description: Invalid request content: application/json: schema: type: object properties: message: type: string
mock/mockgenのインストール
- mock/mockgenをインストールします
oapi-codegen-test % go install go.uber.org/mock/mockgen@latest go: downloading go.uber.org/mock v0.5.0 go: downloading golang.org/x/mod v0.18.0 go: downloading golang.org/x/tools v0.22.0 %
- 正常にインストールされたか確認します
oapi-codegen-test % mockgen -version v0.5.0 oapi-codegen-test %
プロジェクトの初期セットアップ
- フォルダを作成します
oapi-codegen-test % mkdir -p client/tests oapi-codegen-test % mkdir interfaces oapi-codegen-test % mkdir e2e oapi-codegen-test % tree . ├── client │ ├── api_client.go │ └── tests ├── e2e ├── go.mod ├── go.sum ├── interfaces ├── main.go └── myapi-client 5 directories, 5 files oapi-codegen-test %
OpenAPI仕様からクライアントコードとモデルの生成
クライアントコードの生成
- クライアントコードを生成します
oapi-codegen-test % oapi-codegen -generate client -o ./client/api_client.go -package client https://raw.githubusercontent.com/xxxxx/swagger-test/refs/heads/main/swagger-tutorial.yml %
-
api_client.go
を確認します// Package client provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package client import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "github.com/oapi-codegen/runtime" ) // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error // Doer performs HTTP requests. // // The standard http.Client implements this interface. type HttpRequestDoer interface { Do(req *http.Request) (*http.Response, error) } // Client which conforms to the OpenAPI3 specification for this service. type Client struct { // The endpoint of the server conforming to this interface, with scheme, // https://api.deepmap.com for example. This can contain a path relative // to the server, such as https://api.deepmap.com/dev-test, and all the // paths in the swagger spec will be appended to the server. Server string // Doer for performing requests, typically a *http.Client with any // customized settings, such as certificate chains. Client HttpRequestDoer // A list of callbacks for modifying requests which are generated before sending over // the network. RequestEditors []RequestEditorFn } // ClientOption allows setting custom parameters during construction type ClientOption func(*Client) error // Creates a new Client, with reasonable defaults func NewClient(server string, opts ...ClientOption) (*Client, error) { // create a client with sane default values client := Client{ Server: server, } // mutate client and add all optional params for _, o := range opts { if err := o(&client); err != nil { return nil, err } } // ensure the server URL always has a trailing slash if !strings.HasSuffix(client.Server, "/") { client.Server += "/" } // create httpClient, if not already present if client.Client == nil { client.Client = &http.Client{} } return &client, nil } // WithHTTPClient allows overriding the default Doer, which is // automatically created using http.Client. This is useful for tests. func WithHTTPClient(doer HttpRequestDoer) ClientOption { return func(c *Client) error { c.Client = doer return nil } } // WithRequestEditorFn allows setting up a callback function, which will be // called right before sending the request. This can be used to mutate the request. func WithRequestEditorFn(fn RequestEditorFn) ClientOption { return func(c *Client) error { c.RequestEditors = append(c.RequestEditors, fn) return nil } } // The interface specification for the client above. type ClientInterface interface { // GetArtistList request GetArtistList(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) GetArtistList(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetArtistListRequest(c.Server, params) if err != nil { return nil, err } req = req.WithContext(ctx) if err := c.applyEditors(ctx, req, reqEditors); err != nil { return nil, err } return c.Client.Do(req) } // NewGetArtistListRequest generates requests for GetArtistList func NewGetArtistListRequest(server string, params *GetArtistListParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) if err != nil { return nil, err } operationPath := fmt.Sprintf("/artists") if operationPath[0] == '/' { operationPath = "." + operationPath } queryURL, err := serverURL.Parse(operationPath) if err != nil { return nil, err } if params != nil { queryValues := queryURL.Query() if params.Limit != nil { if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err } else { for k, v := range parsed { for _, v2 := range v { queryValues.Add(k, v2) } } } } if params.Offset != nil { if queryFrag, err := runtime.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err } else { for k, v := range parsed { for _, v2 := range v { queryValues.Add(k, v2) } } } } queryURL.RawQuery = queryValues.Encode() } req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err } return req, nil } func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { return err } } for _, r := range additionalEditors { if err := r(ctx, req); err != nil { return err } } return nil } // ClientWithResponses builds on ClientInterface to offer response payloads type ClientWithResponses struct { ClientInterface } // NewClientWithResponses creates a new ClientWithResponses, which wraps // Client with return type handling func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { client, err := NewClient(server, opts...) if err != nil { return nil, err } return &ClientWithResponses{client}, nil } // WithBaseURL overrides the baseURL. func WithBaseURL(baseURL string) ClientOption { return func(c *Client) error { newBaseURL, err := url.Parse(baseURL) if err != nil { return err } c.Server = newBaseURL.String() return nil } } // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { // GetArtistListWithResponse request GetArtistListWithResponse(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*GetArtistListResponse, error) } type GetArtistListResponse struct { Body []byte HTTPResponse *http.Response JSON200 *[]Artist JSON400 *struct { Message *string `json:"message,omitempty"` } } // Status returns HTTPResponse.Status func (r GetArtistListResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } return http.StatusText(0) } // StatusCode returns HTTPResponse.StatusCode func (r GetArtistListResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } // GetArtistListWithResponse request returning *GetArtistListResponse func (c *ClientWithResponses) GetArtistListWithResponse(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*GetArtistListResponse, error) { rsp, err := c.GetArtistList(ctx, params, reqEditors...) if err != nil { return nil, err } return ParseGetArtistListResponse(rsp) } // ParseGetArtistListResponse parses an HTTP response from a GetArtistListWithResponse call func ParseGetArtistListResponse(rsp *http.Response) (*GetArtistListResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } response := &GetArtistListResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: var dest []Artist if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest struct { Message *string `json:"message,omitempty"` } if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest } return response, nil }
モデルコードの生成
- モデルコードを生成します
oapi-codegen-test % oapi-codegen -generate types -o ./client/model.go -package client https://raw.githubusercontent.com/xxxxx/swagger-test/refs/heads/main/swagger-tutorial.yml %
-
model.go
を確認します// Package client provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package client const ( BasicAuthScopes = "BasicAuth.Scopes" ) // Artist defines model for Artist. type Artist struct { Albums *int `json:"albums,omitempty"` ArtistGenre *string `json:"artist_genre,omitempty"` ArtistName *string `json:"artist_name,omitempty"` Username string `json:"username"` } // GetArtistListParams defines parameters for GetArtistList. type GetArtistListParams struct { // Limit Limits the number of items on a page Limit *int `form:"limit,omitempty" json:"limit,omitempty"` // Offset Specifies the page number of the artists to be displayed Offset *int `form:"offset,omitempty" json:"offset,omitempty"` }
インターフェースとモックの作成
インターフェースの作成
- interfaces/ディレクトリに
client_interface.go
を作成しますoapi-codegen-test % touch interfaces/client_interface.go oapi-codegen-test % ls interfaces/client_interface.go interfaces/client_interface.go oapi-codegen-test %
-
client_interface.go
を実装します// interfaces/client_interface.go package interfaces import ( "context" "net/http" "oapi-codegen-test/client" ) // ClientInterface defines the methods for the API client. type ClientInterface interface { GetArtistList(ctx context.Context, params *client.GetArtistListParams) (*http.Response, error) }
モックの生成
- モックを生成します
oapi-codegen-test % mockgen -source=interfaces/client_interface.go -destination=interfaces/client_mock.go -package=interfaces oapi-codegen-test %
- 生成したコードを確認します
// Code generated by MockGen. DO NOT EDIT. // Source: interfaces/client_interface.go // // Generated by this command: // // mockgen -source=interfaces/client_interface.go -destination=interfaces/client_mock.go -package=interfaces // // Package interfaces is a generated GoMock package. package interfaces import ( context "context" http "net/http" client "oapi-codegen-test/client" reflect "reflect" gomock "go.uber.org/mock/gomock" ) // MockClientInterface is a mock of ClientInterface interface. type MockClientInterface struct { ctrl *gomock.Controller recorder *MockClientInterfaceMockRecorder isgomock struct{} } // MockClientInterfaceMockRecorder is the mock recorder for MockClientInterface. type MockClientInterfaceMockRecorder struct { mock *MockClientInterface } // NewMockClientInterface creates a new mock instance. func NewMockClientInterface(ctrl *gomock.Controller) *MockClientInterface { mock := &MockClientInterface{ctrl: ctrl} mock.recorder = &MockClientInterfaceMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockClientInterface) EXPECT() *MockClientInterfaceMockRecorder { return m.recorder } // GetArtistList mocks base method. func (m *MockClientInterface) GetArtistList(ctx context.Context, params *client.GetArtistListParams) (*http.Response, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetArtistList", ctx, params) ret0, _ := ret[0].(*http.Response) ret1, _ := ret[1].(error) return ret0, ret1 } // GetArtistList indicates an expected call of GetArtistList. func (mr *MockClientInterfaceMockRecorder) GetArtistList(ctx, params any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetArtistList", reflect.TypeOf((*MockClientInterface)(nil).GetArtistList), ctx, params) }
テスト用ヘルパー関数の実装
テストデータの再利用
- client_test.goを作成します
oapi-codegen-test % touch client/tests/client_test.go oapi-codegen-test % ls client/tests/client_test.go client/tests/client_test.go oapi-codegen-test %
- 実装します
// client_test.go package client_test import ( "oapi-codegen-test/client" "testing" ) // NewTestArtist creates a default test artist object for testing. func NewTestArtist() *client.Artist { return &client.Artist{ ArtistName: stringPointer("Test Artist"), ArtistGenre: stringPointer("Pop"), Albums: intPointer(3), Username: "test_artist", } } func stringPointer(s string) *string { return &s } func intPointer(i int) *int { return &i } func TestGetArtistList(t *testing.T) { testArtist := NewTestArtist() // テスト処理でtestArtistを使用して検証 if *testArtist.ArtistName != "Test Artist" { t.Errorf("Expected artist name to be 'Test Artist'") } }
テストの実行
- テストを実行します
oapi-codegen-test % go test ./client/tests ok oapi-codegen-test/client/tests 0.365s oapi-codegen-test %
main.go用のエンドツーエンドテストの実装
- main_testの作成
oapi-codegen-test % touch e2e/main_test.go oapi-codegen-test % ls e2e/main_test.go e2e/main_test.go oapi-codegen-test %
- 作成します
// e2e/main_test.go package e2e import ( "context" "io/ioutil" "net/http" "oapi-codegen-test/client" // clientパッケージのインポート "strings" "testing" // interfacesパッケージのインポート ) // テスト用モッククライアントの実装 type mockClient struct{} // GetArtistListのモック実装 func (m *mockClient) GetArtistList(ctx context.Context, params *client.GetArtistListParams) (*http.Response, error) { return &http.Response{ StatusCode: 200, Body: ioutil.NopCloser(strings.NewReader(`[{"artist_name": "E2E Artist", "artist_genre": "Jazz", "albums": 1, "username": "e2e_artist"}]`)), }, nil } func TestMainFunction(t *testing.T) { // モッククライアントのインスタンスを作成 mockClient := &mockClient{} // テスト対象関数を実行し、モッククライアントを使用 response, err := mockClient.GetArtistList(context.Background(), &client.GetArtistListParams{}) // エラーチェック if err != nil { t.Fatalf("Expected no error, got %v", err) } // ステータスコードとレスポンス内容の検証 if response.StatusCode != http.StatusOK { t.Errorf("Expected status code 200, got %v", response.StatusCode) } body, _ := ioutil.ReadAll(response.Body) if !strings.Contains(string(body), "E2E Artist") { t.Errorf("Expected response to contain 'E2E Artist'") } }
- テストを実行します
oapi-codegen-test % go test ./e2e ok oapi-codegen-test/e2e 0.402s oapi-codegen-test %
考察
今回、oapi-codegenによってAPIクライアントとデータモデルを自動生成し、uber-go/mockを活用してテスト用モックを作成することで、エンドツーエンドテストと単体テストを実装しました。oapi-codegenの自動生成を利用することで、API仕様が変更された際にも手動でクライアントコードを修正する手間を削減できるのは良いと感じました。
また、uber-go/mockによるモック化によって、サーバー側の依存がなくてもAPIの動作を再現できるのは便利でした。効率的にかいはつできそうで
参考