はじめに
こんにちは、STORESの高田です
今回は Playwright を用いて RESTful API のシナリオテストを実装したことについてご紹介します
Playwright ではAPIの操作フローをプログラムで書けるため、想定利用ケースをコードで明示できる上、融通の効くテストが書けます。「初期ユーザを準備する」といった何度も行う処理を再利用可能な状態で切り出せたり、ヘッダー・認証・ミドルウェア等の処理を通したテストができるため、より実態に近い状態でテストできるメリットがあります
ただ、ここで直面する問題として、公開されたAPIだけではシナリオが完結できないケースが出てきます。例えば、サインアップ処理時に必要なトークンをデータベースから取得せざるを得ないケースなど、シナリオテストを実装するにあたって、どうしてもデータベースの中身を見たくなるケースが出てきます
それに対する一つの解決策として、データベースとシナリオテストの間に proxy を実装してデータを操作できるようにしています。その具体例についてご紹介します
シナリオテストからデータベースを操作する
シナリオテストを実装するにあたって、どうしてもデータベースの中身を見たくなるケースでは、データベースとシナリオテストの間に proxy を実装しておくと自由にデータを参照でき、テストの自由度が上がります。データの取得(SELECT文)だけでなく、削除(DELETE文)なども実行できるとより利便性が上がります
データの取得
都合により疑似コードとなりますが、まずは使用例から説明します。利用側は以下のように、SQLを利用してデータを取得します
test.describe("/user", () => { let ctx: APIRequestContext; let pgAPICtx: APIRequestContext; test.beforeAll(async ({ playwright }) => { ctx = await request.newContext({ baseURL: "http://api:8080", }); pgAPICtx = await request.newContext({ baseURL: "http://pg-api:3020", }); }); test("signup and reset password", async ({ page }) => { // signup 処理がここにある const fooTokenRes = await pgAPICtx.post(`/select`, { data: { query: `SELECT * FROM foo_tokens WHERE id = $1`, bind: [id], }, }); expect(fooTokenRes.ok()).toBeTruthy(); const fooTokenResBody = await fooTokenRes.json();
取得したデータは fooTokenResBody[0].token
のように、後続のテストで使えるようになります
データの削除
DELETE 文も発行でき、データの初期化も次のように可能になっています
export async function clearFooLogs() { const pgAPICtx = await newPgAPICtx(); const fooLogRes = await pgAPICtx.post(`/exec`, { data: { query: `DELETE FROM foo_logs`, bind: [], }, }); expect(fooLogRes.ok()).toBeTruthy(); }
proxy の実装
proxy の実装は Go で行っており、以下のような proxy を開発用の docker compose services に追加することで、シナリオテストから JSON を POST することでデータベース操作が可能になります
プログラムとしては短いですし、動けば良い類のアプリケーションなのであまり凝ったことはせずに済ませてしまっています
package main import ( "encoding/json" "fmt" "log" "net/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" // PostgreSQL Driver ) var DB *sqlx.DB func init() { dsn := fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Tokyo", // fill args here ) db, err := sqlx.Open("postgres", dsn) if err != nil { log.Fatal(err) } DB = db } func main() { r := chi.NewRouter() r.Use(middleware.Logger) r.Post("/select", handleSelect) r.Post("/exec", handleExec) log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", 3020), r)) } func handleSelect(w http.ResponseWriter, r *http.Request) { payload := struct { Query string `json:"query"` Bind []any `json:"bind"` }{} if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { log.Println(err) w.WriteHeader(http.StatusInternalServerError) return } log.Printf("query: %s\tbind: %s\n", payload.Query, payload.Bind) rows, err := DB.Queryx(payload.Query, payload.Bind...) if err != nil { log.Println(err) w.WriteHeader(http.StatusInternalServerError) return } results := []map[string]any{} for rows.Next() { result := make(map[string]any) if err := rows.MapScan(result); err != nil { log.Println(err) w.WriteHeader(http.StatusInternalServerError) return } results = append(results, result) } if err := json.NewEncoder(w).Encode(results); err != nil { log.Println(err) w.WriteHeader(http.StatusInternalServerError) return } return } func handleExec(w http.ResponseWriter, r *http.Request) { payload := struct { Query string `json:"query"` Bind []any `json:"bind"` }{} if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { log.Println(err) w.WriteHeader(http.StatusInternalServerError) return } log.Printf("query: %s\tbind: %s\n", payload.Query, payload.Bind) _, err := DB.Exec(payload.Query, payload.Bind...) if err != nil { log.Println(err) w.WriteHeader(http.StatusInternalServerError) return } return }
事例として参考にしてもらえればと思います